Command

Fast, composable, styled command menu for Angular.

PreviousNext
Use arrow keys to navigate, Enter to select, Escape to clear selection.
Create new project⌘N
Open file⌘O
Save all⌘S
Go to Dashboard⌘1
Go to Projects⌘2
Open terminal⌘T
Toggle theme⌘D
import { Component, HostListener } from '@angular/core';
 
import type { ZardCommandOption } from '../command.component';
import { ZardCommandModule } from '../command.module';
 
@Component({
  selector: 'z-demo-command-default',
  standalone: true,
  imports: [ZardCommandModule],
  template: `
    <z-command class="md:min-w-[500px]" (zOnSelect)="handleCommand($event)">
      <z-command-input placeholder="Search actions, files, and more..."></z-command-input>
      <z-command-list>
        <z-command-empty>No commands found.</z-command-empty>
 
        <z-command-option-group zLabel="Quick Actions">
          <z-command-option zLabel="Create new project" zValue="new-project" zIcon="folder" zShortcut="⌘N"> </z-command-option>
          <z-command-option zLabel="Open file" zValue="open-file" zIcon="folder-open" zShortcut="⌘O"> </z-command-option>
          <z-command-option zLabel="Save all" zValue="save-all" zIcon="save" zShortcut="⌘S"> </z-command-option>
        </z-command-option-group>
 
        <z-command-divider></z-command-divider>
 
        <z-command-option-group zLabel="Navigation">
          <z-command-option zLabel="Go to Dashboard" zValue="dashboard" zIcon="layout-dashboard" zShortcut="⌘1"> </z-command-option>
          <z-command-option zLabel="Go to Projects" zValue="projects" zIcon="folder" zShortcut="⌘2"> </z-command-option>
        </z-command-option-group>
 
        <z-command-divider></z-command-divider>
 
        <z-command-option-group zLabel="Tools">
          <z-command-option zLabel="Open terminal" zValue="terminal" zIcon="terminal" zShortcut="⌘T"> </z-command-option>
          <z-command-option zLabel="Toggle theme" zValue="theme" zIcon="moon" zShortcut="⌘D"> </z-command-option>
        </z-command-option-group>
      </z-command-list>
    </z-command>
  `,
})
export class ZardDemoCommandDefaultComponent {
  // Handle command selection
  handleCommand(option: ZardCommandOption) {
    const action = `Executed "${option.label}" (value: ${option.value})`;
    console.log(action);
 
    // You can add real logic here
    switch (option.value) {
      case 'new-project':
        this.showAlert('Creating new project...');
        break;
      case 'open-file':
        this.showAlert('Opening file dialog...');
        break;
      case 'save-all':
        this.showAlert('Saving all files...');
        break;
      case 'dashboard':
        this.showAlert('Navigating to Dashboard...');
        break;
      case 'projects':
        this.showAlert('Navigating to Projects...');
        break;
      case 'terminal':
        this.showAlert('Opening terminal...');
        break;
      case 'theme':
        this.showAlert('Toggling theme...');
        break;
      default:
        this.showAlert(`Action: ${option.label}`);
    }
  }
 
  // Handle keyboard shortcuts
  @HostListener('window:keydown', ['$event'])
  handleKeydown(event: KeyboardEvent) {
    if (event.metaKey || event.ctrlKey) {
      switch (event.key.toLowerCase()) {
        case 'n':
          event.preventDefault();
          this.executeCommand('new-project', 'Create new project');
          break;
        case 'o':
          event.preventDefault();
          this.executeCommand('open-file', 'Open file');
          break;
        case 's':
          event.preventDefault();
          this.executeCommand('save-all', 'Save all');
          break;
        case '1':
          event.preventDefault();
          this.executeCommand('dashboard', 'Go to Dashboard');
          break;
        case '2':
          event.preventDefault();
          this.executeCommand('projects', 'Go to Projects');
          break;
        case 't':
          event.preventDefault();
          this.executeCommand('terminal', 'Open terminal');
          break;
        case 'd':
          event.preventDefault();
          this.executeCommand('theme', 'Toggle theme');
          break;
      }
    }
  }
 
