Files
iptvnator/apps/web/src/app/workspace/workspace-command-palette.component.ts
T
4gray 7f4c8ae9ad feat: add workspace command palette component with tests
- Implemented WorkspaceCommandPaletteComponent for executing commands.
- Added unit tests for command palette functionality.
- Updated WorkspaceShellComponent to integrate command palette and keyboard shortcuts.
- Refactored workspace context panel and shell components for improved UI and functionality.
- Enhanced tooltips and accessibility for various UI elements.
- Removed unused category management functionality from context panel.
2026-03-05 23:34:54 +01:00

223 lines
5.9 KiB
TypeScript

import {
AfterViewInit,
Component,
ElementRef,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
export type WorkspaceCommandScope = 'global' | 'playlist' | 'section';
export type WorkspaceCommandId =
| 'global-search'
| 'playlist-search'
| 'open-global-favorites'
| 'open-downloads'
| 'open-global-recent';
export interface WorkspaceCommandItem {
id: WorkspaceCommandId;
label: string;
description: string;
scope: WorkspaceCommandScope;
enabled: boolean;
}
export interface WorkspaceCommandSelection {
commandId: WorkspaceCommandId;
query: string;
}
interface WorkspaceCommandPaletteData {
commands: WorkspaceCommandItem[];
query?: string;
}
interface WorkspaceCommandGroup {
scope: WorkspaceCommandScope;
title: string;
items: WorkspaceCommandItem[];
}
@Component({
selector: 'app-workspace-command-palette',
imports: [MatDialogModule, MatIconModule],
templateUrl: './workspace-command-palette.component.html',
styleUrl: './workspace-command-palette.component.scss',
})
export class WorkspaceCommandPaletteComponent implements AfterViewInit {
private readonly dialogRef = inject(
MatDialogRef<
WorkspaceCommandPaletteComponent,
WorkspaceCommandSelection | undefined
>
);
private readonly data =
inject<WorkspaceCommandPaletteData>(MAT_DIALOG_DATA);
private readonly queryInputRef =
viewChild<ElementRef<HTMLInputElement>>('queryInput');
readonly query = signal(this.data?.query ?? '');
readonly selectedIndex = signal(0);
readonly filteredCommands = computed(() => {
const term = this.query().trim().toLowerCase();
if (!term) {
return this.data.commands;
}
return this.data.commands.filter((command) => {
const haystack =
`${command.label} ${command.description}`.toLowerCase();
return haystack.includes(term);
});
});
readonly commandGroups = computed<WorkspaceCommandGroup[]>(() => {
const commands = this.filteredCommands();
const grouped: WorkspaceCommandGroup[] = [];
const buildGroup = (
scope: WorkspaceCommandScope,
title: string
): WorkspaceCommandGroup | null => {
const items = commands.filter((command) => command.scope === scope);
if (items.length === 0) {
return null;
}
return { scope, title, items };
};
const groups = [
buildGroup('global', 'Global actions'),
buildGroup('playlist', 'This playlist'),
buildGroup('section', 'This section'),
].filter((group): group is WorkspaceCommandGroup => group !== null);
grouped.push(...groups);
return grouped;
});
readonly flatCommands = computed(() =>
this.commandGroups().reduce<WorkspaceCommandItem[]>(
(items, group) => items.concat(group.items),
[]
)
);
constructor() {
effect(() => {
const items = this.flatCommands();
if (items.length === 0) {
this.selectedIndex.set(-1);
return;
}
const currentIndex = this.selectedIndex();
if (currentIndex < 0 || currentIndex >= items.length) {
this.selectedIndex.set(0);
}
});
}
ngAfterViewInit(): void {
queueMicrotask(() => {
this.queryInputRef()?.nativeElement.focus();
this.queryInputRef()?.nativeElement.select();
});
}
onQueryInput(event: Event): void {
const target = event.target as HTMLInputElement | null;
this.query.set(target?.value ?? '');
}
onInputKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.moveSelection(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.moveSelection(-1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
this.selectCurrent();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.dialogRef.close();
}
}
onCommandHover(command: WorkspaceCommandItem): void {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
if (index >= 0) {
this.selectedIndex.set(index);
}
}
onCommandClick(command: WorkspaceCommandItem): void {
if (!command.enabled) {
return;
}
this.dialogRef.close({
commandId: command.id,
query: this.query().trim(),
});
}
isCommandSelected(command: WorkspaceCommandItem): boolean {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
return index >= 0 && this.selectedIndex() === index;
}
private moveSelection(direction: 1 | -1): void {
const items = this.flatCommands();
if (items.length === 0) {
return;
}
const currentIndex = this.selectedIndex();
const safeIndex = currentIndex < 0 ? 0 : currentIndex;
const nextIndex = (safeIndex + direction + items.length) % items.length;
this.selectedIndex.set(nextIndex);
}
private selectCurrent(): void {
const items = this.flatCommands();
if (items.length === 0) {
return;
}
const index = this.selectedIndex();
if (index < 0 || index >= items.length) {
return;
}
this.onCommandClick(items[index]);
}
}