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.
Escape and the Tab trap listen on window through the .window modifier, so
Micra removes them on destroy.
Markup
<div data-component="modal" @keydown.escape.window="close" @keydown.tab.window="trapTab">
<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>
</div>
Component
Micra.define("modal", {
state: { isOpen: false },
async open() {
this._last = document.activeElement;
this.state.isOpen = true;
await this.tick(); // the dialog is rendered now
this.refs.dialog.querySelector("button").focus();
},
close() {
if (!this.state.isOpen) return;
this.state.isOpen = false;
this._last?.focus();
},
trapTab(e) {
if (!this.state.isOpen) 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();
}
},
});
data-ref="dialog" gives the focus logic a handle without reaching into the DOM
by id. await this.tick() waits for the render before focusing: right after the
write the dialog is still display: none. The two .window listeners are on
the root and are removed with the component (Micra 2.9+).