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