Skip to content

Dialog ​

Overview ​

Modal and non-modal focus scopes with nested overlay coordination.

Pattern: WAI-ARIA Dialog (Modal). Status: stable.

When to use ​

  • Modal application surfaces that must trap focus, inert the page, and restore focus on close.
  • Non-modal overlays that still need outside-interaction handling and typed lifecycle events.
  • Nested dialog flows where Escape and outside dismissal must affect only the topmost layer.

When not to use ​

  • Static sections, accordions, or inline help that should stay in normal document flow.
  • Native browser prompts or simple links where no focus scope, overlay stack, or lifecycle hook is needed.

Import ​

ts
import { createDialog } from 'ui-headless-runtime';

Controller creation ​

Create Dialog during component mount or setup, subscribe before rendering derived UI, and keep every cleanup returned by registrations or DOM binding.

Options ​

  • Public options: closeOnEscape, closeOnOutsidePointer, id, initialFocus, modal, restoreFocus, defaultValue, getValue, onValueChange, subscribeValue.
  • Trigger, content, backdrop, anchor, and overlay branches are elements passed to bind, not creation options. Lifecycle cancellation is registered with on('beforeOpen', ...) or on('beforeClose', ...).

Snapshot ​

  • Snapshot fields: ariaModal, hasBackdrop, modal, contentId, controlled, open, position, role, topmost.
  • Dialog does not generate an accessible name or trigger metadata; consumers provide the final labelled markup.

Commands ​

  • Component commands: bind, close, open, toggle.
  • bind wires consumer elements and returns release cleanup. Shared event methods and the RuntimeController lifecycle remain available.

Events ​

  • Events: beforeClose, beforeOpen, close, open, afterClose, afterOpen, stateChange.
  • beforeOpen and beforeClose are cancellable; committed lifecycle payloads contain open and typed details.

Change reasons ​

  • Change reasons: programmatic, trigger, escape-key, outside-pointer, focus-out, selection, context-menu, keyboard, hover, focus.
  • The public open-state union is shared by overlays; a Dialog emits only reasons reached by its configured commands and dismissal paths.

Controlled mode ​

In controlled mode the dialog reports requested open changes but waits for the consumer to pass the committed open value back in.

Uncontrolled mode ​

In uncontrolled mode the controller owns open and subscribers receive a new snapshot after every accepted transition.

DOM binding ​

  • Bind the real trigger, dialog container, and optional backdrop after rendering.
  • The runtime never creates the dialog DOM; it only attaches listeners and returns cleanup.

Required markup ​

  • Use a labelled container with dialog semantics and visible title text.
  • Keep the backdrop sibling outside the focusable dialog content when modal layering matters.

ARIA contract ​

  • Apply role="dialog", aria-modal, aria-labelledby, and aria-describedby from the snapshot metadata.

Keyboard interaction ​

  • Enter / Space: Open from the trigger.
  • Escape: Close the topmost overlay and restore focus.
  • Tab / Shift+Tab: Move focus according to the configured focus scope.

Focus behavior ​

  • Opening modal content moves focus to the configured initial target or the first tabbable element.
  • If nothing is tabbable, the content container is used as the fallback.
  • Closing restores focus to the trigger when it still exists.

Nested behavior ​

  • Nested dialogs share the overlay stack; Escape and outside dismissal are ignored by ancestors while a child is topmost.

Cleanup ​

  • Call the binding cleanup before removing DOM nodes, then unsubscribe and destroy.
  • Double destroy is a no-op and pending focus restoration is guarded when the trigger has been removed.

Minimal lifecycle example ​

ts
import { createDialog } from 'ui-headless-runtime';

const controller = createDialog();
const unsubscribe = controller.subscribe((snapshot) => {
  console.log(snapshot);
});

console.log(controller.getSnapshot());
unsubscribe();
controller.destroy();

The production demo loads the exact executable module from apps/demo/src/examples/dialog.ts.

Edge cases ​

  • modal: Focus trap, inert background, scroll lock, and restore focus.
  • non-modal: Dismissible content without inerting or focus trapping.
  • nested: Only the topmost dialog handles Escape.
  • no-tabbable: The content container becomes the focus fallback.

Limitations ​

  • The runtime cannot provide the dialog title, validate copy, or guarantee final WCAG conformance for consumer markup.

API reference ​

See createDialog.

Headless behavior; consumer-owned rendering and styling.