  private executeCommand(value: string, label: string) {
    this.handleCommand({ value, label } as ZardCommandOption);
  }
 
  private showAlert(message: string, isWarning = false) {
    if (isWarning) {
      console.warn(message);
    } else {
      console.log(message);
    }
 
    // In a real app, you might show a toast notification here
    setTimeout(() => {
      // You could clear the action after some time
    }, 3000);
  }
}
 

Installation

1

Run the CLI

Use the CLI to add the component to your project.

npx @ngzard/ui add command
1

Add the component files

Create the component directory structure and add the following files to your project.

command.component.ts
command.component.ts
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  contentChild,
  contentChildren,
  effect,
  EventEmitter,
  forwardRef,
  input,
  Output,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { type ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import type { ClassValue } from 'clsx';
 
import { commandVariants, type ZardCommandVariants } from './command.variants';
import { ZardCommandOptionComponent } from './command-option.component';
import { ZardCommandInputComponent } from './command-input.component';
import { mergeClasses } from '../../shared/utils/utils';
import type { ZardIcon } from '../icon/icons';
 
export interface ZardCommandOption {
  value: unknown;
  label: string;
  disabled?: boolean;
  command?: string;
  shortcut?: string;
  icon?: ZardIcon;
  action?: () => void;
  key?: string; // Keyboard shortcut key (e.g., 'n' for Ctrl+N)
}
 
export interface ZardCommandGroup {
  label: string;
  options: ZardCommandOption[];
}
 
export interface ZardCommandConfig {
  placeholder?: string;
  emptyText?: string;
  groups: ZardCommandGroup[];
  dividers?: boolean;
  onSelect?: (option: ZardCommandOption) => void;
}
 
@Component({
  selector: 'z-command',
  exportAs: 'zCommand',
  standalone: true,
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  template: `
    <div [class]="classes()">
      <div id="command-instructions" class="sr-only">Use arrow keys to navigate, Enter to select, Escape to clear selection.</div>
      <div id="command-status" class="sr-only" aria-live="polite" aria-atomic="true">
        {{ statusMessage() }}
      </div>
      <ng-content></ng-content>
    </div>
  `,
  host: {
    '[attr.role]': '"combobox"',
    '[attr.aria-expanded]': 'true',
    '[attr.aria-haspopup]': '"listbox"',
    '(keydown)': 'onKeyDown($event)',
  },
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => ZardCommandComponent),
      multi: true,
    },
  ],
})
export class ZardCommandComponent implements ControlValueAccessor {
  readonly commandInput = contentChild(ZardCommandInputComponent);
  readonly optionComponents = contentChildren(ZardCommandOptionComponent, { descendants: true });
 
  readonly size = input<ZardCommandVariants['size']>('default');
  readonly class = input<ClassValue>('');
 
  @Output() readonly zOnChange = new EventEmitter<ZardCommandOption>();
  @Output() readonly zOnSelect = new EventEmitter<ZardCommandOption>();
 
  // Internal signals for search functionality
  readonly searchTerm = signal('');
  readonly selectedIndex = signal(-1);
 
  // Signal to trigger updates when optionComponents change
  private readonly optionsUpdateTrigger = signal(0);
 
  protected readonly classes = computed(() => mergeClasses(commandVariants({ size: this.size() }), this.class()));
 
  // Computed signal for filtered options - this will automatically update when searchTerm or options change
  readonly filteredOptions = computed(() => {
    const searchTerm = this.searchTerm();
    // Include the trigger signal to make this computed reactive to option changes
    this.optionsUpdateTrigger();
 
    if (!this.optionComponents()) return [];
 
    const lowerSearchTerm = searchTerm.toLowerCase().trim();
    if (lowerSearchTerm === '') return this.optionComponents();
 
    return this.optionComponents().filter(option => {
      const label = option.zLabel().toLowerCase();
      const command = option.zCommand()?.toLowerCase() ?? '';
      return label.includes(lowerSearchTerm) || command.includes(lowerSearchTerm);
    });
  });
 
