<ui-dialog> & 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.
| Element | Attributes | Properties | Methods | Events |
|---|---|---|---|---|
<ui-dialog> |
opened, load-feedback-delay, alertdialog, aria-label |
.opened, .waitOn |
open(), close(rv?), when_closed() → Promise |
modal-close-request, opened-changed |
dialog.mjs export | Returns | Notes |
|---|---|---|
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).
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_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_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.
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.
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.
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.
Short messages can use a small maximum width and size to fit their content.
Forms with tabs, sections, or two columns can use up to ~42rem.
max_width optionWhen 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>
})
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:
.then method is accepted, including promises from another JavaScript realm. An instanceof check would exclude some of these and open the dialog too soon. prepare_view() uses the same check for a view's .readyP.
customElements.whenDefined() for it and then the
.readyP of every element matching it inside
<main>.
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.
<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.
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.
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.
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.
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.
modal-close-request with
return_value="cancel"; call e.preventDefault() to block it).<dialog> (no JS needed; the native element handles it).autofocus). Place autofocus on the primary input or, for alert
dialogs, on the least-destructive button (Cancel).<dialog> carries implicit role="dialog";
add the alertdialog attribute to <ui-dialog> to switch to
role="alertdialog" (ARIA 1.2 §6.11).<header><h3>, the
component auto-generates an id and sets aria-labelledby on the
inner <dialog>. Without a heading, supply
aria-label="…" on <ui-dialog> and the component forwards
it to the inner element. Omitting both triggers a console.warn.apply_static_i18n() when the catalog loads (Conventions). The source of the accessible name is chosen at first connection: a heading keeps its aria-labelledby, and an aria-label supplied directly on the inner <dialog> is preserved.
.waitOn to resolve, the dialog gets
aria-busy="true" after load-feedback-delay ms (default 200ms),
so quick loads never flash the busy state.aria-labelledby
or aria-label.<dialog> focus trap is fully
escapable via ESC.ui-dialog.mjs for options under exploration.modal-close-request guard blocks ESC and backdrop clicks but not
[dialog-confirm] buttons — that's intentional (confirm is the explicit save
path), but callers that need to gate confirm must listen for the event separately and
call e.preventDefault() when the guard condition matches.dialog.mjs:
show_component_dialog, response_dialog,
open_dialog, close_dialog, dialog_closed.
<ui-popover> for non-modal overlays
anchored to a trigger;
<ui-menu> for listbox-style menus.
public/ui/components/ui-dialog.mjs.