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');
},
},
];
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>
The CommandPalette component also emits isOpenChange and commandSelected events. Accordingly:
- The
isOpenChangeevent is fired when the user changes the state of theCommandPalettecomponent from shown to hidden, and back. TheisOpenChangeevent is used to notify the parent when the palette should close (e.g., after selecting a command or clicking outside). - And the
commandSelectedevent works when the user selects a command. It emits an entire element of the command from thecommandsarray, and passes the required.payloadto 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);
}
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;
-
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;
}
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;
}
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>
}
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' });
});
}
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)