Atomic Angular — Doc interne

Dialog

Open a modal or non-modal window on the native <dialog> element, and get a typed result back from an imperative call() without wiring a viewChild by hand.

A confirmation prompt, an edit form, a notice that needs an explicit dismissal — each of those is a window that has to sit above the rest of the page, trap focus while it is open, and hand a result back to whoever opened it. Dialog is that window, built on the native HTML <dialog> element (or a [dialog] attribute on any other host), and createCallable/injectCallRef is the typed call/result API most of this page teaches.

Concept — native top layer

The browser's <dialog> element renders in the top layer, a stacking context above everything else in the document — no application z-index can cover it, and none is needed to keep it on top. showModal() also gives it a native ::backdrop pseudo-element and traps focus inside it for free. Dialog prefers a real <dialog> tag for exactly this reason, and falls back to a [dialog] attribute (with a manual backdrop and Escape handling) only for hosts that cannot be a <dialog> tag.

Minimal example

The recommended, imperative way to open a dialog: bind a component once with createCallable, then await it from anywhere. provideCallable() must be registered once at bootstrap — see Options.

A confirm dialog, called and awaited
confirm-dialog.ts
import { afterNextRender, Component, viewChild } from "@angular/core";
import {
  ButtonComponent,
  createCallable,
  DialogBodyComponent,
  DialogComponent,
  DialogContentComponent,
  DialogDescriptionComponent,
  DialogFooterComponent,
  DialogHeaderComponent,
  DialogTitleComponent,
  injectCallRef,
} from "@sinequa/galactik";

@Component({
  selector: "confirm-dialog",
  imports: [
    DialogComponent,
    DialogContentComponent,
    DialogHeaderComponent,
    DialogTitleComponent,
    DialogBodyComponent,
    DialogDescriptionComponent,
    DialogFooterComponent,
    ButtonComponent,
  ],
  template: `
    <dialog #dialog="dialog" (closed)="call.end($event === 'dialog-confirm')">
      <DialogContent size="sm">
        <DialogHeader>
          <DialogTitle>Please confirm</DialogTitle>
        </DialogHeader>
        <DialogBody>
          <DialogDescription>{{ call.props().message }}</DialogDescription>
        </DialogBody>
        <DialogFooter>
          <button variant="secondary" size="md" (click)="dialog.close('dialog-cancel')">Cancel</button>
          <button variant="primary" size="md" (click)="dialog.close('dialog-confirm')">Confirm</button>
        </DialogFooter>
      </DialogContent>
    </dialog>
  `,
})
export class ConfirmDialog {
  // Arguments + resolution handle, injected via CALL_REF (provided by DialogService.call()).
  protected readonly call = injectCallRef<{ message: string }, boolean>();
  private readonly dialog = viewChild.required(DialogComponent);

  constructor() {
    // The instance is mounted headless — open it once the view exists.
    afterNextRender(() => this.dialog().showModal());
  }
}

// Module-level singleton: bind ConfirmDialog once, call it from anywhere.
export const Confirm = createCallable<{ message: string }, boolean>(ConfirmDialog);
sample.component.ts
import { Component, signal } from "@angular/core";
import { ButtonComponent } from "@sinequa/galactik";
import { Confirm } from "./confirm-dialog";

@Component({
  selector: "sample-component",
  imports: [ButtonComponent],
  template: `
    <button variant="primary" size="md" (click)="deleteItem()">Delete item…</button>
    @if (deleted()) {
      <p>Item was deleted.</p>
    }
  `,
})
export class SampleComponent {
  protected readonly deleted = signal(false);

  protected async deleteItem() {
    const confirmed = await Confirm.call({ message: "Delete this item? This cannot be undone." });
    if (confirmed) this.deleted.set(true);
  }
}

(closed)="call.end($event === 'dialog-confirm')" resolves the promise however the dialog closes — clicking Confirm, clicking Cancel, or pressing Escape all reach the same handler, with no dedicated "Escape" branch to write.

How it works

Three things happen once, in order: the dialog opens through one of three methods, its content mounts, and closing it resolves whatever awaited the call.

#dialog attribute provides DIALOG_REF @if(isOpen()) provides MODAL_LABEL inject(DIALOG_REF, optional) -> close() effect: label.titleId.set(id) titleId() closed (DialogEvent) Host app "DialogComponent (dialog, [dialog DIALOG_REF DialogContent (role=dialog, aria-modal) MODAL_LABEL DialogHeader DialogTitle DialogBody DialogDescription DialogFooter

DialogContent never needs a for or an explicit id to associate its title: DialogTitle publishes its own id (generated, or set explicitly) into the MODAL_LABEL token it shares with DialogContent, which reads it back for aria-labelledby. The same token pattern lets DialogHeader's built-in close button — and any custom sub-component you project — close the dialog by injecting DIALOG_REF, without ever depending on DialogComponent itself (see Pitfalls).

Three open modes, all on the same template reference:

  • show() — non-modal. On a real <dialog> tag this has no backdrop.
  • showModal() — modal, native <dialog> semantics (native focus trap, native Escape) when applied to a real <dialog> tag.
  • showPopover() — the native Popover API, light-dismiss.

