DEV Community

Cover image for Creating a Command Palette Component in Angular (Part 2)
Heghine
Heghine

Posted on

Creating a Command Palette Component in Angular (Part 2)

Command Palette

Sources:

GitHub: https://github.com/ZeroaNinea/Command-Palette-Angular
GitHub Pages: https://zeroaninea.github.io/Command-Palette-Angular/

In this part, I implement the actual CommandPalette component and connect it to the dynamic theming system from Part 1. The result is a reusable, keyboard-driven UI component that can execute custom commands.


Command Model and Examples

Each command is an object that combines metadata (label, keywords) with behavior (handler). This allows the palette to remain generic while executing arbitrary logic.

There is a simple list of commands in the app.ts file: two alerts and commands that change the colors of palettes.

I'm planning to create more UI elements in the future, and I will add them as commands in the CommandPalette.

  commands: Command[] = [
    // id: string;
    // label: string;
    // keywords?: string[];
    // shortcut?: string;
    // payload?: unknown;
    // handler?: (payload?: unknown) => void;
    {
      id: 'alert-1',
      label: 'Alert 1',
      keywords: ['alert', '1'],
      shortcut: 'a1',
      handler: () => alert('Alert 1'),
    },
    {
      id: 'alert-2',
      label: 'Alert 2',
      keywords: ['alert', '2'],
      shortcut: 'a2',
      handler: () => alert('Alert 2'),
    },
    {
      id: 'primary-red',
      label: 'Set Primary to Red',
      handler: () => this.primary.set('#ff4d4f'),
    },
    {
      id: 'primary-blue',
      label: 'Set Primary to Blue',
      handler: () => this.primary.set('#4FC3F7'),
    },
    {
      id: 'reset',
      label: 'Reset Colors',
      handler: () => {
        this.primary.set('#4FC3F7');
        this.secondary.set('#2196F3');
        this.tertiary.set('#086CBC');
      },
    },
  ];
Enter fullscreen mode Exit fullscreen mode

Importing the CommandPalette Component

The commands' list is passed to the CommandPalette component as the commands array.

  <app-command-palette
    [style.--cp-bg]="neutralPalette().bg"
    [style.--cp-surface]="neutralPalette().surface"
    [style.--cp-border]="neutralVariantPalette().border"
    [style.--cp-text]="neutralVariantPalette()['100']"
    [style.--cp-accent]="primaryPalette().accent"
    [style.--cp-hover]="neutralPalette().hover"
    [isOpen]="isOpen()"
    [commands]="commands"
    (isOpenChange)="isOpen.set($event)"
    (commandSelected)="onCommand($event)"
  ></app-command-palette>
Enter fullscreen mode Exit fullscreen mode

The CommandPalette component also emits isOpenChange and commandSelected events. Accordingly:

  • The isOpenChange event is fired when the user changes the state of the CommandPalette component from shown to hidden, and back. The isOpenChange event is used to notify the parent when the palette should close (e.g., after selecting a command or clicking outside).
  • And the commandSelected event works when the user selects a command. It emits an entire element of the command from the commands array, and passes the required .payload to the command handler if it exists.
  onCommand(cmd: Command) {
    this.commandSelected.emit(cmd);
    cmd.handler?.(cmd.payload);
    this.isOpenChange.emit(false);
  }

  close() {
    this.isOpenChange.emit(false);
  }
Enter fullscreen mode Exit fullscreen mode

Command Filtering and Keyboard Navigation

Not all commands are visible at the same time. If the user inputs something into the command palette, it filters visible commands. There are two properties that used for command filtering inside the CommandPalette component that participate in the filtering logic: query and filtered.

  @Input() commands: Command[] = [];
  @Input() isOpen: boolean = false;

  @Output() commandSelected = new EventEmitter<Command>();
  @Output() isOpenChange = new EventEmitter<boolean>();

  @ViewChild('searchInput') input!: ElementRef<HTMLInputElement>;

  query = '';
  filtered: Command[] = [];
  activeIndex = 0;
