<ui-dialog> & dialog.mjs

dialog.mjs · ui-dialog.mjs

Most application code uses show_component_dialog or response_dialog from /ui/lib/dialog.mjs. They build the dialog shell and mount its body. Use <ui-dialog> directly for static markup or when you need to manage opening and closing yourself.

API

ElementAttributesPropertiesMethodsEvents
<ui-dialog> opened, load-feedback-delay, alertdialog, aria-label .opened, .waitOn open(), close(rv?), when_closed() → Promise modal-close-request, opened-changed
dialog.mjs exportReturnsNotes
show_component_dialog(body, options?) Promise<UiDialog> Builds a <ui-dialog> shell and mounts body in <main>. body may be a tag name, an HTML markup string, an element, a promise, or an async factory. Auto-removes on close.
response_dialog(body, options) Promise<FormData|null> Wraps show_component_dialog; auto-adds Cancel/OK in the footer. Resolves to FormData from the first <form> on confirm, or null on dismissal.
open_dialog(dialog, {wait_on?}) Promise<void> Low-level open. Operates on the inner native <dialog>, not <ui-dialog>.
close_dialog(dialog, return_value?) Promise<string> Low-level close. Operates on the inner <dialog>.
dialog_closed(dialog) Promise<string> Resolves to dialog.returnValue when the inner <dialog> closes.

Common options for both helpers: title (required; shown in the header), attrs (a plain object spread onto the body element before mount), max_width (an ideal width like '42rem'; the helper wraps it in min(calc(100% - 2rem), …) to preserve viewport gutters).

1. response_dialog — collect form data

The shortest path from "I need a value back from the user" to a working dialog. The helper builds the shell, mounts the form, adds Cancel/OK to the footer, and resolves to a FormData from the first <form> in the body — or null if the user pressed Cancel, ESC, the close [×], or clicked the backdrop. Pass an HTML markup string via the html tagged template, a tag name, or an element instance.

Show code

2. show_component_dialog — tag name + attrs

