Command
A search field over a list of commands, filtered as the user types.
- v1.0.0
- WAI-ARIA: Combobox
- SSR
- Zoneless
- 3.7 kB gzip
- +1.6 kB Angular CDK, shared
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Command,
CommandEmpty,
CommandGroup,
CommandGroupHeading,
CommandInput,
CommandInputWrapper,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/ui/command/command';
/**
* An inline command list with groups, shortcuts and a disabled command.
* Focus stays in the search field; the arrow keys move the highlight and
* Enter runs it.
*/
@Component({
selector: 'example-command-demo',
imports: [
Command,
CommandEmpty,
CommandGroup,
CommandGroupHeading,
CommandInput,
CommandInputWrapper,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
NdIcon,
],
template: `
<div class="grid w-md max-w-full gap-2">
<div ndCommand label="Commands" class="rounded-lg border shadow-md">
<div ndCommandInputWrapper>
<svg ndIcon="search"></svg>
<input ndCommandInput placeholder="Type a command or search…" />
</div>
<div ndCommandList>
<div ndCommandEmpty>No results found.</div>
<div ndCommandGroup>
<div ndCommandGroupHeading>Suggestions</div>
<div ndCommandItem (triggered)="run('Calendar')">Calendar</div>
<div
ndCommandItem
keywords="smiley emoji"
(triggered)="run('Search emoji')"
>
Search emoji
</div>
<div ndCommandItem disabled>Calculator</div>
</div>
<div ndCommandSeparator></div>
<div ndCommandGroup>
<div ndCommandGroupHeading>Settings</div>
<div ndCommandItem (triggered)="run('Profile')">
Profile <span ndCommandShortcut>⌘P</span>
</div>
<div ndCommandItem (triggered)="run('Billing')">
Billing <span ndCommandShortcut>⌘B</span>
</div>
<div ndCommandItem (triggered)="run('Settings')">
Settings <span ndCommandShortcut>⌘S</span>
</div>
</div>
</div>
</div>
<p class="text-sm text-muted-foreground" aria-live="polite">
{{ ran() }}
</p>
</div>
`,
})
export class CommandDemoExample {
protected readonly ran = signal('');
/**
* Reports the command that ran.
*
* @param name - The command's name.
*/
protected run(name: string): void {
this.ran.set(`Ran ${name}.`);
}
}
A command menu is a search field over a list of commands, filtered as the user types, such as a ⌘K palette. Focus stays in the field; the arrow keys move the highlight and Enter runs the highlighted command. Commands can be grouped, have keywords that match them too, and show their keyboard shortcuts. Put it in a Dialog to open it as a palette.
Installation
npx @ndop/cli add commandpnpm dlx @ndop/cli add commandyarn dlx @ndop/cli add commandbunx @ndop/cli add commandInstall 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/command/command.tsimport { computed, Directive, input } from '@angular/core'; import { NdCommand, NdCommandEmpty, NdCommandGroup, NdCommandGroupHeading, NdCommandInput, NdCommandItem, NdCommandList, NdCommandSeparator, } from '@ndop/primitives/command'; import { cn } from '@/lib/utils'; /** * A command menu (`@ndop/primitives/command`): a search field over a list * of commands, filtered as the user types. Put it in a Dialog for a ⌘K * palette. * * @example * ```html * <div ndCommand label="Commands"> * <div ndCommandInputWrapper> * <svg ndIcon="search"></svg> * <input ndCommandInput placeholder="Type a command or search…" /> * </div> * <div ndCommandList> * <div ndCommandEmpty>No results found.</div> * <div ndCommandItem (triggered)="open()">Calendar</div> * </div> * </div> * ``` */ @Directive({ selector: '[ndCommand]', hostDirectives: [ { directive: NdCommand, inputs: ['label', 'search', 'filter', 'shouldFilter', 'loop'], outputs: ['searchChange'], }, ], host: { 'data-slot': 'command', '[class]': 'classes()' }, }) export class Command { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', this.class(), ), ); } /** * The row around the search field and its icon. * * @example * ```html * <div ndCommandInputWrapper><svg ndIcon="search"></svg>…</div> * ``` */ @Directive({ selector: '[ndCommandInputWrapper]', host: { 'data-slot': 'command-input-wrapper', '[class]': 'classes()' }, }) export class CommandInputWrapper { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-9 items-center gap-2 border-b px-3', '[&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:opacity-50', this.class(), ), ); } /** * The search field; focus stays in it while the arrow keys move through * the commands. * * @example * ```html * <input ndCommandInput placeholder="Type a command or search…" /> * ``` */ @Directive({ selector: 'input[ndCommandInput]', hostDirectives: [NdCommandInput], host: { 'data-slot': 'command-input', '[class]': 'classes()' }, }) export class CommandInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', this.class(), ), ); } /** * The scrolling list of commands. * * @example * ```html * <div ndCommandList>…</div> * ``` */ @Directive({ selector: '[ndCommandList]', hostDirectives: [NdCommandList], host: { 'data-slot': 'command-list', '[class]': 'classes()' }, }) export class CommandList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', this.class(), ), ); } /** * What shows while no command matches. * * @example * ```html * <div ndCommandEmpty>No results found.</div> * ``` */ @Directive({ selector: '[ndCommandEmpty]', hostDirectives: [NdCommandEmpty], host: { 'data-slot': 'command-empty', '[class]': 'classes()' }, }) export class CommandEmpty { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('py-6 text-center text-sm', this.class()), ); } /** * A group of commands, named by its {@link CommandGroupHeading}. * * @example * ```html * <div ndCommandGroup> * <div ndCommandGroupHeading>Suggestions</div> * … * </div> * ``` */ @Directive({ selector: '[ndCommandGroup]', hostDirectives: [NdCommandGroup], host: { 'data-slot': 'command-group', '[class]': 'classes()' }, }) export class CommandGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('overflow-hidden p-1 text-foreground', this.class()), ); } /** * The heading of a {@link CommandGroup}. * * @example * ```html * <div ndCommandGroupHeading>Suggestions</div> * ``` */ @Directive({ selector: '[ndCommandGroupHeading]', hostDirectives: [NdCommandGroupHeading], host: { 'data-slot': 'command-group-heading', '[class]': 'classes()' }, }) export class CommandGroupHeading { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', this.class()), ); } /** * A line between groups, hidden while searching. * * @example * ```html * <div ndCommandSeparator></div> * ``` */ @Directive({ selector: '[ndCommandSeparator]', hostDirectives: [NdCommandSeparator], host: { 'data-slot': 'command-separator', '[class]': 'classes()' }, }) export class CommandSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('-mx-1 h-px bg-border', this.class()), ); } /** * One command; `triggered` emits when it runs. * * @example * ```html * <div ndCommandItem keywords="emoji" (triggered)="open()">Search emoji</div> * ``` */ @Directive({ selector: '[ndCommandItem]', hostDirectives: [ { directive: NdCommandItem, inputs: ['value', 'keywords', 'disabled'], outputs: ['triggered'], }, ], host: { 'data-slot': 'command-item', '[class]': 'classes()' }, }) export class CommandItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", this.class(), ), ); } /** * A command's keyboard shortcut, shown at its end. It's text for sighted * users; the command doesn't bind it. * * @example * ```html * <span ndCommandShortcut>⌘P</span> * ``` */ @Directive({ selector: '[ndCommandShortcut]', host: { 'data-slot': 'command-shortcut', '[class]': 'classes()' }, }) export class CommandShortcut { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('ms-auto text-xs tracking-widest 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 {
Command,
CommandEmpty,
CommandGroup,
CommandGroupHeading,
CommandInput,
CommandInputWrapper,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/ui/command/command';<div class="grid w-md max-w-full gap-2">
<div ndCommand label="Commands" class="rounded-lg border shadow-md">
<div ndCommandInputWrapper>
<svg ndIcon="search"></svg>
<input ndCommandInput placeholder="Type a command or search…" />
</div>
<div ndCommandList>
<div ndCommandEmpty>No results found.</div>
<div ndCommandGroup>
<div ndCommandGroupHeading>Suggestions</div>
<div ndCommandItem (triggered)="run('Calendar')">Calendar</div>
<div
ndCommandItem
keywords="smiley emoji"
(triggered)="run('Search emoji')"
>
Search emoji
</div>
<div ndCommandItem disabled>Calculator</div>
</div>
<div ndCommandSeparator></div>
<div ndCommandGroup>
<div ndCommandGroupHeading>Settings</div>
<div ndCommandItem (triggered)="run('Profile')">
Profile <span ndCommandShortcut>⌘P</span>
</div>
<div ndCommandItem (triggered)="run('Billing')">
Billing <span ndCommandShortcut>⌘B</span>
</div>
<div ndCommandItem (triggered)="run('Settings')">
Settings <span ndCommandShortcut>⌘S</span>
</div>
</div>
</div>
</div>
<p class="text-sm text-muted-foreground" aria-live="polite">
{{ ran() }}
</p>
</div>Examples
Command dialog
A command palette in a dialog, opened with Ctrl+K or Cmd+K.
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandGroupHeading,
CommandInput,
CommandInputWrapper,
CommandItem,
CommandList,
CommandShortcut,
} from '@/ui/command/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/ui/dialog/dialog';
/**
* A command palette in a dialog, opened with its button or with Ctrl+K
* (⌘K on a Mac). Running a command closes it.
*/
@Component({
selector: 'example-command-dialog',
imports: [
Button,
Command,
CommandEmpty,
CommandGroup,
CommandGroupHeading,
CommandInput,
CommandInputWrapper,
CommandItem,
CommandList,
CommandShortcut,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
NdIcon,
],
host: { '(document:keydown)': 'shortcut($event)' },
template: `
<ng-container ndDialog [(open)]="open">
<button ndButton variant="outline" ndDialogTrigger>
Open palette <kbd class="text-muted-foreground text-xs">⌘K</kbd>
</button>
<ng-template ndDialogPortal>
<div ndDialogOverlay></div>
<div ndDialogContent class="overflow-hidden p-0">
<div ndDialogHeader class="sr-only">
<h2 ndDialogTitle>Command palette</h2>
<p ndDialogDescription>Search for a command to run.</p>
</div>
<div
ndCommand
label="Commands"
class="**:data-[slot=command-input-wrapper]:h-12 **:data-[slot=command-group-heading]:px-2 **:data-[slot=command-group-heading]:font-medium **:data-[slot=command-group-heading]:text-muted-foreground **:data-[slot=command-group]:px-2 **:data-[slot=command-input-wrapper]:[&>svg]:h-5 **:data-[slot=command-input-wrapper]:[&>svg]:w-5 **:data-[slot=command-input]:h-12 **:data-[slot=command-item]:px-2 **:data-[slot=command-item]:py-3 **:data-[slot=command-item]:[&_svg]:h-5 **:data-[slot=command-item]:[&_svg]:w-5"
>
<div ndCommandInputWrapper>
<svg ndIcon="search"></svg>
<input ndCommandInput placeholder="Type a command or search…" />
</div>
<div ndCommandList>
<div ndCommandEmpty>No results found.</div>
<div ndCommandGroup>
<div ndCommandGroupHeading>Suggestions</div>
<div ndCommandItem (triggered)="run('Calendar')">Calendar</div>
<div ndCommandItem (triggered)="run('Search emoji')">
Search emoji
</div>
</div>
<div ndCommandGroup>
<div ndCommandGroupHeading>Settings</div>
<div ndCommandItem (triggered)="run('Profile')">
Profile <span ndCommandShortcut>⌘P</span>
</div>
<div ndCommandItem (triggered)="run('Settings')">
Settings <span ndCommandShortcut>⌘S</span>
</div>
</div>
</div>
</div>
</div>
</ng-template>
</ng-container>
<p class="text-sm text-muted-foreground" aria-live="polite">{{ ran() }}</p>
`,
})
export class CommandDialogExample {
protected readonly open = signal(false);
protected readonly ran = signal('');
/**
* Opens or closes the palette for Ctrl+K or ⌘K.
*
* @param event - A keydown anywhere in the document.
*/
protected shortcut(event: KeyboardEvent): void {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
this.open.update((open) => !open);
}
}
/**
* Runs a command and closes the palette.
*
* @param name - The command's name.
*/
protected run(name: string): void {
this.ran.set(`Ran ${name}.`);
this.open.set(false);
}
}
Accessibility
Implements the Combobox pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| ArrowDownorArrowUp | Highlights the next or previous shown, enabled item. |
| HomeorEnd | Moves the caret in the field, as in any text field. |
| Enter | Runs the highlighted item. |
| a | Types into the field, filtering the list. |
Focus
- Focus stays in the field;
aria-activedescendantpoints at the highlighted item, which scrolls into view. - Clicking an item runs it.
Right-to-left
Nothing mirrors: the list is vertical, and the field follows the text direction.
API reference
Command
[ndCommand]A command menu (@ndop/primitives/command): a search field over a list
of commands, filtered as the user types. Put it in a Dialog for a ⌘K
palette.
| Command binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[label]Names the field and the list for assistive technology. | string | null | null |
[filter]Tests whether an item matches what was typed. | NdFilter | ndContainsFilter |
[shouldFilter]Hides the items that don't match; false for lists filtered elsewhere. | boolean | true |
[loop]Makes the arrow keys wrap at the ends. | boolean | false |
[(search)]The field's text. Two-way bindable with [(search)]. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command |
CommandInputWrapper
[ndCommandInputWrapper]The row around the search field and its icon.
| CommandInputWrapper binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command-input-wrapper |
CommandInput
input[ndCommandInput]role comboboxThe search field; focus stays in it while the arrow keys move through the commands.
Manages aria-expanded, aria-controls, aria-activedescendant, aria-autocomplete, aria-label.
| CommandInput binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command-input |
CommandList
[ndCommandList]role listboxThe scrolling list of commands.
Manages aria-label, id.
| CommandList binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command-list |
CommandEmpty
[ndCommandEmpty]What shows while no command matches.
Manages hidden.
| CommandEmpty binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command-empty |
CommandGroup
[ndCommandGroup]role groupA group of commands, named by its CommandGroupHeading.
Manages aria-labelledby, hidden.
| CommandGroup binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command-group |
CommandGroupHeading
[ndCommandGroupHeading]The heading of a CommandGroup.
Manages id, aria-hidden.
| CommandGroupHeading binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command-group-heading |
CommandSeparator
[ndCommandSeparator]A line between groups, hidden while searching.
Manages aria-hidden, hidden.
| CommandSeparator binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command-separator |
CommandItem
[ndCommandItem]role optionOne command; triggered emits when it runs.
Manages aria-selected, aria-disabled, id, hidden.
| CommandItem binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[value]Identifies it; defaults to its text. | string | null | null |
[keywords]Other words the search matches it by, separated by spaces. | string | '' |
[disabled]Makes it inert; the arrow keys skip it. | boolean | false |
| Data attribute | Values |
|---|---|
data-slot | command-item |
data-selected | — |
data-disabled | — |
CommandShortcut
[ndCommandShortcut]A command's keyboard shortcut, shown at its end. It's text for sighted users; the command doesn't bind it.
| CommandShortcut binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | command-shortcut |
Agent notes
Do
- Name it with
label. - Add
keywordsfor the words people may search a command by. - Group commands and show an empty state.
Don't
- Don't use it for picking a form value; use a Combobox.
- Don't show shortcuts the app doesn't bind.