  // Status message for screen readers
  protected readonly statusMessage = computed(() => {
    const searchTerm = this.searchTerm().trim();
    const filteredCount = this.filteredOptions().length;
 
    if (!searchTerm) return '';
 
    if (!filteredCount) {
      return `No results found for "${searchTerm}"`;
    }
 
    return `${filteredCount} result${filteredCount === 1 ? '' : 's'} found for "${searchTerm}"`;
  });
 
  private onChange = (_value: unknown) => {
    // ControlValueAccessor implementation
  };
  private onTouched = () => {
    // ControlValueAccessor implementation
  };
 
  constructor() {
    effect(() => {
      this.triggerOptionsUpdate();
    });
  }
 
  /**
   * Trigger an update to the filteredOptions computed signal
   */
  private triggerOptionsUpdate(): void {
    this.optionsUpdateTrigger.update(value => value + 1);
  }
 
  onSearch(searchTerm: string) {
    this.searchTerm.set(searchTerm);
    this.selectedIndex.set(-1);
    this.updateSelectedOption();
  }
 
  selectOption(option: ZardCommandOptionComponent) {
    const commandOption: ZardCommandOption = {
      value: option.zValue(),
      label: option.zLabel(),
      disabled: option.zDisabled(),
      command: option.zCommand(),
      shortcut: option.zShortcut(),
      icon: option.zIcon(),
    };
 
    this.onChange(commandOption.value);
    this.zOnChange.emit(commandOption);
    this.zOnSelect.emit(commandOption);
  }
 
  // in @Component host: '(keydown)': 'onKeyDown($event)'
  onKeyDown(event: KeyboardEvent) {
    const filteredOptions = this.filteredOptions();
    if (filteredOptions.length === 0) return;
 
    const currentIndex = this.selectedIndex();
 
    switch (event.key) {
      case 'ArrowDown': {
        event.preventDefault();
        const nextIndex = currentIndex < filteredOptions.length - 1 ? currentIndex + 1 : 0;
        this.selectedIndex.set(nextIndex);
        this.updateSelectedOption();
        break;
      }
 
      case 'ArrowUp': {
        event.preventDefault();
        const prevIndex = currentIndex > 0 ? currentIndex - 1 : filteredOptions.length - 1;
        this.selectedIndex.set(prevIndex);
        this.updateSelectedOption();
        break;
      }
 
      case 'Enter':
        event.preventDefault();
        if (currentIndex >= 0 && currentIndex < filteredOptions.length) {
          const selectedOption = filteredOptions[currentIndex];
          if (!selectedOption.zDisabled()) {
            this.selectOption(selectedOption);
          }
        }
        break;
 
      case 'Escape':
        event.preventDefault();
        this.selectedIndex.set(-1);
        this.updateSelectedOption();
        break;
    }
  }
 
  private updateSelectedOption() {
    const filteredOptions = this.filteredOptions();
    const selectedIndex = this.selectedIndex();
 
    // Clear previous selection
    for (const option of filteredOptions) {
      option.setSelected(false);
    }
 
    // Set new selection
    if (selectedIndex >= 0 && selectedIndex < filteredOptions.length) {
      const selectedOption = filteredOptions[selectedIndex];
      selectedOption.setSelected(true);
      selectedOption.focus();
    }
  }
 
  // ControlValueAccessor implementation
  writeValue(_value: unknown): void {
    // Implementation if needed for form control integration
  }
 
  registerOnChange(fn: (value: unknown) => void): void {
    this.onChange = fn;
  }
 
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
 
  setDisabledState(_isDisabled: boolean): void {
    // Implementation if needed for form control disabled state
  }
 
  /**
   * Refresh the options list - useful when options are added/removed dynamically
   */
  refreshOptions(): void {
    this.triggerOptionsUpdate();
  }
 
  /**
   * Focus the command input
   */
  focus(): void {
    this.commandInput()?.focus();
  }
}
 
command.variants.ts
command.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
 
export const commandVariants = cva('flex h-full w-full flex-col overflow-hidden shadow-md border rounded-md bg-popover text-popover-foreground', {
  variants: {
    size: {
      sm: 'min-h-64',
      default: 'min-h-80',
      lg: 'min-h-96',
      xl: 'min-h-[30rem]',
    },
  },
  defaultVariants: {
    size: 'default',
  },
});
 