When the body is a custom element that owns its own buttons and submit logic (so you don't want the auto Cancel/OK pair from response_dialog), pass a tag name. Values in attrs are set as HTML attributes on the element before connectedCallback runs, which is how the body reads its inputs. The promise resolves to the <ui-dialog> once the body is mounted; await dlg.when_closed() for the return value.

Show code

3. show_component_dialog — async factory

Pass a synchronous or asynchronous factory when the body needs data as JavaScript properties. Fetch the data, create the body element, set its properties and return it. The dialog opens after the factory resolves, with the same delayed aria-busy feedback used by waitOn.

Show code

4. Footer decoration — buttons + status

A body component contributes Cancel + Submit (and any inline status UI) into the footer slot by setting a DOM element on this.footer_decoration and dispatching decoration-changed (bubbles). The dialog moves the children into .dialog-footer-decoration, which uses display: contents so layout is unchanged. Reach for this when the body owns the submit lifecycle: disabling the button during async work, surfacing per-step status to a screen reader via role="status", and choosing the close return_value.

Show code

5. Header decoration — live status badge

Same protocol as the footer, but on .header_decoration. Use it for live status the body owns and mutates — Draft/Published, sync indicators, version numbers — that should sit next to the title. The decoration node stays live after relocation, so updating its children (or the badge text in this demo) takes effect in place without a re-render.

Show code

6. Sizing — max_width and --_max-w

Width is controlled by the custom property --_max-w on the inner <dialog>. Default is min(calc(100% - 2rem), 32rem) — capped at 32rem on wide viewports, with a 1rem gutter on each side. Keep the min(calc(100% - 2rem), …) wrapper so the dialog still shrinks on narrow screens.

At 640px and narrower, the dialog expands to full-width and anchors to the bottom (slide-up sheet); a media query overrides --_max-w there.

Do not set min-width on the dialog's inner content — <main> already adds padding: var(--size-4) on each side, so wide content will overflow. Cap content with max-width instead and let the dialog size itself.

Narrow (≈ 20rem)

Short messages can use a small maximum width and size to fit their content.

Wide (≈ 42rem)

Forms with tabs, sections, or two columns can use up to ~42rem.

Programmatic — max_width option

When opening a component dialog programmatically, pass an ideal width and the helper wraps it for you.

await show_component_dialog('app-workflow', {
  title: 'Invite',
  attrs: { 'project-id': id },
  max_width: '42rem',   // → sets --_max-w on the inner <dialog>
})
Show code (declarative widths)

7. waitOn + the .readyP protocol

For a declarative <ui-dialog> that must wait for work before opening, set .waitOn to either kind of value below, or to an array containing both:

Other values produce a console.warn and are ignored, allowing the dialog to open. Rejecting would risk leaving it closed with an unhandled promise rejection, because event handlers often do not await opening. If a valid wait exceeds load-feedback-delay (200 ms by default), the dialog sets aria-busy="true". Faster loads show no busy state. show_component_dialog uses this protocol automatically; this section explains declarative use.

Show code

8. Manual <ui-dialog> — confirm / cancel

When the dialog is part of declarative markup (server-rendered, or a fixed DOM child whose lifetime you manage), use the element directly. Buttons with [dialog-confirm] or [dialog-dismiss] close the dialog with return value "confirm" or "dismiss"; await .when_closed() to read it. The state machine queues rapid open/close calls — clicking the trigger many times in a row is safe.

Show code

9. Blocking close — modal-close-request

Listen for the cancelable modal-close-request event to block ESC, backdrop, and [dialog-dismiss] from closing. [dialog-confirm] is not blocked — it's the explicit save path. e.detail.return_value reports which path was attempted ("dismiss" for ESC/backdrop/[×], "confirm" if you ever choose to gate that too). The helpers in dialog.mjs don't expose this hook, so this is the canonical declarative usage.

Show code

10. Form-as-body (footer-as-submit)

When the body is a form whose submit is the primary action, skip Cancel/OK and put the submit in the footer with <button type="submit" form="id">. Wire form.onsubmit to this.closest('ui-dialog')?.close('submit') so submit, ESC, and backdrop all resolve through the same path. Use this for "Import"/"Send"/"Save" flows where Cancel is just a dismissal.

Show code

11. Alert dialog — alertdialog + aria-label

For destructive confirmations, add the alertdialog attribute to <ui-dialog>. This sets role="alertdialog" on the inner <dialog>, which tells screen readers to announce the dialog body on open (per APG alertdialog pattern). Place autofocus on the least-destructive button (Cancel) so focus lands there, not on the destructive action.

When the dialog has no <header><h3>, supply an accessible name via aria-label="…" on <ui-dialog> — the component forwards it to the inner <dialog>.

When using response_dialog() or show_component_dialog() from lib/dialog.mjs, pass alertdialog: true. The helper adds the attribute before connecting <ui-dialog>, because _setup_a11y() reads it only during connectedCallback. The injected Cancel button receives autofocus. Without that, the browser would focus the header's Close button when presenting a destructive confirmation.

Set alertdialog for each destructive confirmation that needs it. Do not enable it globally on a shared confirmation helper: a reversible action normally uses an ordinary dialog. Keeping the distinction makes destructive confirmations recognizable to assistive technology.

Show code

12. Low-level — open_dialog / close_dialog / dialog_closed

For long-lived dialogs you keep in the DOM across opens (avoid recreating the body, retain references) the three primitives in dialog.mjs drive the open/close state machine directly. They operate on the inner native HTMLDialogElement, not on the <ui-dialog> wrapper — pass dlg.querySelector('dialog'). open_dialog resolves once the open animation finishes; dialog_closed resolves to dialog.returnValue when the user closes via any path.

Show code

Accessibility

Keyboard contract

ARIA wiring

WCAG success criteria

Known limitations

See also