Dialog
A modal window opened from a template or a component.
import { โฆ } from '@inlaylab/ivorie/dialog'import { ChangeDetectionStrategy, Component, TemplateRef, inject, signal, viewChild } from '@angular/core';
import { IvButton } from '@inlaylab/ivorie/button';
import { IV_DIALOG_IMPORTS, IvDialog } from '@inlaylab/ivorie/dialog';
@Component({
selector: 'demo-dialog',
imports: [IV_DIALOG_IMPORTS, IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button ivButton variant="outline" (click)="open()">Open dialog</button>
@if (deleted()) {
<p class="mt-2 text-sm text-muted-foreground">Project deleted.</p>
}
<ng-template #confirmTpl>
<iv-dialog-header>
<iv-dialog-title>Delete project?</iv-dialog-title>
<iv-dialog-description>This action cannot be undone.</iv-dialog-description>
</iv-dialog-header>
<iv-dialog-footer>
<button ivButton variant="outline" [ivDialogClose]="false">Cancel</button>
<button ivButton variant="destructive" [ivDialogClose]="true">Delete</button>
</iv-dialog-footer>
</ng-template>
`,
})
export default class DialogDemo {
private readonly dialog = inject(IvDialog);
private readonly confirmTpl = viewChild.required<TemplateRef<unknown>>('confirmTpl');
protected readonly deleted = signal(false);
open() {
this.dialog
.open<unknown, boolean>(this.confirmTpl())
.afterClosed()
.subscribe(({ result }) => this.deleted.set(result === true));
}
}
Installation
npx ivorie add dialogimport { โฆ } from '@inlaylab/ivorie/dialog'A modal dialog on CDK Overlay. Open a TemplateRef or a whole component. The close event tells you why it closed.
Usage
import { IvDialog, IV_DIALOG_IMPORTS } from '@inlaylab/ivorie/dialog';<button ivButton (click)="open()">Open dialog</button>
<ng-template #confirmTpl>
<iv-dialog-header>
<iv-dialog-title>Delete project?</iv-dialog-title>
<iv-dialog-description>This action cannot be undone.</iv-dialog-description>
</iv-dialog-header>
<iv-dialog-footer>
<button ivButton variant="outline" [ivDialogClose]="false">Cancel</button>
<button ivButton variant="destructive" [ivDialogClose]="true">Delete</button>
</iv-dialog-footer>
</ng-template>private dialog = inject(IvDialog);
readonly confirmTpl = viewChild.required<TemplateRef<unknown>>('confirmTpl');
open() {
const ref = this.dialog.open<unknown, boolean>(this.confirmTpl());
ref.afterClosed().subscribe(({ reason, result }) => {
if (reason === 'close-button' && result === true) this.deleteProject();
});
}Examples
Component with data
Alex: alex@acme.com
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { IvButton } from '@inlaylab/ivorie/button';
import { IV_DIALOG_DATA, IV_DIALOG_IMPORTS, IvDialog, IvDialogRef } from '@inlaylab/ivorie/dialog';
import { IvInput } from '@inlaylab/ivorie/input';
interface User {
name: string;
email: string;
}
@Component({
imports: [IV_DIALOG_IMPORTS, IvButton, IvInput],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<iv-dialog-header>
<iv-dialog-title>Edit user</iv-dialog-title>
<iv-dialog-description>Update the email for {{ data.name }}.</iv-dialog-description>
</iv-dialog-header>
<input ivInput aria-label="Email" [value]="email()" (input)="email.set($any($event.target).value)" />
<iv-dialog-footer>
<button ivButton variant="outline" ivDialogClose>Cancel</button>
<button ivButton (click)="save()">Save</button>
</iv-dialog-footer>
`,
})
class EditUserDialog {
private readonly ref = inject<IvDialogRef<EditUserDialog, User>>(IvDialogRef);
protected readonly data = inject<User>(IV_DIALOG_DATA);
protected readonly email = signal(this.data.email);
save() {
this.ref.close({ name: this.data.name, email: this.email() });
}
}
@Component({
selector: 'demo-dialog-component',
imports: [IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col items-center gap-2">
<button ivButton variant="outline" (click)="open()">Edit user</button>
<p class="text-sm text-muted-foreground">{{ user().name }}: {{ user().email }}</p>
</div>
`,
})
export default class DialogComponentDemo {
private readonly dialog = inject(IvDialog);
protected readonly user = signal<User>({ name: 'Alex', email: 'alex@acme.com' });
open() {
this.dialog
.open<EditUserDialog, User, User>(EditUserDialog, { data: this.user() })
.afterClosed()
.subscribe(({ result }) => {
if (result) this.user.set(result);
});
}
}
Form
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { IvButton } from '@inlaylab/ivorie/button';
import { IV_DIALOG_IMPORTS, IvDialog, IvDialogRef } from '@inlaylab/ivorie/dialog';
import { IvInput } from '@inlaylab/ivorie/input';
@Component({
imports: [IV_DIALOG_IMPORTS, ReactiveFormsModule, IvButton, IvInput],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="form" (ngSubmit)="save()" class="contents">
<iv-dialog-header>
<iv-dialog-title>Invite member</iv-dialog-title>
<iv-dialog-description>They get an email with a link to join.</iv-dialog-description>
</iv-dialog-header>
<iv-dialog-body>
<input ivInput type="email" formControlName="email" placeholder="Email" aria-label="Email" />
</iv-dialog-body>
<iv-dialog-footer>
<button ivButton type="button" variant="outline" ivDialogClose>Cancel</button>
<button ivButton type="submit" [disabled]="form.invalid">Invite</button>
</iv-dialog-footer>
</form>
`,
})
class InviteDialog {
private readonly ref = inject<IvDialogRef<InviteDialog, string>>(IvDialogRef);
protected readonly form = new FormGroup({
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
});
save() {
if (this.form.valid) this.ref.close(this.form.controls.email.value);
}
}
@Component({
selector: 'demo-dialog-form',
imports: [IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col items-center gap-2">
<button ivButton variant="outline" (click)="open()">Invite member</button>
@if (invited()) {
<p class="text-sm text-muted-foreground">Invited {{ invited() }}</p>
}
</div>
`,
})
export default class DialogFormDemo {
private readonly dialog = inject(IvDialog);
protected readonly invited = signal('');
open() {
this.dialog
.open<InviteDialog, string>(InviteDialog)
.afterClosed()
.subscribe(({ result }) => {
if (result) this.invited.set(result);
});
}
}
Trigger directive
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { IvButton } from '@inlaylab/ivorie/button';
import { DialogCloseEvent, IV_DIALOG_IMPORTS } from '@inlaylab/ivorie/dialog';
@Component({
selector: 'demo-dialog-trigger',
imports: [IV_DIALOG_IMPORTS, IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col items-center gap-2">
<button ivButton variant="outline" [ivDialogTrigger]="tpl" (closed)="reason.set($event.reason)">
Open dialog
</button>
@if (reason()) {
<p class="text-sm text-muted-foreground">Closed: {{ reason() }}</p>
}
</div>
<ng-template #tpl>
<iv-dialog-header>
<iv-dialog-title>Hello</iv-dialog-title>
<iv-dialog-description>Opened with the trigger directive. No handler code.</iv-dialog-description>
</iv-dialog-header>
<iv-dialog-footer>
<button ivButton ivDialogClose>Close</button>
</iv-dialog-footer>
</ng-template>
`,
})
export default class DialogTriggerDemo {
protected readonly reason = signal<DialogCloseEvent['reason'] | ''>('');
}
Sizes
import { ChangeDetectionStrategy, Component, TemplateRef, inject, viewChild } from '@angular/core';
import { IvButton } from '@inlaylab/ivorie/button';
import { DialogSize, IV_DIALOG_IMPORTS, IvDialog } from '@inlaylab/ivorie/dialog';
@Component({
selector: 'demo-dialog-sizes',
imports: [IV_DIALOG_IMPORTS, IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex gap-2">
@for (size of sizes; track size) {
<button ivButton variant="outline" (click)="open(size)">{{ size }}</button>
}
</div>
<ng-template #tpl>
<iv-dialog-header>
<iv-dialog-title>Dialog size</iv-dialog-title>
<iv-dialog-description>Set the width with the size option.</iv-dialog-description>
</iv-dialog-header>
<iv-dialog-footer>
<button ivButton ivDialogClose>Close</button>
</iv-dialog-footer>
</ng-template>
`,
})
export default class DialogSizesDemo {
private readonly dialog = inject(IvDialog);
private readonly tpl = viewChild.required<TemplateRef<unknown>>('tpl');
protected readonly sizes: DialogSize[] = ['sm', 'default', 'lg', 'xl'];
open(size: DialogSize) {
this.dialog.open(this.tpl(), { size });
}
}
Scrolling body
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { IvButton } from '@inlaylab/ivorie/button';
import { IV_DIALOG_IMPORTS } from '@inlaylab/ivorie/dialog';
@Component({
selector: 'demo-dialog-scrolling-body',
imports: [IV_DIALOG_IMPORTS, IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button ivButton variant="outline" [ivDialogTrigger]="tpl" [ivDialogConfig]="{ height: 'tall' }">
Terms of service
</button>
<ng-template #tpl>
<iv-dialog-header>
<iv-dialog-title>Terms of service</iv-dialog-title>
<iv-dialog-description>Scroll the body. The header and footer stay put.</iv-dialog-description>
</iv-dialog-header>
<iv-dialog-body>
@for (n of sections; track n) {
<p class="text-sm text-muted-foreground">
{{ n }}. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
</p>
}
</iv-dialog-body>
<iv-dialog-footer>
<button ivButton variant="outline" ivDialogClose>Decline</button>
<button ivButton ivDialogClose>Accept</button>
</iv-dialog-footer>
</ng-template>
`,
})
export default class DialogScrollingBodyDemo {
protected readonly sections = Array.from({ length: 12 }, (_, i) => i + 1);
}
Close reasons
Last close: none
import { ChangeDetectionStrategy, Component, TemplateRef, inject, signal, viewChild } from '@angular/core';
import { IvButton } from '@inlaylab/ivorie/button';
import { DialogCloseEvent, IV_DIALOG_IMPORTS, IvDialog } from '@inlaylab/ivorie/dialog';
@Component({
selector: 'demo-dialog-close-reasons',
imports: [IV_DIALOG_IMPORTS, IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col items-center gap-2">
<button ivButton variant="outline" (click)="open()">Open dialog</button>
<p class="text-sm text-muted-foreground">Last close: {{ last()?.reason ?? 'none' }}</p>
</div>
<ng-template #tpl>
<iv-dialog-header>
<iv-dialog-title>How will you close this?</iv-dialog-title>
<iv-dialog-description>
Click outside, press Escape, use the corner button, or pick an action.
</iv-dialog-description>
</iv-dialog-header>
<iv-dialog-footer>
<button ivButton variant="outline" ivDialogClose>Cancel</button>
<button ivButton [ivDialogClose]="'ok'" reason="programmatic">Confirm</button>
</iv-dialog-footer>
</ng-template>
`,
})
export default class DialogCloseReasonsDemo {
private readonly dialog = inject(IvDialog);
private readonly tpl = viewChild.required<TemplateRef<unknown>>('tpl');
protected readonly last = signal<DialogCloseEvent | null>(null);
open() {
this.dialog
.open(this.tpl())
.afterClosed()
.subscribe((event) => this.last.set(event));
}
}
API
Every part also accepts class.
IvDialog
Service, providedIn: 'root'.
| Method | Signature | Description |
|---|---|---|
open |
<T, R, D>(content: Type<T> | TemplateRef<unknown>, config?: DialogConfig<D>) => IvDialogRef<T, R> |
Opens a dialog. |
A template gets $implicit (the ref), data and dialogRef in its context. A component gets IvDialogRef and IV_DIALOG_DATA by injection.
IvDialogRef
| Member | Signature | Description |
|---|---|---|
close |
(result?: R, reason?: DialogCloseReason) => void |
Closes the dialog. reason defaults to 'programmatic'. Only the first close counts. |
afterClosed |
() => Observable<DialogCloseEvent<R>> |
Emits once, then completes. |
closed |
Signal<DialogCloseEvent<R> | undefined> |
undefined while open, then the close event. |
componentInstance |
T | undefined |
The live instance when opened with a component. |
DialogConfig
| Name | Type | Default | Description |
|---|---|---|---|
data |
D |
null |
Injected as IV_DIALOG_DATA. Also in the template context as data. |
size |
'sm' | 'default' | 'lg' | 'xl' | 'fit' |
'default' |
Panel width. fit sizes to content. |
height |
'auto' | 'tall' |
'auto' |
tall pins the height to 34rem, or the viewport if shorter, so multi-step dialogs do not resize. Long steps scroll in the body. |
showCloseButton |
boolean |
true |
Shows the corner close button. |
disableBackdropClose |
boolean |
false |
Backdrop click does not close. |
disableEscapeClose |
boolean |
false |
Escape does not close. |
panelClass |
string | string[] |
none | Extra classes on the overlay panel. |
backdropClass |
string | string[] |
none | Extra classes on the backdrop. |
ariaLabel |
string |
none | Sets aria-label. Alone, it drops the default label reference. |
ariaLabelledBy |
string |
title id | Sets aria-labelledby. |
ariaDescribedBy |
string |
description id | Sets aria-describedby. |
DialogCloseEvent
{ reason: DialogCloseReason; result?: R }.
| Reason | Meaning |
|---|---|
'backdrop' |
Backdrop click. |
'escape' |
Escape key. |
'close-button' |
Corner close button, or an [ivDialogClose] element. |
'programmatic' |
A close() call with no reason. |
'navigation' |
The overlay was disposed from outside, for example by a route change. |
IV_DIALOG_DATA
Injection token for the data passed to open.
ivDialogTrigger
Opens a dialog on click.
| Name | Type | Default | Description |
|---|---|---|---|
ivDialogTrigger |
Type<T> | TemplateRef<unknown> |
required | What to open (class field content). |
ivDialogConfig |
DialogConfig<D> |
{} |
Config for open. |
Outputs:
| Name | Payload | Description |
|---|---|---|
opened |
IvDialogRef<T, R> |
Fires when the dialog opens. |
closed |
DialogCloseEvent<R> |
Fires when the dialog closes. |
ivDialogClose
Closes the dialog on click. It adds no styles: put it on a styled button such as <button ivButton>.
| Name | Type | Default | Description |
|---|---|---|---|
ivDialogClose |
R |
undefined |
Result passed to close (class field result). |
reason |
DialogCloseReason |
'close-button' |
Reason reported on close. |
iv-dialog-header, iv-dialog-footer
Layout parts. No inputs. The header and footer stay pinned while the body scrolls.
iv-dialog-title
Renders an <h2>. No inputs.
iv-dialog-description
| Name | Type | Default | Description |
|---|---|---|---|
innerHTML |
string | undefined |
undefined |
HTML for the text. Angular sanitizes it. When set, projected content is ignored. |
iv-dialog-body
Scrollable region between header and footer. No inputs.
Accessibility
- The panel has
role="dialog"andaria-modal="true". - It is labelled by
iv-dialog-titleand described byiv-dialog-description.ariaLabel,ariaLabelledByandariaDescribedByoverride these. - Focus is trapped inside the panel, and moves into it on open.
- On close, focus returns to the element that was focused before open. It is not moved if focus is already somewhere else outside the dialog. This runs before
afterClosedemits, so a subscriber can still move focus. - Escape closes the dialog. It is routed through the CDK keyboard dispatcher, so only the topmost overlay closes: an open select or menu inside the dialog closes first.
- The close button has the hidden label "Close".
- Body scroll is blocked while open.
Notes
- Inside a
<form>that wraps header, body and footer, addclass="contents"to the form. Otherwise the form is the single flex child and the container gap collapses. - Focus the first field with
cdkFocusInitialif the default initial focus is wrong.