Sheet
A dialog that slides in from an edge of the screen, for secondary content such as settings or filters.
- v1.0.0
- WAI-ARIA: Dialog (Modal)
- SSR
- Zoneless
- 3.3 kB gzip
- +9.7 kB Angular CDK, shared
import { Component, signal } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import {
Sheet,
SheetClose,
SheetCloseButton,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
} from '@/ui/sheet/sheet';
/**
* A profile form in a sheet from the end side, bound to Signal Forms and
* saved from its footer, which closes the sheet.
*/
@Component({
selector: 'example-sheet-demo',
imports: [
Button,
FormField,
Input,
Label,
NdIcon,
Sheet,
SheetClose,
SheetCloseButton,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
],
template: `
<ng-container ndSheet [(open)]="open">
<button ndButton variant="outline" ndSheetTrigger>Edit profile</button>
<ng-template ndSheetPortal>
<div ndSheetOverlay></div>
<div ndSheetContent>
<div ndSheetHeader>
<h2 ndSheetTitle>Edit profile</h2>
<p ndSheetDescription>
Make changes to your profile here. Save when you're done.
</p>
</div>
<form
class="grid flex-1 auto-rows-min gap-6 px-4"
(submit)="save($event)"
id="sheet-demo-form"
>
<div class="grid gap-3">
<label ndLabel for="sheet-demo-name">Name</label>
<input ndInput id="sheet-demo-name" [formField]="profile.name" />
</div>
<div class="grid gap-3">
<label ndLabel for="sheet-demo-username">Username</label>
<input
ndInput
id="sheet-demo-username"
[formField]="profile.username"
/>
</div>
</form>
<div ndSheetFooter>
<button ndButton type="submit" form="sheet-demo-form">
Save changes
</button>
<button ndButton variant="outline" ndSheetClose>Cancel</button>
</div>
<button ndSheetCloseButton>
<svg ndIcon="x"></svg>
<span class="sr-only">Close</span>
</button>
</div>
</ng-template>
</ng-container>
`,
})
export class SheetDemoExample {
protected readonly open = signal(false);
protected readonly model = signal({ name: 'Ada Lovelace', username: 'ada' });
protected readonly profile = form(this.model, (path) => {
required(path.name);
required(path.username);
});
/**
* Closes the sheet once the profile is valid, instead of reloading.
*
* @param event - The form's submit event.
*/
protected save(event: Event): void {
event.preventDefault();
this.profile().markAsTouched();
if (this.profile().valid()) {
this.open.set(false);
}
}
}
A sheet is a dialog that slides in from an edge of the screen, for
content that goes with the page without replacing it, such as settings,
filters or a cart. It's a modal dialog: focus is trapped inside while
it's open and returns when it closes, and Escape or the backdrop closes
it. It comes from the end side by default; start and end are logical,
so they swap in right-to-left layouts.
Installation
npx @ndop/cli add sheetpnpm dlx @ndop/cli add sheetyarn dlx @ndop/cli add sheetbunx @ndop/cli add sheetInstall 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/sheet/sheet.tsimport { computed, Directive, input } from '@angular/core'; import { NdDialog, NdDialogClose, NdDialogContent, NdDialogDescription, NdDialogOverlay, NdDialogPortal, NdDialogTitle, NdDialogTrigger, } from '@ndop/primitives/dialog'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * A sheet: a modal dialog (`@ndop/primitives/dialog`) that slides in from * an edge, for secondary content such as settings or filters. Put it on * an `ng-container`; `open`, `defaultOpen`, `closeOnEscape` and * `closeOnOverlayClick` are Dialog's. * * @example * ```html * <ng-container ndSheet> * <button ndButton variant="outline" ndSheetTrigger>Open</button> * <ng-template ndSheetPortal> * <div ndSheetOverlay></div> * <div ndSheetContent side="end"> * <div ndSheetHeader><h2 ndSheetTitle>Edit profile</h2></div> * </div> * </ng-template> * </ng-container> * ``` */ @Directive({ selector: '[ndSheet]', exportAs: 'ndSheet', hostDirectives: [ { directive: NdDialog, inputs: ['open', 'defaultOpen', 'closeOnEscape', 'closeOnOverlayClick'], outputs: ['openChange'], }, ], }) export class Sheet {} /** * The button that opens a sheet. * * @example * ```html * <button ndButton ndSheetTrigger>Open</button> * ``` */ @Directive({ selector: '[ndSheetTrigger]', hostDirectives: [NdDialogTrigger], host: { 'data-slot': 'sheet-trigger' }, }) export class SheetTrigger {} /** * Marks the `ng-template` a sheet renders while open. * * @example * ```html * <ng-template ndSheetPortal>…</ng-template> * ``` */ @Directive({ selector: 'ng-template[ndSheetPortal]', hostDirectives: [NdDialogPortal], }) export class SheetPortal {} /** * A button that closes its sheet. * * @example * ```html * <button ndButton ndSheetClose>Cancel</button> * ``` */ @Directive({ selector: '[ndSheetClose]', hostDirectives: [NdDialogClose], host: { 'data-slot': 'sheet-close' }, }) export class SheetClose {} /** * The dimmed backdrop behind a sheet; clicking it closes the sheet. * * @example * ```html * <div ndSheetOverlay></div> * ``` */ @Directive({ selector: '[ndSheetOverlay]', hostDirectives: [NdDialogOverlay], host: { 'data-slot': 'sheet-overlay', '[class]': 'classes()' }, }) export class SheetOverlay { /** 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(), ), ); } /** Class table for {@link SheetContent}. */ export const sheetVariants = variants({ base: 'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500', variants: { side: { end: 'inset-y-0 end-0 h-full w-3/4 border-s data-[state=closed]:slide-out-to-end data-[state=open]:slide-in-from-end sm:max-w-sm', start: 'inset-y-0 start-0 h-full w-3/4 border-e data-[state=closed]:slide-out-to-start data-[state=open]:slide-in-from-start sm:max-w-sm', top: 'inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', bottom: 'inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', }, }, defaults: { side: 'end' }, }); /** The edge a {@link SheetContent} slides in from. */ export type SheetSide = VariantOf<typeof sheetVariants, 'side'>; /** * The sheet panel: a modal dialog labelled by its title, sliding in from * its `side`. `start` and `end` are logical, so they swap in * right-to-left layouts. * * @example * ```html * <div ndSheetContent side="end">…</div> * ``` */ @Directive({ selector: '[ndSheetContent]', hostDirectives: [NdDialogContent], host: { 'data-slot': 'sheet-content', '[attr.data-side]': 'side()', '[class]': 'classes()', }, }) export class SheetContent { /** The edge it slides in from. */ readonly side = input<SheetSide>('end'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(sheetVariants({ side: this.side() }), this.class()), ); } /** * The close button in a sheet's corner; put an icon and a visually hidden * name in it. * * @example * ```html * <button ndSheetCloseButton> * <svg ndIcon="x"></svg><span class="sr-only">Close</span> * </button> * ``` */ @Directive({ selector: 'button[ndSheetCloseButton]', hostDirectives: [NdDialogClose], host: { type: 'button', 'data-slot': 'sheet-close', '[class]': 'classes()', }, }) export class SheetCloseButton { /** 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-secondary [&_svg]:size-4', this.class(), ), ); } /** * Lays out a sheet's title and description. * * @example * ```html * <div ndSheetHeader>…</div> * ``` */ @Directive({ selector: '[ndSheetHeader]', host: { 'data-slot': 'sheet-header', '[class]': 'classes()' }, }) export class SheetHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col gap-1.5 p-4', this.class()), ); } /** * Lays out a sheet's buttons at its bottom. * * @example * ```html * <div ndSheetFooter>…</div> * ``` */ @Directive({ selector: '[ndSheetFooter]', host: { 'data-slot': 'sheet-footer', '[class]': 'classes()' }, }) export class SheetFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mt-auto flex flex-col gap-2 p-4', this.class()), ); } /** * A sheet's title, which labels it. * * @example * ```html * <h2 ndSheetTitle>Edit profile</h2> * ``` */ @Directive({ selector: '[ndSheetTitle]', hostDirectives: [NdDialogTitle], host: { 'data-slot': 'sheet-title', '[class]': 'classes()' }, }) export class SheetTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-semibold text-foreground', this.class()), ); } /** * A sheet's description. * * @example * ```html * <p ndSheetDescription>Make changes to your profile here.</p> * ``` */ @Directive({ selector: '[ndSheetDescription]', hostDirectives: [NdDialogDescription], host: { 'data-slot': 'sheet-description', '[class]': 'classes()' }, }) export class SheetDescription { /** 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 { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import {
Sheet,
SheetClose,
SheetCloseButton,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
} from '@/ui/sheet/sheet';<ng-container ndSheet [(open)]="open">
<button ndButton variant="outline" ndSheetTrigger>Edit profile</button>
<ng-template ndSheetPortal>
<div ndSheetOverlay></div>
<div ndSheetContent>
<div ndSheetHeader>
<h2 ndSheetTitle>Edit profile</h2>
<p ndSheetDescription>
Make changes to your profile here. Save when you're done.
</p>
</div>
<form
class="grid flex-1 auto-rows-min gap-6 px-4"
(submit)="save($event)"
id="sheet-demo-form"
>
<div class="grid gap-3">
<label ndLabel for="sheet-demo-name">Name</label>
<input ndInput id="sheet-demo-name" [formField]="profile.name" />
</div>
<div class="grid gap-3">
<label ndLabel for="sheet-demo-username">Username</label>
<input
ndInput
id="sheet-demo-username"
[formField]="profile.username"
/>
</div>
</form>
<div ndSheetFooter>
<button ndButton type="submit" form="sheet-demo-form">
Save changes
</button>
<button ndButton variant="outline" ndSheetClose>Cancel</button>
</div>
<button ndSheetCloseButton>
<svg ndIcon="x"></svg>
<span class="sr-only">Close</span>
</button>
</div>
</ng-template>
</ng-container>Examples
Sheet sides
A sheet from each edge: top, end, bottom and start.
import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
type SheetSide,
} from '@/ui/sheet/sheet';
/**
* A sheet from each edge. `start` and `end` are logical, so they swap in
* right-to-left layouts.
*/
@Component({
selector: 'example-sheet-sides',
imports: [
Button,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
],
template: `
<div class="grid grid-cols-2 gap-2">
@for (side of sides; track side) {
<ng-container ndSheet>
<button ndButton variant="outline" ndSheetTrigger class="capitalize">
{{ side }}
</button>
<ng-template ndSheetPortal>
<div ndSheetOverlay></div>
<div ndSheetContent [side]="side">
<div ndSheetHeader>
<h2 ndSheetTitle class="capitalize">{{ side }} sheet</h2>
<p ndSheetDescription>It slides in from the {{ side }}.</p>
</div>
<div class="p-4">
<button ndButton variant="outline" ndSheetClose>Close</button>
</div>
</div>
</ng-template>
</ng-container>
}
</div>
`,
})
export class SheetSidesExample {
protected readonly sides: readonly SheetSide[] = [
'top',
'end',
'bottom',
'start',
];
}
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
Sheet
[ndSheet]A sheet: a modal dialog (@ndop/primitives/dialog) that slides in from
an edge, for secondary content such as settings or filters. Put it on
an ng-container; open, defaultOpen, closeOnEscape and
closeOnOverlayClick are Dialog's.
| Sheet 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 |
SheetTrigger
[ndSheetTrigger]The button that opens a sheet.
Manages aria-haspopup, aria-expanded, aria-controls.
| Data attribute | Values |
|---|---|
data-slot | sheet-trigger |
data-state | — |
SheetPortal
ng-template[ndSheetPortal]Marks the ng-template a sheet renders while open.
SheetClose
[ndSheetClose]A button that closes its sheet.
| Data attribute | Values |
|---|---|
data-slot | sheet-close |
SheetOverlay
[ndSheetOverlay]The dimmed backdrop behind a sheet; clicking it closes the sheet.
Manages aria-hidden.
| SheetOverlay binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | sheet-overlay |
data-state | — |
SheetContent
[ndSheetContent]role dialogThe sheet panel: a modal dialog labelled by its title, sliding in from
its side. start and end are logical, so they swap in
right-to-left layouts.
Manages aria-modal, aria-labelledby, aria-describedby.
| SheetContent binding | Type | Default |
|---|---|---|
[side]The edge it slides in from. | 'bottom' | 'top' | 'start' | 'end' | 'end' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | sheet-content |
data-side | — |
data-state | — |
SheetCloseButton
button[ndSheetCloseButton]The close button in a sheet's corner; put an icon and a visually hidden name in it.
| SheetCloseButton binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | sheet-close |
SheetHeader
[ndSheetHeader]Lays out a sheet's title and description.
| SheetHeader binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | sheet-header |
SheetFooter
[ndSheetFooter]Lays out a sheet's buttons at its bottom.
| SheetFooter binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | sheet-footer |
SheetTitle
[ndSheetTitle]A sheet's title, which labels it.
| SheetTitle binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | sheet-title |
SheetDescription
[ndSheetDescription]A sheet's description.
| SheetDescription binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | sheet-description |
Agent notes
Do
- Give it a title, which names it.
- Put a close button in it, with a visible or visually hidden name.
- Use the end side for secondary panels and the bottom on small screens.
Don't
- Don't use it for your main navigation on large screens; use a Sidebar.
- Don't stack sheets on top of each other.