Modal
A focus-trapped dialog: opening it moves focus inside, Tab cycles within the
dialog, Escape and a backdrop click close it, and focus returns to the trigger.
The document-level key listener is added in onCreate and removed in
onDestroy.
Markup
<button @click="open">Delete project</button>
<div data-show="isOpen">
<div class="backdrop" @click="close"></div>
<div data-ref="dialog" role="dialog" aria-modal="true" aria-labelledby="m-title">
<h2 id="m-title">Delete project</h2>
<p>This action cannot be undone.</p>
<button @click="close">Cancel</button>
<button @click="close">Delete</button>
</div>
</div>
Component
Micra.define("modal", {
state: { isOpen: false },
open() {
this._last = document.activeElement;
this.state.isOpen = true;
setTimeout(() => this.refs.dialog?.querySelector("button")?.focus(), 30);
},
close() {
this.state.isOpen = false;
setTimeout(() => this._last?.focus(), 0);
},
_onKey(e) {
if (!this.state.isOpen) return;
if (e.key === "Escape") return this.close();
if (e.key !== "Tab") return;
const items = [...this.refs.dialog.querySelectorAll("button, [href], input")];
const first = items[0],
last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
},
onCreate() {
this._k = (e) => this._onKey(e);
document.addEventListener("keydown", this._k);
},
onDestroy() {
document.removeEventListener("keydown", this._k);
},
});
data-ref="dialog" gives the focus logic a handle without reaching into the DOM
by id; the listener is a document-level concern, so it lives in the lifecycle
hooks (the one place Micra sanctions manual addEventListener).