Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions docs-app/app/components/docs-search.gts
Original file line number Diff line number Diff line change
@@ -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 = '';
};

<template>
<button
type="button"
class="trigger group flex items-center gap-2 px-3 py-1.5 text-sm text-slate-500 transition bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-md hover:border-slate-400 dark:hover:border-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
{{on "click" this.open}}
aria-label="Search documentation"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
<span class="hidden sm:inline">Search docs...</span>
<kbd
class="hidden ml-auto text-xs font-semibold text-slate-400 sm:inline-flex items-center gap-0.5"
>
<abbr title="Command" class="no-underline">⌘</abbr>K
</kbd>
</button>

<CommandPalette @open={{this.isOpen}} @onClose={{this.close}} as |cp|>
<cp.Dialog class="dialog">
<div class="flex flex-col max-h-[60vh] bg-white dark:bg-slate-900 rounded-lg">
<cp.Input
class="w-full px-4 py-3 text-base border-b border-slate-200 dark:border-slate-700 bg-transparent text-slate-900 dark:text-slate-100 focus:outline-none placeholder-slate-400"
placeholder="Search documentation..."
value={{this.searchQuery}}
{{on "input" this.handleInput}}
/>

<cp.List class="overflow-y-auto px-2 py-2 max-h-[400px] focus:outline-none" tabindex="0">
{{#if this.filteredPages.length}}
{{#each this.filteredPages as |page|}}
<cp.Item
class="flex flex-col px-3 py-2 rounded cursor-pointer focus:bg-slate-100 dark:focus:bg-slate-800 focus:outline-none hover:bg-slate-50 dark:hover:bg-slate-800/50"
@onSelect={{fn this.selectPage page}}
>
<div class="font-medium text-slate-900 dark:text-slate-100">
{{page.title}}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400">
{{page.category}}
{{page.path}}
</div>
</cp.Item>
{{/each}}
{{else}}
<div class="px-3 py-8 text-center text-slate-500 dark:text-slate-400">
{{#if this.searchQuery}}
No results found for "{{this.searchQuery}}"
{{else}}
Start typing to search...
{{/if}}
</div>
{{/if}}
</cp.List>
</div>
</cp.Dialog>
</CommandPalette>

<style scoped>
.trigger {
}
.dialog {
position: absolute;
top: 0.75rem;
/* margin-top: -2.5rem; */
border-radius: 0.25rem;
border: 1px solid;
filter: drop-shadow(0px 1rem 1rem rgba(0, 0, 0, 0.5));
}
</style>
</template>
}
4 changes: 4 additions & 0 deletions docs-app/app/templates/page.gts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +18,9 @@ export default class Page extends Component {
<Logomark class="h-9 w-28 lg:hidden" />
<Logo class="hidden w-auto h-9 fill-slate-700 lg:block dark:fill-sky-100" />
</:logoLink>
<:search>
<DocsSearch />
</:search>
<:topRight>
<TestsLink />
<GitHubLink />
Expand Down
1 change: 1 addition & 0 deletions docs-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"decorator-transforms": "^2.3.0",
"ember-element-helper": "^0.8.8",
"ember-focus-trap": "^1.1.0",
"ember-keyboard": "^9.0.4",
"ember-mobile-menu": "^6.0.0",
"ember-modifier": "^4.2.2",
"ember-primitives": "workspace:^",
Expand Down
Loading
Loading