export const commandInputVariants = cva(
  'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
  {
    variants: {},
    defaultVariants: {},
  },
);
 
export const commandListVariants = cva('max-h-[300px] overflow-y-auto overflow-x-hidden p-1', {
  variants: {},
  defaultVariants: {},
});
 
export const commandEmptyVariants = cva('py-6 text-center text-sm text-muted-foreground', {
  variants: {},
  defaultVariants: {},
});
 
export const commandGroupVariants = cva('overflow-hidden text-foreground', {
  variants: {},
  defaultVariants: {},
});
 
export const commandGroupHeadingVariants = cva('px-2 py-1.5 text-xs font-medium text-muted-foreground', {
  variants: {},
  defaultVariants: {},
});
 
export const commandItemVariants = cva(
  'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
  {
    variants: {
      variant: {
        default: '',
        destructive: 'aria-selected:bg-destructive aria-selected:text-destructive-foreground hover:bg-destructive hover:text-destructive-foreground',
      },
    },
    defaultVariants: {
      variant: 'default',
    },
  },
);
 
export const commandSeparatorVariants = cva('-mx-1 my-1 h-px bg-border', {
  variants: {},
  defaultVariants: {},
});
 
export const commandShortcutVariants = cva('ml-auto text-xs tracking-widest text-muted-foreground', {
  variants: {},
  defaultVariants: {},
});
 
export type ZardCommandVariants = VariantProps<typeof commandVariants>;
export type ZardCommandItemVariants = VariantProps<typeof commandItemVariants>;
 
command-divider.component.ts
command-divider.component.ts
import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from '@angular/core';
 
import { mergeClasses } from '../../shared/utils/utils';
import { ZardCommandComponent } from './command.component';
import { commandSeparatorVariants } from './command.variants';
 
import type { ClassValue } from 'clsx';
@Component({
  selector: 'z-command-divider',
  exportAs: 'zCommandDivider',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  template: `
    @if (shouldShow()) {
      <div [class]="classes()" role="separator"></div>
    }
  `,
})
export class ZardCommandDividerComponent {
  private readonly commandComponent = inject(ZardCommandComponent, { optional: true });
 
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(commandSeparatorVariants({}), this.class()));
 
  protected readonly shouldShow = computed(() => {
    if (!this.commandComponent) return true;
 
    const searchTerm = this.commandComponent.searchTerm();
 
    // If no search, always show dividers
    if (searchTerm === '') return true;
 
    // If there's a search term, hide all dividers for now
    // This is a simple approach - we can make it smarter later
    return false;
  });
}
 
command-empty.component.ts
command-empty.component.ts
import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from '@angular/core';
 
import { mergeClasses } from '../../shared/utils/utils';
import { ZardCommandComponent } from './command.component';
import { commandEmptyVariants } from './command.variants';
 
import type { ClassValue } from 'clsx';
 
@Component({
  selector: 'z-command-empty',
  exportAs: 'zCommandEmpty',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  template: `
    @if (shouldShow()) {
      <div [class]="classes()">
        <ng-content>No results found.</ng-content>
      </div>
    }
  `,
})
export class ZardCommandEmptyComponent {
  private readonly commandComponent = inject(ZardCommandComponent, { optional: true });
 
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(commandEmptyVariants({}), this.class()));
 
  protected readonly shouldShow = computed(() => {
    // Check traditional command component
    if (this.commandComponent) {
      const filteredOptions = this.commandComponent.filteredOptions();
      return filteredOptions.length === 0;
    }
 
    return false;
  });
}
 
command-input.component.ts
command-input.component.ts
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  type ElementRef,
  EventEmitter,
  forwardRef,
  inject,
  input,
  type OnDestroy,
  type OnInit,
  Output,
  signal,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import { type ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subject, switchMap, timer } from 'rxjs';
import type { ClassValue } from 'clsx';
 
