Browse documentation

UI / Overlays / Deep dive

Dialog: a named task with a native lifecycle

Separate opening, cancel requests, accepted actions, and native modal behavior.

A confirmation dialog has at least three outcomes: the person cancels, the application accepts the action, or the action fails and needs recovery. A single close button cannot represent all three. Dialog supplies the native dialog lifecycle; your application decides what an accepted action means.

Start with component construction and overlay selection. The parts share one hydrated { open } state. Content renders a real <dialog>, and its ref uses showModal() by default. The browser owns modal top-layer placement and inertness. modal: false chooses show() and permits interaction elsewhere; changing a role or CSS does not make a non-modal surface modal. See MDN dialog.

Open and request dismissal

Trigger and Content share one state. Give the dialog a stable, page-unique heading ID and an explicit dismissal control.

import { html, component } from "@typed/template";
import * as Dialog from "@typed/ui/Dialog";

const ArchiveConfirmation = component(function* (id: string) {
  const state = yield* Dialog.makeState();

  return [
    Dialog.Trigger({ state, content: "Archive project" }),
    Dialog.Content({
      state,
      labelledBy: `${id}-title`,
      content: html`
        <h2 id=${`${id}-title`}>Archive project</h2>
        <p>You can restore this project later.</p>
        ${Dialog.RequestClose({ state, content: "Cancel" })}
      `,
    }),
  ];
});

RequestClose invokes the mounted element’s native requestClose() when available. Its fallback dispatches a cancelable cancel event and closes only when accepted. With no mounted Content, the request does nothing. In contrast, Dialog.close(state) closes directly: use that after an action succeeds.

Add the archive action

Supply the real archive Effect and a stable, page-unique instance ID. Its service requirements remain in the returned view. A recoverable rejection appears in the dialog; success closes it. The busy claim prevents competing clicks from starting the operation twice.

Complete confirmation with busy and failure handling
import { Data, Effect } from "effect";
import { RefSubject } from "@typed/fx";
import { html, component } from "@typed/template";
import * as Button from "@typed/ui/Button";
import * as Dialog from "@typed/ui/Dialog";

class ArchiveRejected extends Data.TaggedError("ArchiveRejected")<{
  readonly message: string;
}> {}

const ArchiveProject = component(function* <R>(id: string, archive: Effect.Effect<void, ArchiveRejected, R>) {
  const state = yield* Dialog.makeState();
  const busy = yield* RefSubject.make(false);
  const message = yield* RefSubject.make("");

  const confirm = Effect.acquireUseRelease(
    // Protect the busy claim and its release from interruption.
    RefSubject.modify(busy, (running) => [!running, true] as const),
    (acquired) => acquired
      ? Effect.gen(function* () {
          yield* RefSubject.set(message, "Archiving…");

          yield* archive;

          yield* RefSubject.set(message, "Archived.");
          yield* Dialog.close(state);
        }).pipe(
          Effect.catchTag("ArchiveRejected", (error) => RefSubject.set(message, error.message)),
          Effect.asVoid,
        )
      : Effect.void,

    // A competing click must not release the active operation's claim.
    (acquired) => acquired ? RefSubject.set(busy, false) : Effect.void,
  );

  return [
    Dialog.Trigger({ state, content: "Archive project" }),
    Dialog.Content({
      state,
      labelledBy: `${id}-title`,
      content: html`
        <h2 id=${`${id}-title`}>Archive project</h2>
        <p>You can restore this project later.</p>
        <p role="status">${message}</p>
        ${Dialog.RequestClose({ state, content: "Close dialog" })}
        ${Button.Button({ content: "Archive", disabled: busy, onclick: confirm })}
      `,
    }),
  ];
});

Closing this dialog dismisses the view; it does not cancel an archive already underway. Reopening shows its current status. The handler belongs to the mounted component Scope, so removing that component interrupts the work. If archiving must survive navigation, let an application service own that operation.

Choose one command path

Omitting controls, as above, installs the state-driven click behavior. Supplying controls emits native commandfor plus show-modal, close, or request-close, and deliberately removes that fallback. Match the value to Content.id and verify command support in your target browsers. An unsupported command does not automatically fall back. The native opening command is specifically show-modal; use the state path for a non-modal dialog.

Content requires exactly one of label or labelledBy. Heading and Description are conveniences for visible naming/description content; stable IDs connect them through labelledBy and describedBy. Names must remain meaningful even when content updates. Keep a visible dismissal control and inspect initial focus, Tab traversal, Escape, and focus return against the APG modal dialog pattern.

Diagnose lifecycle disagreements

If the dialog is visible but state.open is false, inspect cancel, close, and toggle handlers and the composed ref on the actual <dialog>. A custom host must forward all of them. A prevented user cancel handler vetoes the internal close handler; cancel browser behavior synchronously, before asynchronous work. If opening does nothing, distinguish an unsupported command from an unmounted content ref. If opening throws, inspect connection and native open state rather than adding another boolean.

Continue to NativeDialog to see the smaller synchronization layer, or Dom contracts before overriding a host. API: Dialog.