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.
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);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.
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, nativeEscape) when applied to a real<dialog>tag.showPopover()— the native Popover API, light-dismiss.
The imperative call itself goes through DialogService, which createCallable wraps:
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.
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.
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).
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.
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.
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
Overlay
Components that float above the page — Dialog, Sheet, Menu, Popover, Tooltip — and the tokens and layering rules they share.
Sheet
A modal panel anchored to one edge of the viewport — the drawer pattern — built on the same native <dialog> as Dialog, with its own slide-in/out animation and scroll lock.