Enter fullscreen mode Exit fullscreen mode
  • query: contains the value that user inputs into the component input.
  • filtered: it's the array of filtered commands.

The visibleCommands getter returns the filtered array if the user did not input queries. Otherwise, it will return the commands array received from the parent component.

  get visibleCommands(): Command[] {
    if (!this.query) return this.commands;
    return this.filtered;
  }
Enter fullscreen mode Exit fullscreen mode

The filter filters commands by their label and keyboards.

  filter() {
    const q = this.query.toLowerCase();

    this.filtered = this.commands.filter(
      (cmd) =>
        cmd.label.toLowerCase().includes(q) ||
        cmd.keywords?.some((k) => k.toLowerCase().includes(q)),
    );

    this.activeIndex = 0;
  }
Enter fullscreen mode Exit fullscreen mode

The filter method is bound to the input event, and it triggers only when the user inputs something.

@if (isOpen) {
  <div (click)="close()" class="overlay">
    <div (click)="$event.stopPropagation()" class="palette">
      <input
        #searchInput
        class="search"
        placeholder="Type a command..."
        [(ngModel)]="query"
        (input)="filter()"
        (keydown)="onKeydown($event)"
      />
      <div class="list">
        @for (command of visibleCommands; track $index; let i = $index) {
          <div class="item" [class.active]="i === activeIndex" (click)="onCommand(command)">
            {{ command.label }}
          </div>
        }
      </div>
    </div>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

There is also an onKeydown method that is bound to the keydown event. It works when the user focuses on the input and uses any key. But its logic only uses the ArrowDown, ArrowUp, Enter and Escape keys.

  onKeydown(e: KeyboardEvent) {
    const list = this.visibleCommands;

    if (!list.length) return;

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      this.activeIndex = (this.activeIndex + 1) % list.length;
    }

    if (e.key === 'ArrowUp') {
      e.preventDefault();
      this.activeIndex = (this.activeIndex - 1 + list.length) % list.length;
    }

    if (e.key === 'Enter') {
      e.preventDefault();
      this.onCommand(list[this.activeIndex]);
    }

    if (e.key === 'Escape') {
      this.close();
    }

    setTimeout(() => {
      const el = document.querySelector('.item.active');
      el?.scrollIntoView({ block: 'nearest' });
    });
  }
Enter fullscreen mode Exit fullscreen mode

The class .active is assigned to the element that has the same index in the visibleCommands array as the value of the activeIndex method.

When the user presses the ArrowDown or ArrowUp keys, the code decrements and increments the activeIndex, but it cannot be less than 0 and more than the length of the visibleCommands array.

The Enter key executes the function and closes the CommandPalette component. And the Escape key just closes it.

Then after iterating the conditional operations the application scrolls to the new active element.

ngOnChanges

The ngOnChanges lifecycle hook is used to react to changes in input properties. In this case, it listens for changes to the isOpen input.

When the command palette is opened, its internal state is reset:

  • the search query is cleared,
  • the filtered commands list is reset,
  • the active index is set to the first item.

This ensures that every time the palette opens, it starts in a clean and predictable state instead of preserving the previous search.

ngAfterViewChecked

The ngAfterViewChecked lifecycle hook runs after Angular updates the component’s view.

It is used here to automatically focus the input field when the command palette is opened.

This allows the user to start typing immediately without needing to click on the input manually.


Afterword

In this part, I implemented the core logic of the command palette: command definition, filtering, and keyboard navigation. The component is fully functional and can execute custom commands while remaining reusable and independent from specific business logic.

One of the key ideas behind this implementation is treating commands as objects that combine metadata (label, keywords, shortcut) with behavior (handler). This allows the component to stay generic while supporting a wide range of use cases.

The command palette is also integrated with the dynamic theming system created in the previous part, which makes it easy to adapt its appearance without changing the component itself.

There are still many possible improvements:

  • grouping commands by category,
  • adding icons,
  • highlighting matched search text,
  • improving accessibility and keyboard support.

I plan to continue expanding this project and use the command palette as a central interaction system for future UI components.

Top comments (0)