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+).