import { ZardIconComponent } from '../icon/icon.component';
import { ZardCommandComponent } from './command.component';
import { commandInputVariants } from './command.variants';
import { mergeClasses } from '../../shared/utils/utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 
@Component({
  selector: 'z-command-input',
  exportAs: 'zCommandInput',
  standalone: true,
  imports: [FormsModule, ZardIconComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  template: `
    <div class="flex items-center border-b px-3" cmdk-input-wrapper="">
      <z-icon zType="search" class="mr-2 shrink-0 opacity-50" />
      <input
        #searchInput
        [class]="classes()"
        [placeholder]="placeholder()"
        [(ngModel)]="searchTerm"
        (input)="onInput($event)"
        (keydown)="onKeyDown($event)"
        autocomplete="off"
        autocorrect="off"
        spellcheck="false"
        role="combobox"
        [attr.aria-expanded]="true"
        [attr.aria-haspopup]="'listbox'"
        [attr.aria-controls]="'command-list'"
        [attr.aria-label]="'Search commands'"
        [attr.aria-describedby]="'command-instructions'"
      />
    </div>
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => ZardCommandInputComponent),
      multi: true,
    },
  ],
})
export class ZardCommandInputComponent implements ControlValueAccessor, OnInit, OnDestroy {
  private readonly commandComponent = inject(ZardCommandComponent, { optional: true });
  private readonly destroyRef = inject(DestroyRef);
  readonly searchInput = viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
 
  readonly placeholder = input<string>('Type a command or search...');
  readonly class = input<ClassValue>('');
 
  @Output() readonly valueChange = new EventEmitter<string>();
 
  readonly searchTerm = signal('');
  private readonly searchSubject = new Subject<string>();
 
  protected readonly classes = computed(() => mergeClasses(commandInputVariants({}), this.class()));
 
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  private onChange = (_: string) => {
    // ControlValueAccessor implementation - intentionally empty
  };
  private onTouched = () => {
    // ControlValueAccessor implementation - intentionally empty
  };
 
  ngOnInit(): void {
    // Set up debounced search stream - always send to subject
    this.searchSubject
      .pipe(
        // If empty, emit immediately, otherwise debounce
        switchMap(value => (value ? timer(150) : timer(0))),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe(() => {
        // Get the current value from the signal to ensure we have the latest
        const currentValue = this.searchTerm();
        this.updateParentComponents(currentValue);
      });
  }
 
  onInput(event: Event) {
    const target = event.target as HTMLInputElement;
    const value = target.value;
    this.searchTerm.set(value);
 
    // Always send to subject - let the stream handle timing
    this.searchSubject.next(value);
  }
 
  private updateParentComponents(value: string): void {
    // Send search to appropriate parent component
    if (this.commandComponent) {
      this.commandComponent.onSearch(value);
    }
    this.onChange(value);
    this.valueChange.emit(value);
  }
 
  onKeyDown(event: KeyboardEvent) {
    // Let parent command component handle navigation keys
    if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) {
      // For Escape key, don't stop propagation to allow document listener to work
      if (event.key !== 'Escape') {
        event.preventDefault(); // Prevent default input behavior
        event.stopPropagation(); // Stop the event from bubbling up
      }
 
      // Send to parent command component
      if (this.commandComponent) {
        this.commandComponent.onKeyDown(event);
      }
    }
    // Handle other keys as needed
  }
 
  writeValue(value: string | null): void {
    this.searchTerm.set(value ?? '');
  }
 
  registerOnChange(fn: (value: string) => void): void {
    this.onChange = fn;
  }
 
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
 
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  setDisabledState(_: boolean): void {
    // Implementation if needed for form control disabled state
  }
 
  /**
   * Focus the input element
   */
  focus(): void {
    this.searchInput().nativeElement.focus();
  }
 
  ngOnDestroy(): void {
    // Complete subjects to clean up subscriptions
    this.searchSubject.complete();
  }
}
 
command-list.component.ts
command-list.component.ts
import type { ClassValue } from 'clsx';
 
import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core';
 
import { mergeClasses } from '../../shared/utils/utils';
import { commandListVariants } from './command.variants';
 
@Component({
  selector: 'z-command-list',
  exportAs: 'zCommandList',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  template: `
    <div [class]="classes()" role="listbox" id="command-list">
      <ng-content></ng-content>
    </div>
  `,
})
export class ZardCommandListComponent {
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(commandListVariants({}), this.class()));
}
 
command-option-group.component.ts
command-option-group.component.ts
import { type AfterContentInit, ChangeDetectionStrategy, Component, computed, contentChildren, inject, input, ViewEncapsulation } from '@angular/core';
 
import { mergeClasses } from '../../shared/utils/utils';
import { ZardCommandOptionComponent } from './command-option.component';
import { ZardCommandComponent } from './command.component';
import { commandGroupHeadingVariants, commandGroupVariants } from './command.variants';
 
import type { ClassValue } from 'clsx';
 
@Component({
  selector: 'z-command-option-group',
  exportAs: 'zCommandOptionGroup',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  template: `
    @if (shouldShow()) {
      <div [class]="classes()" role="group">
        @if (zLabel()) {
          <div [class]="headingClasses()" role="presentation">
            {{ zLabel() }}
          </div>
        }
        <div role="group">
          <ng-content></ng-content>
        </div>
      </div>
    }
  `,
})
export class ZardCommandOptionGroupComponent implements AfterContentInit {
  private readonly commandComponent = inject(ZardCommandComponent, { optional: true });
 
  readonly optionComponents = contentChildren(ZardCommandOptionComponent, { descendants: true });
 
  readonly zLabel = input.required<string>();
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(commandGroupVariants({}), this.class()));
 
  protected readonly headingClasses = computed(() => mergeClasses(commandGroupHeadingVariants({})));
 
  protected readonly shouldShow = computed(() => {
    if (!this.commandComponent || !this.optionComponents) return true;
 
    const searchTerm = this.commandComponent.searchTerm();
    const filteredOptions = this.commandComponent.filteredOptions();
 
    // If no search term, show all groups
    if (searchTerm === '') return true;
 
    // Check if any option in this group is in the filtered list
    return this.optionComponents().some(option => filteredOptions.includes(option));
  });
 
  ngAfterContentInit() {
    // Component is ready when content children are initialized
  }
}
 
command-option.component.ts
command-option.component.ts
import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, input, signal, ViewEncapsulation } from '@angular/core';
import type { ClassValue } from 'clsx';
 
import { commandItemVariants, commandShortcutVariants, type ZardCommandItemVariants } from './command.variants';
import { mergeClasses, transform } from '../../shared/utils/utils';
import { ZardIconComponent } from '../icon/icon.component';
import { ZardCommandComponent } from './command.component';
import type { ZardIcon } from '../icon/icons';
 
@Component({
  selector: 'z-command-option',
  exportAs: 'zCommandOption',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [ZardIconComponent],
  template: `
    @if (shouldShow()) {
      <div
        [class]="classes()"
        [attr.role]="'option'"
        [attr.aria-selected]="isSelected()"
        [attr.data-selected]="isSelected()"
        [attr.data-disabled]="zDisabled()"
        [attr.tabindex]="0"
        (click)="onClick()"
        (keydown)="onKeyDown($event)"
        (mouseenter)="onMouseEnter()"
      >
        @if (zIcon()) {
          <div z-icon [zType]="zIcon()!" class="mr-2 shrink-0 flex items-center justify-center"></div>
        }
        <span class="flex-1">{{ zLabel() }}</span>
        @if (zShortcut()) {
          <span [class]="shortcutClasses()">{{ zShortcut() }}</span>
        }
      </div>
    }
  `,
})
export class ZardCommandOptionComponent {
  private readonly elementRef = inject(ElementRef);
  private readonly commandComponent = inject(ZardCommandComponent, { optional: true });
 
  readonly zValue = input.required<unknown>();
  readonly zLabel = input.required<string>();
  readonly zCommand = input<string>('');
  readonly zIcon = input<ZardIcon>();
  readonly zShortcut = input<string>('');
  readonly zDisabled = input(false, { transform });
  readonly variant = input<ZardCommandItemVariants['variant']>('default');
  readonly class = input<ClassValue>('');
 
  readonly isSelected = signal(false);
 
  protected readonly classes = computed(() => {
    const baseClasses = commandItemVariants({ variant: this.variant() });
    const selectedClasses = this.isSelected() ? 'bg-accent text-accent-foreground' : '';
    return mergeClasses(baseClasses, selectedClasses, this.class());
  });
 
  protected readonly shortcutClasses = computed(() => mergeClasses(commandShortcutVariants({})));
 
  protected readonly shouldShow = computed(() => {
    if (!this.commandComponent) return true;
 
    const filteredOptions = this.commandComponent.filteredOptions();
    const searchTerm = this.commandComponent.searchTerm();
 
    // If no search term, show all options
    if (searchTerm === '') return true;
 
    // Check if this option is in the filtered list
    return filteredOptions.includes(this);
  });
 
  onClick() {
    if (this.zDisabled()) return;
    if (this.commandComponent) {
      this.commandComponent.selectOption(this);
    }
  }
 
  onKeyDown(event: KeyboardEvent) {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      this.onClick();
    }
  }
 
  onMouseEnter() {
    if (this.zDisabled()) return;
    // Visual feedback for hover
  }
 
  setSelected(selected: boolean) {
    this.isSelected.set(selected);
  }
 
  focus() {
    const element = this.elementRef.nativeElement;
    element.focus();
    // Scroll element into view if needed
    element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }
}
 
command.module.ts
command.module.ts
import { FormsModule } from '@angular/forms';
import { NgModule } from '@angular/core';
 
import { ZardCommandOptionGroupComponent } from './command-option-group.component';
import { ZardCommandDividerComponent } from './command-divider.component';
import { ZardCommandOptionComponent } from './command-option.component';
import { ZardCommandInputComponent } from './command-input.component';
import { ZardCommandEmptyComponent } from './command-empty.component';
import { ZardCommandListComponent } from './command-list.component';
import { ZardCommandComponent } from './command.component';
 
const COMMAND_COMPONENTS = [
  ZardCommandComponent,
  ZardCommandInputComponent,
  ZardCommandListComponent,
  ZardCommandEmptyComponent,
  ZardCommandOptionComponent,
  ZardCommandOptionGroupComponent,
  ZardCommandDividerComponent,
];
 
@NgModule({
  imports: [FormsModule, ...COMMAND_COMPONENTS],
  exports: [...COMMAND_COMPONENTS],
})
export class ZardCommandModule {}
 

Examples

default

Use arrow keys to navigate, Enter to select, Escape to clear selection.
Create new project⌘N
Open file⌘O
Save all⌘S
Go to Dashboard⌘1
Go to Projects⌘2
Open terminal⌘T
Toggle theme⌘D
import { Component, HostListener } from '@angular/core';
 
import type { ZardCommandOption } from '../command.component';
import { ZardCommandModule } from '../command.module';
 
@Component({
  selector: 'z-demo-command-default',
  standalone: true,
  imports: [ZardCommandModule],
  template: `
    <z-command class="md:min-w-[500px]" (zOnSelect)="handleCommand($event)">
      <z-command-input placeholder="Search actions, files, and more..."></z-command-input>
      <z-command-list>
        <z-command-empty>No commands found.</z-command-empty>
 
        <z-command-option-group zLabel="Quick Actions">
          <z-command-option zLabel="Create new project" zValue="new-project" zIcon="folder" zShortcut="⌘N"> </z-command-option>
          <z-command-option zLabel="Open file" zValue="open-file" zIcon="folder-open" zShortcut="⌘O"> </z-command-option>
          <z-command-option zLabel="Save all" zValue="save-all" zIcon="save" zShortcut="⌘S"> </z-command-option>
        </z-command-option-group>
 
        <z-command-divider></z-command-divider>
 
        <z-command-option-group zLabel="Navigation">
          <z-command-option zLabel="Go to Dashboard" zValue="dashboard" zIcon="layout-dashboard" zShortcut="⌘1"> </z-command-option>
          <z-command-option zLabel="Go to Projects" zValue="projects" zIcon="folder" zShortcut="⌘2"> </z-command-option>
        </z-command-option-group>
 
        <z-command-divider></z-command-divider>
 
        <z-command-option-group zLabel="Tools">
          <z-command-option zLabel="Open terminal" zValue="terminal" zIcon="terminal" zShortcut="⌘T"> </z-command-option>
          <z-command-option zLabel="Toggle theme" zValue="theme" zIcon="moon" zShortcut="⌘D"> </z-command-option>
        </z-command-option-group>
      </z-command-list>
    </z-command>
  `,
})
export class ZardDemoCommandDefaultComponent {
  // Handle command selection
  handleCommand(option: ZardCommandOption) {
    const action = `Executed "${option.label}" (value: ${option.value})`;
    console.log(action);
 
    // You can add real logic here
    switch (option.value) {
      case 'new-project':
        this.showAlert('Creating new project...');
        break;
      case 'open-file':
        this.showAlert('Opening file dialog...');
        break;
      case 'save-all':
        this.showAlert('Saving all files...');
        break;
      case 'dashboard':
        this.showAlert('Navigating to Dashboard...');
        break;
      case 'projects':
        this.showAlert('Navigating to Projects...');
        break;
      case 'terminal':
        this.showAlert('Opening terminal...');
        break;
      case 'theme':
        this.showAlert('Toggling theme...');
        break;
      default:
        this.showAlert(`Action: ${option.label}`);
    }
  }
 
  // Handle keyboard shortcuts
  @HostListener('window:keydown', ['$event'])
  handleKeydown(event: KeyboardEvent) {
    if (event.metaKey || event.ctrlKey) {
      switch (event.key.toLowerCase()) {
        case 'n':
          event.preventDefault();
          this.executeCommand('new-project', 'Create new project');
          break;
        case 'o':
          event.preventDefault();
          this.executeCommand('open-file', 'Open file');
          break;
        case 's':
          event.preventDefault();
          this.executeCommand('save-all', 'Save all');
          break;
        case '1':
          event.preventDefault();
          this.executeCommand('dashboard', 'Go to Dashboard');
          break;
        case '2':
          event.preventDefault();
          this.executeCommand('projects', 'Go to Projects');
          break;
        case 't':
          event.preventDefault();
          this.executeCommand('terminal', 'Open terminal');
          break;
        case 'd':
          event.preventDefault();
          this.executeCommand('theme', 'Toggle theme');
          break;
      }
    }
  }
 
  private executeCommand(value: string, label: string) {
    this.handleCommand({ value, label } as ZardCommandOption);
  }
 
  private showAlert(message: string, isWarning = false) {
    if (isWarning) {
      console.warn(message);
    } else {
      console.log(message);
    }
 
    // In a real app, you might show a toast notification here
    setTimeout(() => {
      // You could clear the action after some time
    }, 3000);
  }
}
 

API

[z-command] Component

The main command palette container that handles search input and keyboard navigation. Features intelligent debounced search, ARIA accessibility, and comprehensive keyboard navigation.

Property Description Type Default
size Size of the command palette sm | default | lg | xl default
class Additional CSS classes string ''

Events

Event Description Type
zOnChange Fired when the selected option changes EventEmitter<ZardCommandOption>
zOnSelect Fired when an option is selected EventEmitter<ZardCommandOption>

[z-command-input] Component

Search input component with debounced input handling and accessibility features.

Property Description Type Default
placeholder Placeholder text for input string Type a command or search...
class Additional CSS classes string ''

Events

Event Description Type
valueChange Fired when input value changes EventEmitter<string>

[z-command-list] Component

Container for command options with proper ARIA listbox semantics.

Property Description Type Default
class Additional CSS classes string ''

[z-command-empty] Component

Displays when no search results are found. Automatically shows/hides based on search state.

Property Description Type Default
class Additional CSS classes string ''

[z-command-option] Component

Individual selectable option within the command palette with enhanced accessibility and interaction features.

Property Description Type Default
zValue Value of the option (required) any -
zLabel Label text (required) string -
zIcon Icon HTML content string ''
zCommand Command identifier string ''
zShortcut Keyboard shortcut display string ''
zDisabled Disabled state boolean false
variant Visual variant default | destructive default
class Additional CSS classes string ''

[z-command-option-group] Component

Groups related command options together with semantic grouping and accessibility.

Property Description Type Default
zLabel Group label (required) string -
class Additional CSS classes string ''

[z-command-divider] Component

Visual separator between command groups with semantic role.

Property Description Type Default
class Additional CSS classes string ''