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

CLInpm
npx ivorie add 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.

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" and aria-modal="true".
  • It is labelled by iv-dialog-title and described by iv-dialog-description. ariaLabel, ariaLabelledBy and ariaDescribedBy override 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 afterClosed emits, 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, add class="contents" to the form. Otherwise the form is the single flex child and the container gap collapses.
  • Focus the first field with cdkFocusInitial if the default initial focus is wrong.