Dialog
A window overlaid on the primary window, rendering the content underneath inert.
- v1.0.0
- WAI-ARIA: Dialog (Modal)
- SSR
- Zoneless
- 3.2 kB gzip
- +9.7 kB Angular CDK, shared
import { Component, signal } from '@angular/core';
import { form, FormField, required, submit } from '@angular/forms/signals';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import {
DialogCloseButton,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogTitle,
Dialog,
DialogClose,
DialogPortal,
DialogTrigger,
} from '@/ui/dialog/dialog';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
interface Profile {
name: string;
username: string;
}
/**
* An edit-profile dialog with a Signal Forms form. Saving validates the
* form, closes the dialog when it passes, and keeps it open otherwise.
*/
@Component({
selector: 'example-dialog-demo',
imports: [
Dialog,
DialogTrigger,
DialogPortal,
DialogClose,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogCloseButton,
NdIcon,
Button,
Input,
Label,
FormField,
],
template: `
<ng-container ndDialog [(open)]="open">
<button ndButton ndDialogTrigger variant="outline">Edit profile</button>
<ng-template ndDialogPortal>
<div ndDialogOverlay></div>
<div ndDialogContent class="sm:max-w-[425px]">
<form class="grid gap-4" (submit)="save($event)">
<div ndDialogHeader>
<h2 ndDialogTitle>Edit profile</h2>
<p ndDialogDescription>
Make changes to your profile here. Click save when you're done.
</p>
</div>
<div class="grid gap-4">
<div class="grid gap-3">
<label ndLabel for="dialog-demo-name">Name</label>
<input
ndInput
id="dialog-demo-name"
[formField]="profile.name"
/>
</div>
<div class="grid gap-3">
<label ndLabel for="dialog-demo-username">Username</label>
<input
ndInput
id="dialog-demo-username"
[formField]="profile.username"
/>
</div>
</div>
<div ndDialogFooter>
<button ndButton ndDialogClose type="button" variant="outline">
Cancel
</button>
<button ndButton type="submit">Save changes</button>
</div>
</form>
<button ndDialogCloseButton>
<svg ndIcon="x"></svg>
<span class="sr-only">Close</span>
</button>
</div>
</ng-template>
</ng-container>
`,
})
export class DialogDemoExample {
protected readonly open = signal(false);
private readonly model = signal<Profile>({
name: 'Ada Lovelace',
username: '@ada',
});
protected readonly profile = form(this.model, (path) => {
required(path.name);
required(path.username);
});
/**
* Validates and saves the profile, then closes the dialog.
*
* @param event - The form's submit event.
*/
protected async save(event: Event): Promise<void> {
event.preventDefault();
await submit(this.profile, async () => {
this.open.set(false);
return undefined;
});
}
}
A dialog interrupts the page to ask for a decision or information, and
blocks the rest of the page until it closes. It follows the WAI-ARIA modal
dialog pattern: focus moves into the dialog and stays there, Escape and the
close button close it, and focus returns to the trigger. Its content renders
in an ng-template portal, so nothing is in the page until it opens.
Installation
npx @ndop/cli add dialogpnpm dlx @ndop/cli add dialogyarn dlx @ndop/cli add dialogbunx @ndop/cli add dialogInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/dialog/dialog.tsimport { computed, Directive, input } from '@angular/core'; import { NdDialog, NdDialogClose, NdDialogContent, NdDialogDescription, NdDialogOverlay, NdDialogPortal, NdDialogTitle, NdDialogTrigger, } from '@ndop/primitives/dialog'; import { cn } from '@/lib/utils'; /** * A modal dialog (`@ndop/primitives/dialog`): it renders its portal's * content over the page while open, traps focus inside it and returns it on * close, and closes on Escape or a backdrop click. Put it on an * `ng-container`; `open` is two-way bindable. * * @example * ```html * <ng-container ndDialog [(open)]="open">…</ng-container> * ``` */ @Directive({ selector: '[ndDialog]', hostDirectives: [ { directive: NdDialog, inputs: ['open', 'defaultOpen', 'closeOnEscape', 'closeOnOverlayClick'], outputs: ['openChange'], }, ], }) export class Dialog {} /** * The button that opens its dialog, with `aria-haspopup`, `aria-expanded` * and `aria-controls`. * * @example * ```html * <button ndButton ndDialogTrigger>Edit profile</button> * ``` */ @Directive({ selector: '[ndDialogTrigger]', hostDirectives: [NdDialogTrigger], host: { 'data-slot': 'dialog-trigger' }, }) export class DialogTrigger {} /** * Marks the `ng-template` its dialog renders while open. * * @example * ```html * <ng-template ndDialogPortal>…</ng-template> * ``` */ @Directive({ selector: 'ng-template[ndDialogPortal]', hostDirectives: [NdDialogPortal], }) export class DialogPortal {} /** * A button that closes its dialog. * * @example * ```html * <button ndButton variant="outline" ndDialogClose>Cancel</button> * ``` */ @Directive({ selector: '[ndDialogClose]', hostDirectives: [NdDialogClose], host: { 'data-slot': 'dialog-close' }, }) export class DialogClose {} /** * Backdrop behind the dialog; a click on it closes the dialog. * * @example * ```html * <div ndDialogOverlay></div> * ``` */ @Directive({ selector: '[ndDialogOverlay]', hostDirectives: [NdDialogOverlay], host: { 'data-slot': 'dialog-overlay', '[class]': 'classes()' }, }) export class DialogOverlay { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', this.class(), ), ); } /** * The dialog box, centered on screen. * * @example * ```html * <div ndDialogContent>…</div> * ``` */ @Directive({ selector: '[ndDialogContent]', hostDirectives: [NdDialogContent], host: { 'data-slot': 'dialog-content', '[class]': 'classes()' }, }) export class DialogContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg', this.class(), ), ); } /** * The icon button in the content's corner that closes the dialog. Put an * `<svg ndIcon="x">` and a `sr-only` label inside it. * * @example * ```html * <button ndDialogCloseButton> * <svg ndIcon="x"></svg> * <span class="sr-only">Close</span> * </button> * ``` */ @Directive({ selector: 'button[ndDialogCloseButton]', hostDirectives: [NdDialogClose], host: { type: 'button', 'data-slot': 'dialog-close', '[class]': 'classes()', }, }) export class DialogCloseButton { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "absolute top-4 end-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * Layout for the title and description. * * @example * ```html * <div ndDialogHeader>…</div> * ``` */ @Directive({ selector: '[ndDialogHeader]', host: { 'data-slot': 'dialog-header', '[class]': 'classes()' }, }) export class DialogHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col gap-2 text-center sm:text-start', this.class()), ); } /** * Layout for the action buttons. * * @example * ```html * <div ndDialogFooter>…</div> * ``` */ @Directive({ selector: '[ndDialogFooter]', host: { 'data-slot': 'dialog-footer', '[class]': 'classes()' }, }) export class DialogFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', this.class()), ); } /** * The dialog's title; it names the dialog. * * @example * ```html * <h2 ndDialogTitle>Edit profile</h2> * ``` */ @Directive({ selector: '[ndDialogTitle]', hostDirectives: [NdDialogTitle], host: { 'data-slot': 'dialog-title', '[class]': 'classes()' }, }) export class DialogTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-lg leading-none font-semibold', this.class()), ); } /** * The dialog's description; it describes the dialog. * * @example * ```html * <p ndDialogDescription>Changes are saved when you close.</p> * ``` */ @Directive({ selector: '[ndDialogDescription]', hostDirectives: [NdDialogDescription], host: { 'data-slot': 'dialog-description', '[class]': 'classes()' }, }) export class DialogDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm text-muted-foreground', this.class()), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import { Button } from '@/ui/button/button';
import {
DialogCloseButton,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogTitle,
Dialog,
DialogClose,
DialogPortal,
DialogTrigger,
} from '@/ui/dialog/dialog';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';<ng-container ndDialog [(open)]="open">
<button ndButton ndDialogTrigger variant="outline">Edit profile</button>
<ng-template ndDialogPortal>
<div ndDialogOverlay></div>
<div ndDialogContent class="sm:max-w-[425px]">
<form class="grid gap-4" (submit)="save($event)">
<div ndDialogHeader>
<h2 ndDialogTitle>Edit profile</h2>
<p ndDialogDescription>
Make changes to your profile here. Click save when you're done.
</p>
</div>
<div class="grid gap-4">
<div class="grid gap-3">
<label ndLabel for="dialog-demo-name">Name</label>
<input
ndInput
id="dialog-demo-name"
[formField]="profile.name"
/>
</div>
<div class="grid gap-3">
<label ndLabel for="dialog-demo-username">Username</label>
<input
ndInput
id="dialog-demo-username"
[formField]="profile.username"
/>
</div>
</div>
<div ndDialogFooter>
<button ndButton ndDialogClose type="button" variant="outline">
Cancel
</button>
<button ndButton type="submit">Save changes</button>
</div>
</form>
<button ndDialogCloseButton>
<svg ndIcon="x"></svg>
<span class="sr-only">Close</span>
</button>
</div>
</ng-template>
</ng-container>Examples
Dialog close button
A share dialog with a read-only link and a footer close button.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import {
DialogCloseButton,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogTitle,
Dialog,
DialogClose,
DialogPortal,
DialogTrigger,
} from '@/ui/dialog/dialog';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
/** A share dialog with a read-only link and a footer close button. */
@Component({
selector: 'example-dialog-close-button',
imports: [
Dialog,
DialogTrigger,
DialogPortal,
DialogClose,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogCloseButton,
NdIcon,
Button,
Input,
Label,
],
template: `
<ng-container ndDialog>
<button ndButton ndDialogTrigger variant="outline">Share</button>
<ng-template ndDialogPortal>
<div ndDialogOverlay></div>
<div ndDialogContent class="sm:max-w-md">
<div ndDialogHeader>
<h2 ndDialogTitle>Share link</h2>
<p ndDialogDescription>
Anyone who has this link will be able to view this.
</p>
</div>
<div class="grid gap-2">
<label ndLabel for="dialog-close-button-link" class="sr-only">
Link
</label>
<input
ndInput
id="dialog-close-button-link"
value="https://ndop.dev/docs/installation"
readonly
/>
</div>
<div ndDialogFooter class="sm:justify-start">
<button ndButton ndDialogClose variant="secondary">Close</button>
</div>
<button ndDialogCloseButton>
<svg ndIcon="x"></svg>
<span class="sr-only">Close</span>
</button>
</div>
</ng-template>
</ng-container>
`,
})
export class DialogCloseButtonExample {}
Dialog unsaved changes
A dialog that only closes through its buttons, so a click outside doesn't lose the user's changes.
import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogTitle,
Dialog,
DialogClose,
DialogPortal,
DialogTrigger,
} from '@/ui/dialog/dialog';
import { Label } from '@/ui/label/label';
import { Textarea } from '@/ui/textarea/textarea';
/**
* A dialog that a click outside doesn't close, so the user doesn't lose
* what they typed by accident. Escape and the buttons still close it.
*/
@Component({
selector: 'example-dialog-unsaved-changes',
imports: [
Button,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogTitle,
Label,
Dialog,
DialogClose,
DialogPortal,
DialogTrigger,
Textarea,
],
template: `
<ng-container ndDialog [closeOnOverlayClick]="false">
<button ndButton ndDialogTrigger variant="outline">Write a note</button>
<ng-template ndDialogPortal>
<div ndDialogOverlay></div>
<div ndDialogContent class="sm:max-w-[425px]">
<div ndDialogHeader>
<h2 ndDialogTitle>New note</h2>
<p ndDialogDescription>
The note is kept until you save or discard it.
</p>
</div>
<div class="grid gap-2">
<label ndLabel for="dialog-unsaved-changes-note">Note</label>
<textarea ndTextarea id="dialog-unsaved-changes-note"></textarea>
</div>
<div ndDialogFooter>
<button ndButton ndDialogClose variant="outline">Discard</button>
<button ndButton ndDialogClose>Save note</button>
</div>
</div>
</ng-template>
</ng-container>
`,
})
export class DialogUnsavedChangesExample {}
Accessibility
Implements the Dialog (Modal) pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| Tab | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. |
| ShiftTab | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. |
| Escape | Closes the dialog, unless closeOnEscape is false. |
Focus
- On open: focus moves to the first focusable element in the content, or to the content element itself when it has none.
- While open: focus is trapped in the content.
- On close: focus returns to the element that had it before opening, usually the trigger.
Right-to-left
Nothing mirrors: the dialog is centered, and Tab order follows the DOM.
API reference
Dialog
[ndDialog]A modal dialog (@ndop/primitives/dialog): it renders its portal's
content over the page while open, traps focus inside it and returns it on
close, and closes on Escape or a backdrop click. Put it on an
ng-container; open is two-way bindable.
| Dialog binding | Type | Default |
|---|---|---|
[defaultOpen]Initial open state when open is not bound. | boolean | false |
[closeOnEscape]Whether Escape closes the dialog. | boolean | true |
[closeOnOverlayClick]Whether a click on [ndDialogOverlay] closes the dialog. | boolean | true |
[(open)]Whether the dialog is open. Two-way bindable with [(open)]. | boolean | false |
DialogTrigger
[ndDialogTrigger]The button that opens its dialog, with aria-haspopup, aria-expanded
and aria-controls.
Manages aria-haspopup, aria-expanded, aria-controls.
| Data attribute | Values |
|---|---|
data-slot | dialog-trigger |
data-state | — |
DialogPortal
ng-template[ndDialogPortal]Marks the ng-template its dialog renders while open.
DialogClose
[ndDialogClose]A button that closes its dialog.
| Data attribute | Values |
|---|---|
data-slot | dialog-close |
DialogOverlay
[ndDialogOverlay]Backdrop behind the dialog; a click on it closes the dialog.
Manages aria-hidden.
| DialogOverlay binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | dialog-overlay |
data-state | — |
DialogContent
[ndDialogContent]role dialogThe dialog box, centered on screen.
Manages aria-modal, aria-labelledby, aria-describedby.
| DialogContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | dialog-content |
data-state | — |
DialogCloseButton
button[ndDialogCloseButton]The icon button in the content's corner that closes the dialog. Put an
<svg ndIcon="x"> and a sr-only label inside it.
| DialogCloseButton binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | dialog-close |
DialogHeader
[ndDialogHeader]Layout for the title and description.
| DialogHeader binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | dialog-header |
DialogFooter
[ndDialogFooter]Layout for the action buttons.
| DialogFooter binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | dialog-footer |
DialogTitle
[ndDialogTitle]The dialog's title; it names the dialog.
| DialogTitle binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | dialog-title |
DialogDescription
[ndDialogDescription]The dialog's description; it describes the dialog.
| DialogDescription binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | dialog-description |
Agent notes
Do
- Give every dialog an
ndDialogTitle; it names the dialog for screen readers. - Add an
ndDialogDescriptionwhen the title alone doesn't explain what is being asked. - Keep one task per dialog, with the main action in the footer.
- Bind
[(open)]when the dialog's state matters to the page, for example to close it after a successful save.
Don't
- Don't open a dialog from another dialog; replace its content or use a page instead.
- Don't put long or scrolling forms in a dialog; send people to a page.
- Don't use a dialog for messages that don't need an answer; show them inline.