The imperative call itself goes through DialogService, which createCallable wraps:

Confirm.call(props, options?) dialogService.call(ConfirmDialog, props, options) new CallRef(), props.set(props) createComponent(), providing CALL_REF injectCallRef() reads props() afterNextRender(showModal) user confirms, cancels, or presses Escape (closed) calls call.end(result) resolves the pending promise Promise resolves with result after unmountDelay, destroyInstance() Caller Confirm DialogService ConfirmDialog CallRef

Calling a callable more than once mounts independent, concurrently open instances rather than replacing the previous one — DialogService.call() stacks by design, which is what lets injectCallRef().index() and .stackSize() drive position-aware UI in a stacked notification (see Recipes).

Recipes

Compose all six slots, and the three open modes

A declarative (non-callable) dialog, using every layout slot plus the three ways to open the same <dialog> reference.

All six slots, three open modes
sample.component.ts
import { Component } from "@angular/core";
import {
  BadgeComponent,
  ButtonComponent,
  DialogBodyComponent,
  DialogComponent,
  DialogContentComponent,
  DialogDescriptionComponent,
  DialogFooterComponent,
  DialogHeaderComponent,
  DialogTitleComponent,
} from "@sinequa/galactik";

@Component({
  selector: "sample-component",
  imports: [
    DialogComponent,
    DialogContentComponent,
    DialogHeaderComponent,
    DialogTitleComponent,
    DialogBodyComponent,
    DialogDescriptionComponent,
    DialogFooterComponent,
    BadgeComponent,
    ButtonComponent,
  ],
  template: `
    <div class="flex gap-2">
      <button variant="secondary" size="md" (click)="dialog.show()">Open (non-modal)</button>
      <button variant="secondary" size="md" (click)="dialog.showModal()">Open (modal)</button>
      <button variant="secondary" size="md" (click)="dialog.showPopover()">Open (popover)</button>
    </div>

    <dialog #dialog="dialog" id="profileDialog">
      <DialogContent size="md">
        <DialogHeader>
          <badge variant="icon" scheme="info" size="md" aria-label="Information">i</badge>
          <DialogTitle>Edit profile</DialogTitle>
        </DialogHeader>
        <DialogBody>
          <DialogDescription>Make changes to your profile here. Click save when you're done.</DialogDescription>
        </DialogBody>
        <DialogFooter>
          <!-- Native Invoker Commands API — closes "profileDialog" without a click handler. -->
          <button variant="secondary" size="md" command="close" commandfor="profileDialog">Cancel</button>
          <button variant="primary" size="md" (click)="dialog.close('dialog-confirm')">Save changes</button>
        </DialogFooter>
      </DialogContent>
    </dialog>
  `,
})
export class SampleComponent {}

Typed payload in, typed result out

createCallable<P, R> types both the arguments passed into call(props) and the value the promise resolves with — here a { name: string } | null result, null on cancel.

Editing a name, with a typed result
edit-name-dialog.ts
import { afterNextRender, Component, signal, viewChild } from "@angular/core";
import {
  ButtonComponent,
  createCallable,
  DialogBodyComponent,
  DialogComponent,
  DialogContentComponent,
  DialogFooterComponent,
  DialogHeaderComponent,
  DialogTitleComponent,
  injectCallRef,
  InputComponent,
  InputControlDirective,
} from "@sinequa/galactik";

interface EditNameProps {
  currentName: string;
}
type EditNameResult = { name: string } | null;

@Component({
  selector: "edit-name-dialog",
  imports: [
    DialogComponent,
    DialogContentComponent,
    DialogHeaderComponent,
    DialogTitleComponent,
    DialogBodyComponent,
    DialogFooterComponent,
    InputComponent,
    InputControlDirective,
    ButtonComponent,
  ],
  template: `
    <dialog #dialog="dialog" (closed)="onClosed()">
      <DialogContent size="sm">
        <DialogHeader>
          <DialogTitle>Edit name</DialogTitle>
        </DialogHeader>
        <DialogBody>
          <input-group>
            <input
              input-control
              type="text"
              [value]="name()"
              (input)="name.set($any($event.target).value)"
              aria-label="Name"
            />
          </input-group>
        </DialogBody>
        <DialogFooter>
          <button variant="secondary" size="md" (click)="dialog.cancel()">Cancel</button>
          <button variant="primary" size="md" [disabled]="!name().trim()" (click)="save()">Save</button>
        </DialogFooter>
      </DialogContent>
    </dialog>
  `,
})
export class EditNameDialog {
  protected readonly call = injectCallRef<EditNameProps, EditNameResult>();
  protected readonly name = signal("");
  private readonly dialog = viewChild.required(DialogComponent);
  private saved = false;

  constructor() {
    this.name.set(this.call.props().currentName);
    afterNextRender(() => this.dialog().showModal());
  }

  protected save() {
    this.saved = true;
    this.call.end({ name: this.name().trim() });
    this.dialog().close("dialog-confirm");
  }

  // Cancel / Escape / backdrop all land here without having gone through save().
  protected onClosed() {
    if (!this.saved) this.call.end(null);
  }
}

