# Dialog

> A window overlaid on the primary window, rendering the content underneath inert.

Version 1.0.0 · WAI-ARIA pattern: [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) · SSR · Zoneless · 3.2 kB gzip

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

```bash
npx @ndop/cli add dialog
```

Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases.

### src/app/ui/dialog/dialog.ts

```ts
import { 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

```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';
```

## Usage

```ts
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';
```

```html
<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 demo

An edit-profile dialog with a Signal Forms form. Saving validates the form, closes the dialog when it passes, and keeps it open otherwise.

```ts
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;
    });
  }
}
```

### Dialog close button

A share dialog with a read-only link and a footer close button.

```ts
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.

```ts
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)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices.

### Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. |
| `Shift + Tab` | 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

Selector: `[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.

| Binding | Type | Default | Description |
| --- | --- | --- | --- |
| `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. |
| `[closeOnEscape]` | `boolean` | `true` | Whether Escape closes the dialog. |
| `[closeOnOverlayClick]` | `boolean` | `true` | Whether a click on `[ndDialogOverlay]` closes the dialog. |
| `[(open)]` | `boolean` | `false` | Whether the dialog is open. Two-way bindable with `[(open)]`. |

### DialogTrigger

Selector: `[ndDialogTrigger]`

The button that opens its dialog, with `aria-haspopup`, `aria-expanded`
and `aria-controls`.

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-trigger` |
| `data-state` |  |

### DialogPortal

Selector: `ng-template[ndDialogPortal]`

Marks the `ng-template` its dialog renders while open.

### DialogClose

Selector: `[ndDialogClose]`

A button that closes its dialog.

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-close` |

### DialogOverlay

Selector: `[ndDialogOverlay]`

Backdrop behind the dialog; a click on it closes the dialog.

| Binding | Type | Default | Description |
| --- | --- | --- | --- |
| `[class]` | `string` | `''` | Extra classes, merged last so they win. |

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-overlay` |
| `data-state` |  |

### DialogContent

Selector: `[ndDialogContent]` · Role: `dialog`

The dialog box, centered on screen.

| Binding | Type | Default | Description |
| --- | --- | --- | --- |
| `[class]` | `string` | `''` | Extra classes, merged last so they win. |

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-content` |
| `data-state` |  |

### DialogCloseButton

Selector: `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.

| Binding | Type | Default | Description |
| --- | --- | --- | --- |
| `[class]` | `string` | `''` | Extra classes, merged last so they win. |

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-close` |

### DialogHeader

Selector: `[ndDialogHeader]`

Layout for the title and description.

| Binding | Type | Default | Description |
| --- | --- | --- | --- |
| `[class]` | `string` | `''` | Extra classes, merged last so they win. |

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-header` |

### DialogFooter

Selector: `[ndDialogFooter]`

Layout for the action buttons.

| Binding | Type | Default | Description |
| --- | --- | --- | --- |
| `[class]` | `string` | `''` | Extra classes, merged last so they win. |

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-footer` |

### DialogTitle

Selector: `[ndDialogTitle]`

The dialog's title; it names the dialog.

| Binding | Type | Default | Description |
| --- | --- | --- | --- |
| `[class]` | `string` | `''` | Extra classes, merged last so they win. |

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-title` |

### DialogDescription

Selector: `[ndDialogDescription]`

The dialog's description; it describes the dialog.

| Binding | Type | Default | Description |
| --- | --- | --- | --- |
| `[class]` | `string` | `''` | Extra classes, merged last so they win. |

| Data attribute | Values |
| --- | --- |
| `data-slot` | `dialog-description` |

## Agent notes

### Do

- Give every dialog an `ndDialogTitle`; it names the dialog for screen readers.
- Add an `ndDialogDescription` when 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.
