From 9917bf73cc6b30ac01a18ec02ec93fb372e6b42b Mon Sep 17 00:00:00 2001
From: zeitiv <24879428+zeitiv@users.noreply.github.com>
Date: Thu, 12 Mar 2026 00:27:44 +0100
Subject: [PATCH 1/3] feat: add Cmd+K command search to docs website
---
apps/showcase/src/app/app.ts | 4 +-
.../command-search/command-search.service.ts | 20 ++
.../command-search/command-search.ts | 289 ++++++++++++++++++
.../src/app/components/navbar/navbar.ts | 35 +++
.../app/layouts/docs-layout/docs-layout.ts | 34 ++-
5 files changed, 380 insertions(+), 2 deletions(-)
create mode 100644 apps/showcase/src/app/components/command-search/command-search.service.ts
create mode 100644 apps/showcase/src/app/components/command-search/command-search.ts
diff --git a/apps/showcase/src/app/app.ts b/apps/showcase/src/app/app.ts
index db67a0b33..dee40d0d7 100644
--- a/apps/showcase/src/app/app.ts
+++ b/apps/showcase/src/app/app.ts
@@ -7,12 +7,14 @@ import {
} from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { cn } from '@semantic-components/ui';
+import { CommandSearch } from './components/command-search/command-search';
@Component({
selector: 'app-root',
- imports: [RouterOutlet],
+ imports: [RouterOutlet, CommandSearch],
template: `
+
`,
host: {
'[class]': 'class()',
diff --git a/apps/showcase/src/app/components/command-search/command-search.service.ts b/apps/showcase/src/app/components/command-search/command-search.service.ts
new file mode 100644
index 000000000..9bab81fd1
--- /dev/null
+++ b/apps/showcase/src/app/components/command-search/command-search.service.ts
@@ -0,0 +1,20 @@
+import { Injectable, signal } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class CommandSearchService {
+ readonly isOpen = signal(false);
+
+ open(): void {
+ this.isOpen.set(true);
+ }
+
+ close(): void {
+ this.isOpen.set(false);
+ }
+
+ toggle(): void {
+ this.isOpen.update((v) => !v);
+ }
+}
diff --git a/apps/showcase/src/app/components/command-search/command-search.ts b/apps/showcase/src/app/components/command-search/command-search.ts
new file mode 100644
index 000000000..91083b6be
--- /dev/null
+++ b/apps/showcase/src/app/components/command-search/command-search.ts
@@ -0,0 +1,289 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ViewEncapsulation,
+ computed,
+ inject,
+ signal,
+} from '@angular/core';
+import { Router } from '@angular/router';
+import {
+ ScCommand,
+ ScCommandEmpty,
+ ScCommandGroup,
+ ScCommandGroupLabel,
+ ScCommandInput,
+ ScCommandInputGroup,
+ ScCommandItem,
+ ScCommandList,
+ ScCommandListContainer,
+ ScCommandSeparator,
+ ScDialog,
+ ScDialogPortal,
+ ScDialogProvider,
+ ScHotkey,
+} from '@semantic-components/ui';
+import {
+ SiBoxIcon,
+ SiDownloadIcon,
+ SiFileTextIcon,
+ SiSearchIcon,
+} from '@semantic-icons/lucide-icons';
+import { ComponentsService } from '../../services/components.service';
+import { CommandSearchService } from './command-search.service';
+
+interface SearchItem {
+ label: string;
+ path: string;
+ group: 'getting-started' | 'installation' | 'component';
+ keywords?: string[];
+}
+
+@Component({
+ selector: 'app-command-search',
+ imports: [
+ ScCommand,
+ ScCommandEmpty,
+ ScCommandGroup,
+ ScCommandGroupLabel,
+ ScCommandInput,
+ ScCommandInputGroup,
+ ScCommandItem,
+ ScCommandList,
+ ScCommandListContainer,
+ ScCommandSeparator,
+ ScDialog,
+ ScDialogPortal,
+ ScDialogProvider,
+ ScHotkey,
+ SiBoxIcon,
+ SiDownloadIcon,
+ SiFileTextIcon,
+ SiSearchIcon,
+ ],
+ template: `
+
+ `,
+ host: { class: 'block' },
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CommandSearch {
+ private readonly router = inject(Router);
+ private readonly componentsService = inject(ComponentsService);
+ protected readonly commandSearchService = inject(CommandSearchService);
+
+ readonly searchString = signal('');
+
+ private readonly gettingStarted: SearchItem[] = [
+ {
+ label: 'Introduction',
+ path: '/docs/getting-started/introduction',
+ group: 'getting-started',
+ keywords: ['getting started', 'overview'],
+ },
+ {
+ label: 'Components',
+ path: '/docs/components',
+ group: 'getting-started',
+ keywords: ['list', 'overview'],
+ },
+ ];
+
+ private readonly installation: SearchItem[] = [
+ {
+ label: 'Prerequisites',
+ path: '/docs/getting-started/prerequisites',
+ group: 'installation',
+ keywords: ['install', 'setup', 'requirements'],
+ },
+ {
+ label: 'UI',
+ path: '/docs/getting-started/ui',
+ group: 'installation',
+ keywords: ['core', 'install'],
+ },
+ {
+ label: 'UI Lab',
+ path: '/docs/getting-started/ui-lab',
+ group: 'installation',
+ keywords: ['experimental', 'install'],
+ },
+ {
+ label: 'Carousel',
+ path: '/docs/getting-started/carousel',
+ group: 'installation',
+ keywords: ['slider', 'install'],
+ },
+ {
+ label: 'Charts',
+ path: '/docs/getting-started/charts',
+ group: 'installation',
+ keywords: ['graph', 'install'],
+ },
+ {
+ label: 'Editor',
+ path: '/docs/getting-started/editor',
+ group: 'installation',
+ keywords: ['rich text', 'install'],
+ },
+ {
+ label: 'Code',
+ path: '/docs/getting-started/code',
+ group: 'installation',
+ keywords: ['highlight', 'syntax', 'install'],
+ },
+ {
+ label: 'MCP Server',
+ path: '/docs/getting-started/mcp-server',
+ group: 'installation',
+ keywords: ['model context protocol', 'ai'],
+ },
+ ];
+
+ private readonly componentItems = computed(() =>
+ this.componentsService.visibleComponents().map((c) => ({
+ label: c.name,
+ path: `/docs/components/${c.path}`,
+ group: 'component' as const,
+ keywords: [c.category.toLowerCase(), c.description.toLowerCase()],
+ })),
+ );
+
+ readonly filteredGettingStarted = computed(() => {
+ const search = this.searchString().toLowerCase();
+ return this.filterItems(this.gettingStarted, search);
+ });
+
+ readonly filteredInstallation = computed(() => {
+ const search = this.searchString().toLowerCase();
+ return this.filterItems(this.installation, search);
+ });
+
+ readonly filteredComponents = computed(() => {
+ const search = this.searchString().toLowerCase();
+ return this.filterItems(this.componentItems(), search);
+ });
+
+ readonly hasResults = computed(
+ () =>
+ this.filteredGettingStarted().length > 0 ||
+ this.filteredInstallation().length > 0 ||
+ this.filteredComponents().length > 0,
+ );
+
+ private filterItems(items: SearchItem[], search: string): SearchItem[] {
+ if (!search) return items;
+ return items.filter(
+ (item) =>
+ item.label.toLowerCase().includes(search) ||
+ item.keywords?.some((k) => k.includes(search)),
+ );
+ }
+
+ onSelect(values: readonly string[]): void {
+ const path = values[0];
+ if (path) {
+ this.navigate(path);
+ }
+ }
+
+ navigate(path: string): void {
+ this.commandSearchService.close();
+ this.searchString.set('');
+ this.router.navigateByUrl(path);
+ }
+}
diff --git a/apps/showcase/src/app/components/navbar/navbar.ts b/apps/showcase/src/app/components/navbar/navbar.ts
index b877f1608..0fdb3f121 100644
--- a/apps/showcase/src/app/components/navbar/navbar.ts
+++ b/apps/showcase/src/app/components/navbar/navbar.ts
@@ -8,6 +8,8 @@ import {
} from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import {
+ ScButton,
+ ScKbd,
ScLink,
ScNavigationMenu,
ScNavigationMenuContent,
@@ -34,11 +36,13 @@ import {
SiGithubIcon,
SiMenuIcon,
SiMoonIcon,
+ SiSearchIcon,
SiSunIcon,
SiXIcon,
} from '@semantic-icons/lucide-icons';
import { ComponentsService } from '../../services/components.service';
import { GithubService } from '../../services/github.service';
+import { CommandSearchService } from '../command-search/command-search.service';
import { Logo } from '../logo/logo';
@Component({
@@ -64,7 +68,10 @@ import { Logo } from '../logo/logo';
ScNavigationMenuList,
ScNavigationMenuPortal,
ScNavigationMenuTrigger,
+ ScButton,
+ ScKbd,
SiGithubIcon,
+ SiSearchIcon,
SiSunIcon,
SiMoonIcon,
SiMenuIcon,
@@ -139,6 +146,28 @@ import { Logo } from '../logo/logo';