Popover
Displays rich content in a portal, triggered by a button.
- v1.0.0
- WAI-ARIA: dialog role
- SSR
- Zoneless
- 3.7 kB gzip
- +12.9 kB Angular CDK, shared
import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import {
Popover,
PopoverPortal,
PopoverTrigger,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
} from '@/ui/popover/popover';
/** A popover holding a small dimensions form. */
@Component({
selector: 'example-popover-demo',
imports: [
Popover,
PopoverTrigger,
PopoverPortal,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
Button,
Input,
Label,
],
template: `
<ng-container ndPopover>
<button ndButton ndPopoverTrigger variant="outline">Open popover</button>
<ng-template ndPopoverPortal>
<div ndPopoverContent class="w-80" aria-labelledby="popover-demo-title">
<div class="grid gap-4">
<div ndPopoverHeader>
<h4 ndPopoverTitle id="popover-demo-title">Dimensions</h4>
<p ndPopoverDescription>Set the dimensions for the layer.</p>
</div>
<div class="grid gap-2">
@for (field of fields; track field.id) {
<div class="grid grid-cols-3 items-center gap-4">
<label ndLabel [for]="field.id">{{ field.label }}</label>
<input
ndInput
[id]="field.id"
[value]="field.value"
class="col-span-2 h-8"
/>
</div>
}
</div>
</div>
</div>
</ng-template>
</ng-container>
`,
})
export class PopoverDemoExample {
protected readonly fields = [
{ id: 'popover-demo-width', label: 'Width', value: '100%' },
{ id: 'popover-demo-max-width', label: 'Max. width', value: '300px' },
{ id: 'popover-demo-height', label: 'Height', value: '25px' },
{ id: 'popover-demo-max-height', label: 'Max. height', value: 'none' },
];
}
A popover shows rich, interactive content next to the element that opens
it, such as a small form or a list of filters. It is non-modal: the rest of
the page stays usable, and clicking outside or pressing Escape closes it,
returning focus to the trigger. side and align place it, using logical
start and end so it mirrors in right-to-left layouts.
Installation
npx @ndop/cli add popoverpnpm dlx @ndop/cli add popoveryarn dlx @ndop/cli add popoverbunx @ndop/cli add popoverInstall 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/popover/popover.tsimport { computed, Directive, input } from '@angular/core'; import { NdPopover, NdPopoverClose, NdPopoverContent, NdPopoverPortal, NdPopoverTrigger, } from '@ndop/primitives/popover'; import { cn } from '@/lib/utils'; /** * A popover (`@ndop/primitives/popover`): rich content anchored to its * trigger, opened and closed with it. Put it on an `ng-container`; `open` * is two-way bindable. * * @example * ```html * <ng-container ndPopover>…</ng-container> * ``` */ @Directive({ selector: '[ndPopover]', hostDirectives: [ { directive: NdPopover, inputs: ['open', 'defaultOpen'], outputs: ['openChange'], }, ], }) export class Popover {} /** * The button that opens and closes its popover. * * @example * ```html * <button ndButton variant="outline" ndPopoverTrigger>Filters</button> * ``` */ @Directive({ selector: '[ndPopoverTrigger]', hostDirectives: [NdPopoverTrigger], host: { 'data-slot': 'popover-trigger' }, }) export class PopoverTrigger {} /** * Marks the `ng-template` its popover renders while open. * * @example * ```html * <ng-template ndPopoverPortal>…</ng-template> * ``` */ @Directive({ selector: 'ng-template[ndPopoverPortal]', hostDirectives: [NdPopoverPortal], }) export class PopoverPortal {} /** * A button that closes its popover. * * @example * ```html * <button ndButton ndPopoverClose>Done</button> * ``` */ @Directive({ selector: '[ndPopoverClose]', hostDirectives: [NdPopoverClose], host: { 'data-slot': 'popover-close' }, }) export class PopoverClose {} /** * The popover panel, placed next to its trigger. * * @example * ```html * <div ndPopoverContent align="start" aria-label="Filters">…</div> * ``` */ @Directive({ selector: '[ndPopoverContent]', hostDirectives: [ { directive: NdPopoverContent, inputs: ['side', 'align', 'sideOffset'] }, ], host: { 'data-slot': 'popover-content', '[class]': 'classes()' }, }) export class PopoverContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 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', this.class(), ), ); } /** * Layout for the popover's title and description. * * @example * ```html * <div ndPopoverHeader>…</div> * ``` */ @Directive({ selector: '[ndPopoverHeader]', host: { 'data-slot': 'popover-header', '[class]': 'classes()' }, }) export class PopoverHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col gap-1 text-sm', this.class()), ); } /** * The popover's title. Point the content's `aria-labelledby` at it. * * @example * ```html * <h2 ndPopoverTitle id="filters-title">Filters</h2> * ``` */ @Directive({ selector: '[ndPopoverTitle]', host: { 'data-slot': 'popover-title', '[class]': 'classes()' }, }) export class PopoverTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-medium', this.class())); } /** * Supporting text under the popover's title. * * @example * ```html * <p ndPopoverDescription>Narrow the results.</p> * ``` */ @Directive({ selector: '[ndPopoverDescription]', host: { 'data-slot': 'popover-description', '[class]': 'classes()' }, }) export class PopoverDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('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 {
Popover,
PopoverPortal,
PopoverTrigger,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
} from '@/ui/popover/popover';<ng-container ndPopover>
<button ndButton ndPopoverTrigger variant="outline">Open popover</button>
<ng-template ndPopoverPortal>
<div ndPopoverContent class="w-80" aria-labelledby="popover-demo-title">
<div class="grid gap-4">
<div ndPopoverHeader>
<h4 ndPopoverTitle id="popover-demo-title">Dimensions</h4>
<p ndPopoverDescription>Set the dimensions for the layer.</p>
</div>
<div class="grid gap-2">
@for (field of fields; track field.id) {
<div class="grid grid-cols-3 items-center gap-4">
<label ndLabel [for]="field.id">{{ field.label }}</label>
<input
ndInput
[id]="field.id"
[value]="field.value"
class="col-span-2 h-8"
/>
</div>
}
</div>
</div>
</div>
</ng-template>
</ng-container>Examples
Popover placement
Popovers on each logical side, with a close button inside. start and end follow the text direction.
import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import {
Popover,
PopoverClose,
PopoverPortal,
PopoverTrigger,
PopoverContent,
} from '@/ui/popover/popover';
/**
* Popovers on each logical side, with a close button inside. `start` and
* `end` follow the text direction.
*/
@Component({
selector: 'example-popover-placement',
imports: [
Popover,
PopoverTrigger,
PopoverPortal,
PopoverClose,
PopoverContent,
Button,
],
template: `
<div class="flex flex-wrap gap-2">
@for (placement of placements; track placement.side) {
<ng-container ndPopover>
<button ndButton ndPopoverTrigger variant="outline">
{{ placement.side }}
</button>
<ng-template ndPopoverPortal>
<div
ndPopoverContent
class="w-56"
[side]="placement.side"
[align]="placement.align"
[attr.aria-label]="'Placed ' + placement.side"
>
<p class="mb-3">Placed on the {{ placement.side }} side.</p>
<button ndButton ndPopoverClose size="sm" variant="secondary">
Done
</button>
</div>
</ng-template>
</ng-container>
}
</div>
`,
})
export class PopoverPlacementExample {
protected readonly placements = [
{ side: 'top', align: 'center' },
{ side: 'end', align: 'start' },
{ side: 'bottom', align: 'center' },
{ side: 'start', align: 'start' },
] as const;
}
Accessibility
Implements the dialog role pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| Escape | Closes the popover and returns focus to the trigger. |
| Tab | Moves focus through the content; past the last element, the popover closes and focus moves to what follows the trigger. |
| ShiftTab | Moves focus backwards through the content; before the first element, the popover closes and focus returns to the trigger. |
Focus
- On open: focus moves to the first focusable element in the content, or to the content element.
- Focus is not trapped.
- On close with Escape or PopoverClose: focus returns to the trigger.
- On close because focus or a pointer left: focus stays where the user put it.
Right-to-left
side="start" and side="end" place the content on the inline start and end of the trigger, which swap in RTL; align="start" and align="end" swap likewise.
API reference
Popover
[ndPopover]A popover (@ndop/primitives/popover): rich content anchored to its
trigger, opened and closed with it. Put it on an ng-container; open
is two-way bindable.
| Popover binding | Type | Default |
|---|---|---|
[defaultOpen]Initial open state when open is not bound. | boolean | false |
[(open)]Whether the popover is open. Two-way bindable with [(open)]. | boolean | false |
PopoverTrigger
[ndPopoverTrigger]The button that opens and closes its popover.
Manages aria-haspopup, aria-expanded, aria-controls.
| Data attribute | Values |
|---|---|
data-slot | popover-trigger |
data-state | — |
PopoverPortal
ng-template[ndPopoverPortal]Marks the ng-template its popover renders while open.
PopoverClose
[ndPopoverClose]A button that closes its popover.
| Data attribute | Values |
|---|---|
data-slot | popover-close |
PopoverContent
[ndPopoverContent]role dialogThe popover panel, placed next to its trigger.
| PopoverContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[side]Preferred side of the trigger; start and end swap in RTL. | 'bottom' | 'top' | 'start' | 'end' | 'bottom' |
[align]Preferred alignment along that side. | 'start' | 'end' | 'center' | 'center' |
[sideOffset]Gap from the trigger, in pixels. | number | 4 |
| Data attribute | Values |
|---|---|
data-slot | popover-content |
data-state | — |
data-side | — |
data-align | — |
PopoverHeader
[ndPopoverHeader]Layout for the popover's title and description.
| PopoverHeader binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | popover-header |
PopoverTitle
[ndPopoverTitle]The popover's title. Point the content's aria-labelledby at it.
| PopoverTitle binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | popover-title |
PopoverDescription
[ndPopoverDescription]Supporting text under the popover's title.
| PopoverDescription binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | popover-description |
Agent notes
Do
- Name the content with
aria-labelledbypointing at a heading inside it, or witharia-label. - Keep the content short and related to its trigger.
- Use
startandendrather than left and right, so the placement follows the text direction.
Don't
- Don't use a popover for a decision that must block the page; use a dialog.
- Don't put a popover's only copy of important information behind it; keep it reachable on the page too.