Select
A button that shows a value and opens a list of options to pick from.
- v1.0.0
- WAI-ARIA: Select-only combobox
- SSR
- Zoneless
- 6.0 kB gzip
- +16.6 kB Angular CDK, shared
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Label } from '@/ui/label/label';
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@/ui/select/select';
/**
* A fruit picker with a placeholder. Typing a letter on the closed select
* picks the next fruit starting with it.
*/
@Component({
selector: 'example-select-demo',
imports: [
Label,
NdIcon,
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
],
template: `
<div class="grid gap-3">
<label ndLabel for="select-demo-fruit">Fruit</label>
<ng-container ndSelect [(value)]="fruit">
<button ndSelectTrigger id="select-demo-fruit" class="w-[180px]">
<span ndSelectValue placeholder="Select a fruit"></span>
<svg ndIcon="chevron-down" class="size-4 opacity-50"></svg>
</button>
<div ndSelectContent>
@for (option of fruits; track option.value) {
<div ndSelectItem [value]="option.value">
<span ndSelectItemIndicator><svg ndIcon="check"></svg></span>
<span ndSelectItemText>{{ option.label }}</span>
</div>
}
</div>
</ng-container>
</div>
`,
})
export class SelectDemoExample {
protected readonly fruit = signal<string | null>(null);
protected readonly fruits = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'blueberry', label: 'Blueberry' },
{ value: 'grapes', label: 'Grapes' },
{ value: 'pineapple', label: 'Pineapple' },
];
}
A select shows the chosen value on a button that opens a list of options
below it. Typing on the closed button picks the next option starting with
those letters, as on a native select; open, the arrow keys and typing
move through the options and Enter picks one. The options are written in
place, so the button shows the chosen option's text on the server. Bind
forms on its trigger.
Installation
npx @ndop/cli add selectpnpm dlx @ndop/cli add selectyarn dlx @ndop/cli add selectbunx @ndop/cli add selectInstall 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/select/select.tsimport { computed, Directive, input } from '@angular/core'; import { NdSelect, NdSelectContent, NdSelectGroup, NdSelectItem, NdSelectItemIndicator, NdSelectItemText, NdSelectLabel, NdSelectSeparator, NdSelectTrigger, NdSelectValue, } from '@ndop/primitives/select'; import { cn } from '@/lib/utils'; /** * A select (`@ndop/primitives/select`): a button that shows its value and * opens a list of options below it. Put it on an `ng-container`, and bind * forms on its trigger. * * @example * ```html * <ng-container ndSelect [(value)]="fruit"> * <button ndSelectTrigger aria-label="Fruit"> * <span ndSelectValue placeholder="Select a fruit"></span> * <svg ndIcon="chevron-down" class="opacity-50"></svg> * </button> * <div ndSelectContent> * <div ndSelectItem value="apple"> * <span ndSelectItemIndicator><svg ndIcon="check"></svg></span> * <span ndSelectItemText>Apple</span> * </div> * </div> * </ng-container> * ``` */ @Directive({ selector: '[ndSelect]', hostDirectives: [ { directive: NdSelect, inputs: [ 'value', 'defaultValue', 'open', 'defaultOpen', 'disabled', 'required', 'invalid', ], outputs: ['valueChange', 'openChange'], }, ], }) export class Select {} /** * The button that shows the value and opens the options. `size` is * `default` or `sm`. Put the chevron icon in it, last. * * @example * ```html * <button ndSelectTrigger id="fruit" size="sm">…</button> * ``` */ @Directive({ selector: 'button[ndSelectTrigger]', hostDirectives: [NdSelectTrigger], host: { 'data-slot': 'select-trigger', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class SelectTrigger { /** Height: `default` or `sm`. */ readonly size = input<'default' | 'sm'>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", this.class(), ), ); } /** * The selected option's text, or `placeholder` while none is selected. * * @example * ```html * <span ndSelectValue placeholder="Select a fruit"></span> * ``` */ @Directive({ selector: '[ndSelectValue]', hostDirectives: [{ directive: NdSelectValue, inputs: ['placeholder'] }], host: { 'data-slot': 'select-value' }, }) export class SelectValue {} /** * The list of options, written after the trigger; it opens below the * trigger, at least as wide. * * @example * ```html * <div ndSelectContent>…</div> * ``` */ @Directive({ selector: '[ndSelectContent]', hostDirectives: [ { directive: NdSelectContent, inputs: ['side', 'align', 'sideOffset'] }, ], host: { 'data-slot': 'select-content', '[class]': 'classes()' }, }) export class SelectContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative z-50 max-h-[min(24rem,80vh)] min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md 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', 'p-1 scroll-my-1', this.class(), ), ); } /** * A heading for a group of options; point the group's `aria-labelledby` * at it. * * @example * ```html * <div ndSelectLabel id="fruits">Fruits</div> * ``` */ @Directive({ selector: '[ndSelectLabel]', hostDirectives: [NdSelectLabel], host: { 'data-slot': 'select-label', '[class]': 'classes()' }, }) export class SelectLabel { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('px-2 py-1.5 text-xs text-muted-foreground', this.class()), ); } /** * Groups options; name it with `aria-labelledby`. * * @example * ```html * <div ndSelectGroup aria-labelledby="fruits">…</div> * ``` */ @Directive({ selector: '[ndSelectGroup]', hostDirectives: [NdSelectGroup], host: { 'data-slot': 'select-group' }, }) export class SelectGroup {} /** * One option. Put its {@link SelectItemIndicator} first and its * {@link SelectItemText} after. * * @example * ```html * <div ndSelectItem value="apple">…</div> * ``` */ @Directive({ selector: '[ndSelectItem]', hostDirectives: [ { directive: NdSelectItem, inputs: ['value', 'disabled', 'label'] }, ], host: { 'data-slot': 'select-item', '[class]': 'classes()' }, }) export class SelectItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", this.class(), ), ); } /** * The check of the selected option, at its end. * * @example * ```html * <span ndSelectItemIndicator><svg ndIcon="check"></svg></span> * ``` */ @Directive({ selector: '[ndSelectItemIndicator]', hostDirectives: [NdSelectItemIndicator], host: { 'data-slot': 'select-item-indicator', '[class]': 'classes()' }, }) export class SelectItemIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute end-2 flex size-3.5 items-center justify-center', this.class(), ), ); } /** * The text of an option, which the trigger shows while it's selected. * * @example * ```html * <span ndSelectItemText>Apple</span> * ``` */ @Directive({ selector: '[ndSelectItemText]', hostDirectives: [NdSelectItemText], host: { 'data-slot': 'select-item-text' }, }) export class SelectItemText {} /** * A line between groups of options. * * @example * ```html * <div ndSelectSeparator></div> * ``` */ @Directive({ selector: '[ndSelectSeparator]', hostDirectives: [NdSelectSeparator], host: { 'data-slot': 'select-separator', '[class]': 'classes()' }, }) export class SelectSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('pointer-events-none -mx-1 my-1 h-px bg-border', 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 { Label } from '@/ui/label/label';
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@/ui/select/select';<div class="grid gap-3">
<label ndLabel for="select-demo-fruit">Fruit</label>
<ng-container ndSelect [(value)]="fruit">
<button ndSelectTrigger id="select-demo-fruit" class="w-[180px]">
<span ndSelectValue placeholder="Select a fruit"></span>
<svg ndIcon="chevron-down" class="size-4 opacity-50"></svg>
</button>
<div ndSelectContent>
@for (option of fruits; track option.value) {
<div ndSelectItem [value]="option.value">
<span ndSelectItemIndicator><svg ndIcon="check"></svg></span>
<span ndSelectItemText>{{ option.label }}</span>
</div>
}
</div>
</ng-container>
</div>Examples
Select groups
A long list of options in labelled groups.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/ui/select/select';
/** A long list of time zones in labelled groups, which scrolls. */
@Component({
selector: 'example-select-groups',
imports: [
NdIcon,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
],
template: `
<ng-container ndSelect>
<button ndSelectTrigger aria-label="Time zone" class="w-[280px]">
<span ndSelectValue placeholder="Select a time zone"></span>
<svg ndIcon="chevron-down" class="size-4 opacity-50"></svg>
</button>
<div ndSelectContent>
@for (region of regions; track region.name; let last = $last) {
<div ndSelectGroup [attr.aria-labelledby]="'select-groups-' + $index">
<div ndSelectLabel [id]="'select-groups-' + $index">
{{ region.name }}
</div>
@for (zone of region.zones; track zone.value) {
<div ndSelectItem [value]="zone.value">
<span ndSelectItemIndicator><svg ndIcon="check"></svg></span>
<span ndSelectItemText>{{ zone.label }}</span>
</div>
}
</div>
@if (!last) {
<div ndSelectSeparator></div>
}
}
</div>
</ng-container>
`,
})
export class SelectGroupsExample {
protected readonly regions = [
{
name: 'North America',
zones: [
{ value: 'est', label: 'Eastern Standard Time (EST)' },
{ value: 'cst', label: 'Central Standard Time (CST)' },
{ value: 'mst', label: 'Mountain Standard Time (MST)' },
{ value: 'pst', label: 'Pacific Standard Time (PST)' },
],
},
{
name: 'Europe and Africa',
zones: [
{ value: 'gmt', label: 'Greenwich Mean Time (GMT)' },
{ value: 'cet', label: 'Central European Time (CET)' },
{ value: 'wat', label: 'West Africa Time (WAT)' },
{ value: 'eat', label: 'East Africa Time (EAT)' },
],
},
{
name: 'Asia',
zones: [
{ value: 'ist', label: 'India Standard Time (IST)' },
{ value: 'cst-china', label: 'China Standard Time (CST)' },
{ value: 'jst', label: 'Japan Standard Time (JST)' },
],
},
];
}
Select sizes
The default and small triggers.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@/ui/select/select';
/** The default and small triggers. */
@Component({
selector: 'example-select-sizes',
imports: [
NdIcon,
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
],
template: `
<div class="flex flex-wrap items-center gap-4">
@for (size of sizes; track size) {
<ng-container ndSelect defaultValue="week">
<button
ndSelectTrigger
[size]="size"
[attr.aria-label]="'Range, ' + size + ' size'"
class="w-[140px]"
>
<span ndSelectValue></span>
<svg ndIcon="chevron-down" class="size-4 opacity-50"></svg>
</button>
<div ndSelectContent>
@for (range of ranges; track range.value) {
<div ndSelectItem [value]="range.value">
<span ndSelectItemIndicator><svg ndIcon="check"></svg></span>
<span ndSelectItemText>{{ range.label }}</span>
</div>
}
</div>
</ng-container>
}
</div>
`,
})
export class SelectSizesExample {
protected readonly sizes = ['default', 'sm'] as const;
protected readonly ranges = [
{ value: 'day', label: 'Today' },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
];
}
Select disabled
A disabled select, and one with a disabled option.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@/ui/select/select';
/**
* A disabled select, and one with a disabled option, which the arrow keys
* skip.
*/
@Component({
selector: 'example-select-disabled',
imports: [
NdIcon,
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
],
template: `
<div class="flex flex-wrap items-center gap-4">
<ng-container ndSelect disabled>
<button ndSelectTrigger aria-label="Plan" class="w-[180px]">
<span ndSelectValue placeholder="Select a plan"></span>
<svg ndIcon="chevron-down" class="size-4 opacity-50"></svg>
</button>
<div ndSelectContent>
<div ndSelectItem value="free">
<span ndSelectItemText>Free</span>
</div>
</div>
</ng-container>
<ng-container ndSelect>
<button ndSelectTrigger aria-label="Seats" class="w-[180px]">
<span ndSelectValue placeholder="Select seats"></span>
<svg ndIcon="chevron-down" class="size-4 opacity-50"></svg>
</button>
<div ndSelectContent>
<div ndSelectItem value="1">
<span ndSelectItemIndicator><svg ndIcon="check"></svg></span>
<span ndSelectItemText>1 seat</span>
</div>
<div ndSelectItem value="5">
<span ndSelectItemIndicator><svg ndIcon="check"></svg></span>
<span ndSelectItemText>5 seats</span>
</div>
<div ndSelectItem value="unlimited" disabled>
<span ndSelectItemIndicator><svg ndIcon="check"></svg></span>
<span ndSelectItemText>Unlimited (Enterprise)</span>
</div>
</div>
</ng-container>
</div>
`,
})
export class SelectDisabledExample {}
Accessibility
Implements the Select-only combobox pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| EnterorSpace | On the trigger, opens the listbox; on an option, selects it, closes and focuses the trigger. |
| ArrowDownorArrowUp | On the trigger, opens the listbox; in it, moves to the next or previous enabled option. |
| HomeorEnd | In the listbox, moves to the first or last enabled option. |
| a | Typeahead: on the closed trigger, selects the next option starting with the typed letters; in the listbox, moves to it. |
| Escape | Closes the listbox and focuses the trigger. |
| Tab | Closes the listbox and moves focus on from the trigger. |
Focus
- Opening focuses the selected option, or the first enabled one.
- Selecting an option,
EscapeandTabclose the listbox; the first two return focus to the trigger. A click outside closes it where focus is. - Focus leaving the trigger and listbox marks a bound field touched.
Right-to-left
start and end alignments are logical and swap in right-to-left layouts. The listbox is vertical, so its arrow keys don't change.
API reference
Select
[ndSelect]A select (@ndop/primitives/select): a button that shows its value and
opens a list of options below it. Put it on an ng-container, and bind
forms on its trigger.
| Select binding | Type | Default |
|---|---|---|
[defaultValue]Initial value when it isn't bound. | string | null | null |
[defaultOpen]Initial open state when open isn't bound, applied after hydration. | boolean | false |
[disabled]Disables the trigger. A disabled bound field does too. | boolean | false |
[required]Marks the select required: aria-required="true" on the trigger. | boolean | false |
[invalid]Marks the select invalid when no forms library is bound; with one, the
field's state decides. | boolean | false |
[(value)]The selected option's value, or null when none is. Two-way bindable
with [(value)]. | string | null | null |
[(open)]Whether the listbox is open. Two-way bindable with [(open)]. | boolean | false |
SelectTrigger
button[ndSelectTrigger]role comboboxThe button that shows the value and opens the options. size is
default or sm. Put the chevron icon in it, last.
Manages aria-haspopup, aria-expanded, aria-controls, aria-required, aria-invalid, aria-describedby.
| SelectTrigger binding | Type | Default |
|---|---|---|
[size]Height: default or sm. | 'default' | 'sm' | 'default' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | select-trigger |
data-size | — |
data-state | — |
data-placeholder | — |
data-disabled | — |
SelectValue
[ndSelectValue]The selected option's text, or placeholder while none is selected.
| SelectValue binding | Type | Default |
|---|---|---|
[placeholder]What it shows while no option is selected. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | select-value |
data-placeholder | — |
SelectContent
[ndSelectContent]role listboxThe list of options, written after the trigger; it opens below the trigger, at least as wide.
Manages aria-labelledby, aria-label, aria-orientation, id, hidden.
| SelectContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[side]Side of the trigger it opens on, before flipping. | 'bottom' | 'top' | 'start' | 'end' | 'bottom' |
[align]Alignment along that side. | 'start' | 'end' | 'center' | 'start' |
[sideOffset]Gap between the trigger and the listbox, in pixels. | number | 4 |
| Data attribute | Values |
|---|---|
data-slot | select-content |
data-state | — |
data-side | — |
SelectLabel
[ndSelectLabel]A heading for a group of options; point the group's aria-labelledby
at it.
| SelectLabel binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | select-label |
SelectGroup
[ndSelectGroup]role groupGroups options; name it with aria-labelledby.
| Data attribute | Values |
|---|---|
data-slot | select-group |
SelectItem
[ndSelectItem]role optionOne option. Put its SelectItemIndicator first and its
SelectItemText after.
Manages aria-selected, aria-disabled, id.
| SelectItem binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[value]requiredThe value the select takes when it's chosen. | string | — |
[disabled]Makes it unselectable; arrow keys skip it. | boolean | false |
[label]Its text, when it differs from what it renders. | string | null | null |
| Data attribute | Values |
|---|---|
data-slot | select-item |
data-state | — |
data-highlighted | — |
data-disabled | — |
SelectItemIndicator
[ndSelectItemIndicator]The check of the selected option, at its end.
Manages hidden.
| SelectItemIndicator binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | select-item-indicator |
data-state | — |
SelectItemText
[ndSelectItemText]The text of an option, which the trigger shows while it's selected.
| Data attribute | Values |
|---|---|
data-slot | select-item-text |
SelectSeparator
[ndSelectSeparator]A line between groups of options.
Manages aria-hidden.
| SelectSeparator binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | select-separator |
Agent notes
Do
- Name the trigger with a label, as any form field.
- Group long lists, with labels.
- Use
size="sm"in dense layouts.
Don't
- Don't use it for more than a few dozen options, or when people know what they want to type; use a Combobox.
- Don't use it for actions; use Dropdown Menu.