export const EditName = createCallable<EditNameProps, EditNameResult>(EditNameDialog);

Stacked, position-aware dialogs

Calling a callable several times in a row mounts independent, concurrently open instances rather than replacing the previous one. injectCallRef() exposes index()/stackSize() for position-aware UI, and ended() for driving an exit-animation state while teardown is pending. call(props, options) also accepts an explicit injector — useful when provideCallable() was not registered, or from a lazily-loaded subtree — an unmountDelay that leaves time for a CSS exit transition before the component is destroyed, and a container that changes where the dialog is mounted (next recipe).

A stacked, dismissible notice
stacked-toast-dialog.ts
import { afterNextRender, Component, viewChild } from "@angular/core";
import {
  ButtonComponent,
  createCallable,
  DialogBodyComponent,
  DialogComponent,
  DialogContentComponent,
  injectCallRef,
} from "@sinequa/galactik";

@Component({
  selector: "stacked-toast-dialog",
  imports: [DialogComponent, DialogContentComponent, DialogBodyComponent, ButtonComponent],
  template: `
    <!-- Plain "dialog" attribute, matching the "dialog, [dialog]" selector on any host element. -->
    <div dialog #dlg="dialog" [class.opacity-0]="call.ended()" (closed)="call.end()">
      <DialogContent size="sm">
        <DialogBody>
          <p>Notice {{ call.index() + 1 }} of {{ call.stackSize() }}</p>
          <p>{{ call.props().message }}</p>
          <button variant="primary" size="md" [disabled]="call.ended()" (click)="dlg.close()">Dismiss</button>
        </DialogBody>
      </DialogContent>
    </div>
  `,
})
export class StackedToastDialog {
  // R = void here: this dialog just needs to be dismissed, no result value.
  protected readonly call = injectCallRef<{ message: string }>();
  private readonly dlg = viewChild.required(DialogComponent);

  constructor() {
    afterNextRender(() => this.dlg().show());
  }
}

// unmountDelay: 200 — leaves time for the opacity transition to play before teardown.
export const Notify = createCallable<{ message: string }>(StackedToastDialog, 200);

Close the parent dialog from a projected sub-component — DIALOG_REF

Content projected into a dialog — including your own reusable pieces, not just DialogHeader's built-in close button — closes the parent dialog by injecting DIALOG_REF. This is exactly the pattern DialogHeaderComponent's own close button uses internally.

A reusable close button for projected content
dialog-close-button.ts
import { Directive, inject } from "@angular/core";
import { DIALOG_REF } from "@sinequa/galactik";

@Directive({
  selector: "button[dialogClose]",
  host: { "(click)": "dialogRef?.close()" },
})
export class DialogCloseButtonDirective {
  // Optional — injecting DIALOG_REF works whether or not this button ends up inside a Dialog.
  private readonly dialogRef = inject(DIALOG_REF, { optional: true });
}

Open a dialog from inside a native popover — container

A callable dialog is mounted in document.body. That is a problem when the caller lives in a native popover="auto" (the [popover] directive, a filter panel, a dropdown): showModal() hides every auto popover that is not an ancestor of the dialog, so the popover closes the instant the dialog opens — and whatever the popover rendered is destroyed with it, the dialog included.

Pass the popover element as container: the dialog is then a DOM descendant of the popover, the browser keeps the popover open behind the modal, and the dialog still renders in the top layer above it.

Confirm.call(props, container = popover) appendChild(dialog host) dialog.showModal() call resolves, popover still open dialog is a descendant, so the popover stays open Component in the popover DialogService popover="auto"
A dialog opened from a popover, without closing it
sample.component.ts
import { Component, ElementRef, inject } from "@angular/core";
import { ButtonComponent } from "@sinequa/galactik";
import { Confirm } from "./confirm-dialog";

@Component({
  selector: "sample-component",
  imports: [ButtonComponent],
  template: `
    <button variant="secondary" size="md" popovertarget="actions-popover">Actions</button>
    <div popover id="actions-popover" class="p-3">
      <button variant="primary" size="md" (click)="remove()">Delete item…</button>
    </div>
  `,
})
export class SampleComponent {
  private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);

  protected async remove() {
    const confirmed = await Confirm.call(
      { message: "Delete this item?" },
      // The nearest popover, if any: undefined falls back to document.body.
      { container: this.host.nativeElement.closest<HTMLElement>("[popover]") ?? undefined },
    );
    if (confirmed) console.log("deleted");
  }
}

closest("[popover]") keeps the component reusable: inside a popover it mounts there, anywhere else it falls back to document.body. ConfirmDialog is the one from the minimal example — nothing in the dialog itself changes.

Mounting in the popover has one side effect to know: the dialog inherits whatever the popover's ancestors do with events. A popover that stops click/keydown propagation (to isolate itself from a toolbar, say) also isolates the dialog from those ancestors.

Options

Prop

Type

provideCallable() — registered once at bootstrap (providers: [provideCallable()]) — captures the root Injector so Callable.call(props) works without passing { injector } explicitly. Without it, and with no injector passed in options, call() throws synchronously.

Pitfalls

What's next

On this page