diff --git a/docs-app/app/components/docs-search.gts b/docs-app/app/components/docs-search.gts new file mode 100644 index 000000000..948c636c7 --- /dev/null +++ b/docs-app/app/components/docs-search.gts @@ -0,0 +1,232 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { registerDestructor } from '@ember/destroyable'; +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { service } from '@ember/service'; + +import { CommandPalette } from 'ember-primitives'; +import { docsManager } from 'kolay'; + +import type RouterService from '@ember/routing/router-service'; +import type { Page } from 'kolay'; + +interface PageData { + path: string; + title: string; + category: string; +} + +function titleize(str: string): string { + return ( + str + .split(/-|\s/) + .filter(Boolean) + .filter((text) => !text.match(/^[\d]+$/)) + .map((text) => `${text[0]?.toLocaleUpperCase()}${text.slice(1, text.length)}`) + .join(' ') + .split('.')[0] || '' + ); +} + +function extractPages(pages: Page[], category = ''): PageData[] { + const result: PageData[] = []; + + for (const page of pages) { + if ('path' in page) { + let title = titleize(page.name); + + if ('title' in page && typeof page.title === 'string') { + title = page.title; + } + + result.push({ + path: page.path, + title, + category: category || 'Documentation', + }); + } + + if ('pages' in page && Array.isArray(page.pages)) { + result.push(...extractPages(page.pages as Page[], titleize(page.name))); + } + } + + return result; +} + +export class DocsSearch extends Component { + @service declare router: RouterService; + + @tracked searchQuery = ''; + + @tracked isOpen = false; + + constructor(owner: unknown, args: unknown) { + super(owner as never, args as never); + + // In docs-app we want Cmd/Ctrl+K to open globally (regardless of focus). + // Use a window listener (capture phase) so we can prevent the browser's default behavior. + if (typeof window === 'undefined') return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (event.isComposing) return; + + if (event.altKey || event.shiftKey) return; + if (!(event.metaKey || event.ctrlKey)) return; + + // Prefer `key` (layout-aware); fall back to `code`. + const isK = event.key?.toLowerCase() === 'k' || event.code === 'KeyK'; + + if (!isK) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + }; + + // `true` => capture phase + window.addEventListener('keydown', onKeyDown, true); + + registerDestructor(this, () => { + window.removeEventListener('keydown', onKeyDown, true); + }); + } + + get allPages(): PageData[] { + const m = docsManager(this); + + if (!m) return []; + + return extractPages(m.pages || []); + } + + get filteredPages(): PageData[] { + if (!this.searchQuery) { + return this.allPages.slice(0, 10); // Show first 10 when no query + } + + const query = this.searchQuery.toLowerCase(); + + return this.allPages + .filter( + (page) => + page.title.toLowerCase().includes(query) || + page.path.toLowerCase().includes(query) || + page.category.toLowerCase().includes(query) + ) + .slice(0, 10); // Limit to 10 results + } + + handleInput = (event: Event) => { + const target = event.target as HTMLInputElement; + + this.searchQuery = target.value; + }; + + selectPage = (page: PageData) => { + this.router.transitionTo(page.path); + this.searchQuery = ''; + this.isOpen = false; + }; + + open = () => { + this.isOpen = true; + }; + + close = () => { + this.isOpen = false; + this.searchQuery = ''; + }; + + +} diff --git a/docs-app/app/templates/page.gts b/docs-app/app/templates/page.gts index 5e11cbf3c..afc2e50fc 100644 --- a/docs-app/app/templates/page.gts +++ b/docs-app/app/templates/page.gts @@ -1,5 +1,6 @@ import Component from '@glimmer/component'; +import { DocsSearch } from 'docs-app/components/docs-search'; import { GitHubLink, TestsLink } from 'docs-app/components/header'; import { Logo, Logomark } from 'docs-app/components/icons'; import { ExternalLink } from 'ember-primitives'; @@ -17,6 +18,9 @@ export default class Page extends Component {