Combobox
A text field that filters a list of options as the user types.
- v1.0.0
- WAI-ARIA: Editable combobox with list autocomplete
- SSR
- Zoneless
- 8.0 kB gzip
- +12.9 kB Angular CDK, shared
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Combobox,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
ComboboxTrigger,
} from '@/ui/combobox/combobox';
import { Label } from '@/ui/label/label';
/**
* A framework picker that filters as you type. Focus stays in the field;
* the arrow keys move through the options and Enter picks one.
*/
@Component({
selector: 'example-combobox-demo',
imports: [
Combobox,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
ComboboxTrigger,
Label,
NdIcon,
],
template: `
<div class="grid w-[240px] gap-3">
<label ndLabel for="combobox-demo-framework">Framework</label>
<ng-container ndCombobox [(value)]="framework">
<div ndComboboxControl>
<input
ndComboboxInput
id="combobox-demo-framework"
placeholder="Select a framework"
/>
<button ndComboboxTrigger><svg ndIcon="chevron-down"></svg></button>
</div>
<div ndComboboxContent>
<div ndComboboxList>
<div ndComboboxEmpty>No framework found.</div>
@for (option of frameworks; track option.value) {
<div ndComboboxItem [value]="option.value">
{{ option.label }}
<span ndComboboxItemIndicator><svg ndIcon="check"></svg></span>
</div>
}
</div>
</div>
</ng-container>
</div>
`,
})
export class ComboboxDemoExample {
protected readonly framework = signal<string | null>(null);
protected readonly frameworks = [
{ value: 'angular', label: 'Angular' },
{ value: 'analog', label: 'Analog' },
{ value: 'astro', label: 'Astro' },
{ value: 'nuxt', label: 'Nuxt' },
{ value: 'remix', label: 'Remix' },
{ value: 'sveltekit', label: 'SvelteKit' },
];
}
A combobox is a text field that filters a list of options below it as
the user types, for picking from many options, such as a country or a
framework. Focus stays in the field while the arrow keys move through
the options and Enter picks one. With multiple, the chosen options
show as chips beside the field, and Backspace removes the last one. Bind
forms on its field.
Installation
npx @ndop/cli add comboboxpnpm dlx @ndop/cli add comboboxyarn dlx @ndop/cli add comboboxbunx @ndop/cli add comboboxInstall 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/combobox/combobox.tsimport { computed, Directive, input } from '@angular/core'; import { NdCombobox, NdComboboxChip, NdComboboxChipRemove, NdComboboxChips, NdComboboxClear, NdComboboxContent, NdComboboxEmpty, NdComboboxGroup, NdComboboxInput, NdComboboxItem, NdComboboxItemIndicator, NdComboboxLabel, NdComboboxList, NdComboboxSeparator, NdComboboxTrigger, } from '@ndop/primitives/combobox'; import { buttonVariants } from '@/ui/button/button'; import { cn } from '@/lib/utils'; /** Classes of the small icon buttons beside the field and in chips. */ const iconButton = buttonVariants({ variant: 'ghost', size: 'icon-xs' }); /** * A combobox (`@ndop/primitives/combobox`): a text field that filters a * list of options below it as the user types. With `multiple`, chosen * options show as chips. Put it on an `ng-container`, and bind forms on * its field. * * @example * ```html * <ng-container ndCombobox [(value)]="framework"> * <div ndComboboxControl> * <input ndComboboxInput aria-label="Framework" placeholder="Select a framework" /> * <button ndComboboxTrigger><svg ndIcon="chevron-down"></svg></button> * </div> * <div ndComboboxContent> * <div ndComboboxList> * <div ndComboboxEmpty>No framework found.</div> * <div ndComboboxItem value="angular">Angular</div> * </div> * </div> * </ng-container> * ``` */ @Directive({ selector: '[ndCombobox]', hostDirectives: [ { directive: NdCombobox, inputs: [ 'value', 'values', 'multiple', 'search', 'open', 'filter', 'autoHighlight', 'disabled', 'required', 'invalid', ], outputs: ['valueChange', 'valuesChange', 'searchChange', 'openChange'], }, ], }) export class Combobox {} /** * The bordered row around a single-value combobox's field and its buttons. * The trigger hides while the clear button shows. * * @example * ```html * <div ndComboboxControl>…</div> * ``` */ @Directive({ selector: '[ndComboboxControl]', host: { 'data-slot': 'combobox-control', '[class]': 'classes()' }, }) export class ComboboxControl { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/combobox-control relative flex h-9 w-auto min-w-0 items-center gap-1 rounded-md border border-input bg-transparent pe-1 shadow-xs transition-[color,box-shadow] has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:ring-destructive/40', this.class(), ), ); } /** * The text field of a single-value combobox, inside its * {@link ComboboxControl}. * * @example * ```html * <input ndComboboxInput aria-label="Framework" /> * ``` */ @Directive({ selector: 'input[ndComboboxInput]', hostDirectives: [NdComboboxInput], host: { 'data-slot': 'combobox-input', '[class]': 'classes()' }, }) export class ComboboxInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'h-full w-full min-w-0 flex-1 bg-transparent px-3 py-1 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', this.class(), ), ); } /** * The button that opens and closes the list with the pointer. Put the * chevron icon in it. * * @example * ```html * <button ndComboboxTrigger><svg ndIcon="chevron-down"></svg></button> * ``` */ @Directive({ selector: 'button[ndComboboxTrigger]', hostDirectives: [NdComboboxTrigger], host: { 'data-slot': 'combobox-trigger', '[class]': 'classes()' }, }) export class ComboboxTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( iconButton, "[&_svg:not([class*='size-'])]:size-4", '[&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:text-muted-foreground', 'group-has-[[data-slot=combobox-clear]:not([hidden])]/combobox-control:hidden', this.class(), ), ); } /** * The button that empties the field and the value with the pointer. Put * the x icon in it. * * @example * ```html * <button ndComboboxClear><svg ndIcon="x"></svg></button> * ``` */ @Directive({ selector: 'button[ndComboboxClear]', hostDirectives: [NdComboboxClear], host: { 'data-slot': 'combobox-clear', '[class]': 'classes()' }, }) export class ComboboxClear { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(iconButton, '[&>svg]:pointer-events-none', this.class()), ); } /** * The popup, written after the field; it opens below the field or the * chips, as wide as them. * * @example * ```html * <div ndComboboxContent>…</div> * ``` */ @Directive({ selector: '[ndComboboxContent]', hostDirectives: [ { directive: NdComboboxContent, inputs: ['side', 'align', 'sideOffset'] }, ], host: { 'data-slot': 'combobox-content', '[class]': 'classes()' }, }) export class ComboboxContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/combobox-content relative max-h-96 w-(--nd-combobox-anchor-width) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 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=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', 'isolate z-50', this.class(), ), ); } /** * The scrolling list of options. * * @example * ```html * <div ndComboboxList>…</div> * ``` */ @Directive({ selector: '[ndComboboxList]', hostDirectives: [NdComboboxList], host: { 'data-slot': 'combobox-list', '[class]': 'classes()' }, }) export class ComboboxList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'max-h-[calc(--spacing(96)---spacing(9))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0', this.class(), ), ); } /** * One option. Put its {@link ComboboxItemIndicator} last. * * @example * ```html * <div ndComboboxItem value="angular">Angular</div> * ``` */ @Directive({ selector: '[ndComboboxItem]', hostDirectives: [ { directive: NdComboboxItem, inputs: ['value', 'disabled', 'label'] }, ], host: { 'data-slot': 'combobox-item', '[class]': 'classes()' }, }) export class ComboboxItem { /** 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 data-highlighted:bg-accent data-highlighted: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", this.class(), ), ); } /** * The check of a chosen option, at its end. * * @example * ```html * <span ndComboboxItemIndicator><svg ndIcon="check"></svg></span> * ``` */ @Directive({ selector: '[ndComboboxItemIndicator]', hostDirectives: [NdComboboxItemIndicator], host: { 'data-slot': 'combobox-item-indicator', '[class]': 'classes()' }, }) export class ComboboxItemIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none absolute end-2 flex size-4 items-center justify-center', '[&>svg]:pointer-events-none [&>svg]:size-4 pointer-coarse:[&>svg]:size-5', this.class(), ), ); } /** * Groups options, named by its {@link ComboboxLabel}. * * @example * ```html * <div ndComboboxGroup><div ndComboboxLabel>Frontend</div>…</div> * ``` */ @Directive({ selector: '[ndComboboxGroup]', hostDirectives: [NdComboboxGroup], host: { 'data-slot': 'combobox-group' }, }) export class ComboboxGroup {} /** * The heading of a {@link ComboboxGroup}. * * @example * ```html * <div ndComboboxLabel>Frontend</div> * ``` */ @Directive({ selector: '[ndComboboxLabel]', hostDirectives: [NdComboboxLabel], host: { 'data-slot': 'combobox-label', '[class]': 'classes()' }, }) export class ComboboxLabel { /** 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 pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm', this.class(), ), ); } /** * What shows while no option matches the field's text. * * @example * ```html * <div ndComboboxEmpty>No framework found.</div> * ``` */ @Directive({ selector: '[ndComboboxEmpty]', hostDirectives: [NdComboboxEmpty], host: { 'data-slot': 'combobox-empty', '[class]': 'classes()' }, }) export class ComboboxEmpty { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex', this.class(), ), ); } /** * A line between groups of options. * * @example * ```html * <div ndComboboxSeparator></div> * ``` */ @Directive({ selector: '[ndComboboxSeparator]', hostDirectives: [NdComboboxSeparator], host: { 'data-slot': 'combobox-separator', '[class]': 'classes()' }, }) export class ComboboxSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('-mx-1 my-1 h-px bg-border', this.class()), ); } /** * The bordered box around the chips and the field of a combobox with * `multiple`. * * @example * ```html * <div ndComboboxChips>…<input ndComboboxChipsInput /></div> * ``` */ @Directive({ selector: '[ndComboboxChips]', hostDirectives: [NdComboboxChips], host: { 'data-slot': 'combobox-chips', '[class]': 'classes()' }, }) export class ComboboxChips { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40', this.class(), ), ); } /** * One chosen value, as a chip. Put its {@link ComboboxChipRemove} last. * * @example * ```html * <span ndComboboxChip [value]="value">{{ value }}</span> * ``` */ @Directive({ selector: '[ndComboboxChip]', hostDirectives: [{ directive: NdComboboxChip, inputs: ['value'] }], host: { 'data-slot': 'combobox-chip', '[class]': 'classes()' }, }) export class ComboboxChip { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pe-0', this.class(), ), ); } /** * The button that removes its chip's value with the pointer. Put the x * icon in it. * * @example * ```html * <button ndComboboxChipRemove><svg ndIcon="x"></svg></button> * ``` */ @Directive({ selector: 'button[ndComboboxChipRemove]', hostDirectives: [NdComboboxChipRemove], host: { 'data-slot': 'combobox-chip-remove', '[class]': 'classes()' }, }) export class ComboboxChipRemove { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( iconButton, '-ms-1 opacity-50 hover:opacity-100', '[&>svg]:pointer-events-none', this.class(), ), ); } /** * The text field among the chips of a combobox with `multiple`. * * @example * ```html * <input ndComboboxChipsInput aria-label="Frameworks" /> * ``` */ @Directive({ selector: 'input[ndComboboxChipsInput]', hostDirectives: [NdComboboxInput], host: { 'data-slot': 'combobox-chip-input', '[class]': 'classes()' }, }) export class ComboboxChipsInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('min-w-16 flex-1 bg-transparent outline-none', this.class()), ); }src/app/ui/button/button.tsimport { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf<typeof buttonVariants, 'variant'>; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf<typeof buttonVariants, 'size'>; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * <button ndButton variant="outline" size="sm">Cancel</button> * <a ndButton variant="link" href="/docs">Docs</a> * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input<ButtonVariant>('default'); /** Height, padding and icon size. */ readonly size = input<ButtonSize>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), 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 {
Combobox,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
ComboboxTrigger,
} from '@/ui/combobox/combobox';
import { Label } from '@/ui/label/label';<div class="grid w-[240px] gap-3">
<label ndLabel for="combobox-demo-framework">Framework</label>
<ng-container ndCombobox [(value)]="framework">
<div ndComboboxControl>
<input
ndComboboxInput
id="combobox-demo-framework"
placeholder="Select a framework"
/>
<button ndComboboxTrigger><svg ndIcon="chevron-down"></svg></button>
</div>
<div ndComboboxContent>
<div ndComboboxList>
<div ndComboboxEmpty>No framework found.</div>
@for (option of frameworks; track option.value) {
<div ndComboboxItem [value]="option.value">
{{ option.label }}
<span ndComboboxItemIndicator><svg ndIcon="check"></svg></span>
</div>
}
</div>
</div>
</ng-container>
</div>Examples
Combobox clear
A combobox with a button that clears it.
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Combobox,
ComboboxClear,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
ComboboxTrigger,
} from '@/ui/combobox/combobox';
/**
* A combobox with a button that clears it, shown in place of the chevron
* once there is something to clear. Escape clears it from the keyboard.
*/
@Component({
selector: 'example-combobox-clear',
imports: [
Combobox,
ComboboxClear,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
ComboboxTrigger,
NdIcon,
],
template: `
<ng-container ndCombobox [(value)]="country">
<div ndComboboxControl class="w-[240px]">
<input
ndComboboxInput
aria-label="Country"
placeholder="Select a country"
/>
<button ndComboboxTrigger><svg ndIcon="chevron-down"></svg></button>
<button ndComboboxClear><svg ndIcon="x"></svg></button>
</div>
<div ndComboboxContent>
<div ndComboboxList>
<div ndComboboxEmpty>No country found.</div>
@for (name of countries; track name) {
<div ndComboboxItem [value]="name">
{{ name }}
<span ndComboboxItemIndicator><svg ndIcon="check"></svg></span>
</div>
}
</div>
</div>
</ng-container>
`,
})
export class ComboboxClearExample {
protected readonly country = signal<string | null>('Cameroon');
protected readonly countries = [
'Brazil',
'Cameroon',
'Canada',
'France',
'Japan',
'Nigeria',
];
}
Combobox multiple
Several frameworks, chosen as chips.
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Combobox,
ComboboxChip,
ComboboxChipRemove,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
} from '@/ui/combobox/combobox';
/**
* Several frameworks, chosen as chips. Picking an option toggles it and
* keeps the list open; Backspace in the empty field removes the last chip.
*/
@Component({
selector: 'example-combobox-multiple',
imports: [
Combobox,
ComboboxChip,
ComboboxChipRemove,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
NdIcon,
],
template: `
<ng-container ndCombobox multiple [(values)]="chosen">
<div ndComboboxChips class="w-xs max-w-full">
@for (value of chosen(); track value) {
<span ndComboboxChip [value]="value">
{{ labelOf(value) }}
<button ndComboboxChipRemove><svg ndIcon="x"></svg></button>
</span>
}
<input
ndComboboxChipsInput
aria-label="Frameworks"
placeholder="Add framework"
/>
</div>
<div ndComboboxContent>
<div ndComboboxList>
<div ndComboboxEmpty>No framework found.</div>
@for (option of frameworks; track option.value) {
<div ndComboboxItem [value]="option.value">
{{ option.label }}
<span ndComboboxItemIndicator><svg ndIcon="check"></svg></span>
</div>
}
</div>
</div>
</ng-container>
`,
})
export class ComboboxMultipleExample {
protected readonly chosen = signal<readonly string[]>(['angular']);
protected readonly frameworks = [
{ value: 'angular', label: 'Angular' },
{ value: 'analog', label: 'Analog' },
{ value: 'astro', label: 'Astro' },
{ value: 'nuxt', label: 'Nuxt' },
{ value: 'remix', label: 'Remix' },
{ value: 'sveltekit', label: 'SvelteKit' },
];
/**
* The label of a chosen framework, for its chip.
*
* @param value - The framework's value.
* @returns Its label.
*/
protected labelOf(value: string): string {
return (
this.frameworks.find((option) => option.value === value)?.label ?? value
);
}
}
Combobox groups
Options in labelled groups, with separators.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Combobox,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
} from '@/ui/combobox/combobox';
/**
* Options in labelled groups, with separators. A group hides while none
* of its options matches.
*/
@Component({
selector: 'example-combobox-groups',
imports: [
Combobox,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
NdIcon,
],
template: `
<ng-container ndCombobox>
<div ndComboboxControl class="w-[240px]">
<input ndComboboxInput aria-label="Food" placeholder="Select a food" />
<button ndComboboxTrigger><svg ndIcon="chevron-down"></svg></button>
</div>
<div ndComboboxContent>
<div ndComboboxList>
<div ndComboboxEmpty>No food found.</div>
@for (group of groups; track group.name; let last = $last) {
<div ndComboboxGroup>
<div ndComboboxLabel>{{ group.name }}</div>
@for (food of group.foods; track food) {
<div ndComboboxItem [value]="food">
{{ food }}
<span ndComboboxItemIndicator>
<svg ndIcon="check"></svg>
</span>
</div>
}
</div>
@if (!last) {
<div ndComboboxSeparator></div>
}
}
</div>
</div>
</ng-container>
`,
})
export class ComboboxGroupsExample {
protected readonly groups = [
{ name: 'Fruits', foods: ['Apple', 'Banana', 'Mango'] },
{ name: 'Vegetables', foods: ['Carrot', 'Okra', 'Spinach'] },
{ name: 'Grains', foods: ['Millet', 'Rice', 'Sorghum'] },
];
}
Combobox disabled
A disabled combobox, and one with a disabled option.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Combobox,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from '@/ui/combobox/combobox';
/**
* A disabled combobox, and one with a disabled option, which the arrow
* keys skip.
*/
@Component({
selector: 'example-combobox-disabled',
imports: [
Combobox,
ComboboxContent,
ComboboxControl,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
NdIcon,
],
template: `
<div class="flex flex-wrap items-center gap-4">
<ng-container ndCombobox disabled>
<div ndComboboxControl class="w-[200px]">
<input
ndComboboxInput
aria-label="Team"
placeholder="Select a team"
/>
<button ndComboboxTrigger><svg ndIcon="chevron-down"></svg></button>
</div>
<div ndComboboxContent>
<div ndComboboxList>
<div ndComboboxEmpty>No team found.</div>
<div ndComboboxItem value="design">Design</div>
</div>
</div>
</ng-container>
<ng-container ndCombobox>
<div ndComboboxControl class="w-[200px]">
<input
ndComboboxInput
aria-label="Region"
placeholder="Select a region"
/>
<button ndComboboxTrigger><svg ndIcon="chevron-down"></svg></button>
</div>
<div ndComboboxContent>
<div ndComboboxList>
<div ndComboboxEmpty>No region found.</div>
<div ndComboboxItem value="africa">Africa</div>
<div ndComboboxItem value="europe">Europe</div>
<div ndComboboxItem value="antarctica" disabled>
Antarctica (unavailable)
</div>
</div>
</div>
</ng-container>
</div>
`,
})
export class ComboboxDisabledExample {}
Accessibility
Implements the Editable combobox with list autocomplete pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| a | Types into the field, opening the popup and filtering its options. |
| ArrowDown | Opens the popup and highlights the chosen option or the first; while open, highlights the next option. |
| ArrowUp | Opens the popup and highlights the last option; while open, highlights the previous option. |
| Alt+ArrowDown | Opens the popup without highlighting an option. |
| Enter | Chooses the highlighted option. Without multiple the popup closes; with it, the option toggles and it stays open. |
| Escape | Closes the popup; if it's closed, clears the field and, without multiple, the value. |
| Backspace | With multiple, in an empty field, removes the last chosen value. |
| HomeorEnd | Move the caret in the field, as in any text field. |
| Tab | Closes the popup and moves focus on. |
Focus
- Focus stays in the field;
aria-activedescendantpoints at the highlighted option, which scrolls into view. - Clicking an option, the trigger, the clear button or a chip's remove button keeps focus in the field.
- Focus leaving the combobox closes the popup and 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
Combobox
[ndCombobox]A combobox (@ndop/primitives/combobox): a text field that filters a
list of options below it as the user types. With multiple, chosen
options show as chips. Put it on an ng-container, and bind forms on
its field.
| Combobox binding | Type | Default |
|---|---|---|
[multiple]Lets several options be chosen, shown as chips. | boolean | false |
[filter]Tests whether an option matches what was typed; null shows every
option, for lists filtered elsewhere. | NdFilter | null | ndContainsFilter |
[autoHighlight]Highlights the first shown option as the user types. | boolean | false |
[disabled]Disables the field. A disabled bound field does too. | boolean | false |
[required]Marks the combobox required: aria-required="true" on the field. | boolean | false |
[invalid]Marks the combobox invalid when no forms library is bound; with one,
the field's state decides. | boolean | false |
[(value)]The chosen option's value, or null, without multiple. Two-way
bindable with [(value)]. | string | null | null |
[(values)]The chosen options' values, with multiple. Two-way bindable with
[(values)]. | readonly string[] | [] |
[(search)]The field's text. Two-way bindable with [(search)]. | string | '' |
[(open)]Whether the popup is open. Two-way bindable with [(open)]. | boolean | false |
ComboboxControl
[ndComboboxControl]The bordered row around a single-value combobox's field and its buttons. The trigger hides while the clear button shows.
| ComboboxControl binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-control |
ComboboxInput
input[ndComboboxInput]role comboboxThe text field of a single-value combobox, inside its
ComboboxControl.
Manages aria-autocomplete, aria-expanded, aria-controls, aria-activedescendant, aria-required, aria-invalid, aria-describedby.
| ComboboxInput binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-input |
data-state | — |
data-disabled | — |
data-invalid | — |
ComboboxTrigger
button[ndComboboxTrigger]The button that opens and closes the list with the pointer. Put the chevron icon in it.
Manages aria-hidden, tabindex.
| ComboboxTrigger binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-trigger |
data-state | — |
ComboboxClear
button[ndComboboxClear]The button that empties the field and the value with the pointer. Put the x icon in it.
Manages aria-hidden, tabindex, hidden.
| ComboboxClear binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-clear |
ComboboxContent
[ndComboboxContent]The popup, written after the field; it opens below the field or the chips, as wide as them.
Manages hidden.
| ComboboxContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[side]Side of the anchor it opens on, before flipping. | 'bottom' | 'top' | 'start' | 'end' | 'bottom' |
[align]Alignment along that side. | 'start' | 'end' | 'center' | 'start' |
[sideOffset]Gap between the anchor and the popup, in pixels. | number | 6 |
| Data attribute | Values |
|---|---|
data-slot | combobox-content |
data-state | — |
data-side | — |
data-empty | — |
ComboboxList
[ndComboboxList]role listboxThe scrolling list of options.
Manages aria-labelledby, aria-label, aria-multiselectable, id.
| ComboboxList binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-list |
data-empty | — |
ComboboxItem
[ndComboboxItem]role optionOne option. Put its ComboboxItemIndicator last.
Manages aria-selected, aria-disabled, id, hidden.
| ComboboxItem binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[value]requiredThe value choosing it gives the combobox. | string | — |
[disabled]Makes it unchoosable; the arrow keys skip it. | boolean | false |
[label]Its text, when it differs from what it renders. | string | null | null |
| Data attribute | Values |
|---|---|
data-slot | combobox-item |
data-highlighted | — |
data-selected | — |
data-disabled | — |
ComboboxItemIndicator
[ndComboboxItemIndicator]The check of a chosen option, at its end.
Manages hidden.
| ComboboxItemIndicator binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-item-indicator |
ComboboxGroup
[ndComboboxGroup]role groupGroups options, named by its ComboboxLabel.
Manages aria-labelledby, hidden.
| Data attribute | Values |
|---|---|
data-slot | combobox-group |
ComboboxLabel
[ndComboboxLabel]The heading of a ComboboxGroup.
Manages id.
| ComboboxLabel binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-label |
ComboboxEmpty
[ndComboboxEmpty]What shows while no option matches the field's text.
Manages hidden.
| ComboboxEmpty binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-empty |
ComboboxSeparator
[ndComboboxSeparator]A line between groups of options.
Manages aria-hidden.
| ComboboxSeparator binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-separator |
ComboboxChips
[ndComboboxChips]The bordered box around the chips and the field of a combobox with
multiple.
| ComboboxChips binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-chips |
ComboboxChip
[ndComboboxChip]One chosen value, as a chip. Put its ComboboxChipRemove last.
| ComboboxChip binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[value]requiredThe chosen value it shows. | string | — |
| Data attribute | Values |
|---|---|
data-slot | combobox-chip |
data-disabled | — |
ComboboxChipRemove
button[ndComboboxChipRemove]The button that removes its chip's value with the pointer. Put the x icon in it.
Manages aria-hidden, tabindex.
| ComboboxChipRemove binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-chip-remove |
ComboboxChipsInput
input[ndComboboxChipsInput]role comboboxThe text field among the chips of a combobox with multiple.
Manages aria-autocomplete, aria-expanded, aria-controls, aria-activedescendant, aria-required, aria-invalid, aria-describedby.
| ComboboxChipsInput binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | combobox-chip-input |
data-state | — |
data-disabled | — |
data-invalid | — |
Agent notes
Do
- Name the field with a label, as any form field.
- Show an empty state for when nothing matches.
- Pass
[filter]="null"and filter yourself when options come from a server.
Don't
- Don't use it for a handful of options; use a Select or a Radio Group.
- Don't use it to run commands; use Command.