diff --git a/src/__tests__/components/command-palette/CommandPalette.test.tsx b/src/__tests__/components/command-palette/CommandPalette.test.tsx new file mode 100644 index 0000000..7c26617 --- /dev/null +++ b/src/__tests__/components/command-palette/CommandPalette.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { + CommandPalette, + type SearchHit, + type SearchResponse, + type SearchFn, +} from '../../../components/command-palette/CommandPalette'; + +const FIXTURE: SearchHit[] = [ + { + project: 'a', + slug: 'one', + role: 'team', + title: 'Brand Validator', + snippet: 'token allowlist', + score: 0.42, + url: '/team/a/one', + }, + { + project: 'b', + slug: 'two', + role: 'team', + title: 'Voice & Persona', + snippet: 'Sage-Caregiver', + score: 0.71, + url: '/team/b/two', + }, +]; + +const instant = + (hits: SearchHit[]): SearchFn => + async (query): Promise => ({ + query, + hits, + total: hits.length, + took_ms: 1, + }); + +function Harness({ + searchFn, + onNavigate, +}: { + searchFn?: SearchFn; + onNavigate?: (url: string, hit: SearchHit) => void; +}) { + const [open, setOpen] = useState(true); + return ( + + ); +} + +describe('CommandPalette', () => { + beforeEach(() => { + document.body.style.overflow = ''; + }); + + it('renders the dialog with input + footer', async () => { + render(); + expect(await screen.findByRole('dialog', { name: /search documentation/i })).toBeTruthy(); + expect(screen.getByLabelText(/search query/i)).toBeTruthy(); + expect(screen.getByText(/navigate/i)).toBeTruthy(); + }); + + it('renders the idle empty state when no recents are supplied', async () => { + render(); + expect(await screen.findByText(/start typing to search the vault/i)).toBeTruthy(); + }); + + it('shows results after typing', async () => { + render(); + const input = screen.getByLabelText(/search query/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'brand' } }); + await waitFor(() => expect(screen.getByRole('listbox')).toBeTruthy()); + expect(screen.getByText('Brand Validator')).toBeTruthy(); + expect(screen.getByText('Voice & Persona')).toBeTruthy(); + }); + + it('calls onNavigate when Enter is pressed on a selected hit', async () => { + const onNavigate = vi.fn(); + render(); + const input = screen.getByLabelText(/search query/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'voice' } }); + await waitFor(() => expect(screen.getByRole('listbox')).toBeTruthy()); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onNavigate).toHaveBeenCalledWith( + '/team/a/one', + expect.objectContaining({ title: 'Brand Validator' }), + ); + }); + + it('arrow-down advances selected index', async () => { + const onNavigate = vi.fn(); + render(); + const input = screen.getByLabelText(/search query/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'a' } }); + await waitFor(() => expect(screen.getByRole('listbox')).toBeTruthy()); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onNavigate).toHaveBeenCalledWith( + '/team/b/two', + expect.objectContaining({ title: 'Voice & Persona' }), + ); + }); + + it('shows the error state when searchFn rejects', async () => { + const failing: SearchFn = async () => { + throw new Error('boom'); + }; + render(); + const input = screen.getByLabelText(/search query/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'x' } }); + expect(await screen.findByText(/search is unavailable right now/i)).toBeTruthy(); + }); + + it('Escape closes the palette', async () => { + render(); + const input = screen.getByLabelText(/search query/i) as HTMLInputElement; + fireEvent.keyDown(input, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: /search documentation/i })).toBeNull(), + ); + }); +}); diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 0000000..fd4fdec --- /dev/null +++ b/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,1356 @@ +'use client'; +/** + * CommandPalette — generalized vault/search palette. + * + * Extracted from docs-ai-vault-search (2026-05-20). The consumer supplies: + * - searchFn(query, signal) → Promise + * - onNavigate?(url, hit) — defaults to window.location assignment + * - recents / recentSearches / emptyActions — optional data slots + * + * No router lock-in, no `/api/search` baked in. Visual treatment, a11y, and + * variants (`mobileVariant`) are preserved from the source. + */ + +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react'; +import { createPortal } from 'react-dom'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +/** Doc role taxonomy. Consumers may pass any string; common values are listed. */ +export type DocRole = 'team' | 'client' | 'public' | (string & {}); + +export interface SearchHit { + project: string; + slug: string; + title: string; + role: DocRole; + /** FTS5 snippet() output — HTML-escaped except for `` wrapper. */ + snippet: string; + /** BM25 rank — lower is better. */ + score: number; + /** Canonical doc URL — SSOT for navigation. */ + url: string; +} + +export interface SearchResponse { + query: string; + hits: SearchHit[]; + total: number; + took_ms?: number; +} + +export type SearchFn = (query: string, signal: AbortSignal) => Promise; + +export interface RecentDoc { + title: string; + path: string; + /** Human label, e.g. "2d ago". */ + date: string; + /** Optional URL for click-through. */ + url?: string; +} + +export interface RecentSearch { + q: string; + ago: string; +} + +export interface EmptyAction { + label: string; + /** Single-key hint shown as a kbd chip, e.g. "1", "I". */ + kbd?: string; +} + +export interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Required fetcher — consumer wires search backend. */ + searchFn?: SearchFn; + /** + * Navigation handler. Receives the destination URL + the hit. If omitted, + * the palette falls back to `window.location.href = url`. + */ + onNavigate?: (url: string, hit: SearchHit) => void; + /** Scope label rendered in the footer pill (e.g. 'team', 'client'). */ + scope?: DocRole; + /** Project slug rendered in the footer pill. */ + project?: string; + /** Mobile variant — bottom-sheet for <768px, modal otherwise. Default 'auto'. */ + mobileVariant?: 'auto' | 'modal' | 'sheet'; + /** Debounce in ms before firing searchFn. Default 200. */ + debounceMs?: number; + /** Input placeholder. Default 'Search docs, briefs, notes…'. */ + placeholder?: string; + /** Idle-state recent docs list. Empty by default. */ + recents?: RecentDoc[]; + /** No-results recent searches list. Empty by default. */ + recentSearches?: RecentSearch[]; + /** No-results action chips. Empty by default. */ + emptyActions?: EmptyAction[]; +} + +export interface CommandPaletteTriggerProps { + onClick: () => void; + compact?: boolean; + 'aria-label'?: string; +} + +// ─── Trigger pill ───────────────────────────────────────────────────────────── + +export function CommandPaletteTrigger({ + onClick, + compact = false, + 'aria-label': ariaLabel = 'Open search', +}: CommandPaletteTriggerProps) { + return ( + + ); +} + +// ─── Main palette ───────────────────────────────────────────────────────────── + +const DEFAULT_DEBOUNCE = 200; + +export function CommandPalette({ + open, + onOpenChange, + searchFn, + onNavigate, + scope, + project, + mobileVariant = 'auto', + debounceMs = DEFAULT_DEBOUNCE, + placeholder = 'Search docs, briefs, notes…', + recents = [], + recentSearches = [], + emptyActions = [], +}: CommandPaletteProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [errored, setErrored] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + // Debounced fetch + useEffect(() => { + if (!open || !searchFn) return; + setErrored(false); + const controller = new AbortController(); + const t = setTimeout(async () => { + setLoading(true); + try { + const response = await searchFn(query, controller.signal); + if (controller.signal.aborted) return; + setResults(Array.isArray(response?.hits) ? response.hits : []); + setSelectedIndex(0); + } catch { + if (!controller.signal.aborted) { + setErrored(true); + setResults([]); + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, debounceMs); + return () => { + clearTimeout(t); + controller.abort(); + }; + }, [query, open, searchFn, debounceMs]); + + const handleNavigate = useCallback( + (hit: SearchHit) => { + if (onNavigate) { + onNavigate(hit.url, hit); + } else if (typeof window !== 'undefined') { + window.location.href = hit.url; + } + }, + [onNavigate], + ); + + return ( + setQuery((q) => q)} + selectedIndex={selectedIndex} + setSelectedIndex={setSelectedIndex} + onClose={() => onOpenChange(false)} + onNavigate={handleNavigate} + scope={scope} + project={project} + mobileVariant={mobileVariant} + placeholder={placeholder} + recents={recents} + recentSearches={recentSearches} + emptyActions={emptyActions} + /> + ); +} + +// ─── Presentational surface ─────────────────────────────────────────────────── + +interface PaletteSurfaceProps { + open: boolean; + query: string; + setQuery: (q: string) => void; + results: SearchHit[]; + loading: boolean; + errored: boolean; + onRetry: () => void; + selectedIndex: number; + setSelectedIndex: (n: number) => void; + onClose: () => void; + onNavigate: (hit: SearchHit) => void; + scope?: DocRole; + project?: string; + mobileVariant: 'auto' | 'modal' | 'sheet'; + placeholder: string; + recents: RecentDoc[]; + recentSearches: RecentSearch[]; + emptyActions: EmptyAction[]; +} + +function PaletteSurface({ + open, + query, + setQuery, + results, + loading, + errored, + onRetry, + selectedIndex, + setSelectedIndex, + onClose, + onNavigate, + scope, + project, + mobileVariant, + placeholder, + recents, + recentSearches, + emptyActions, +}: PaletteSurfaceProps) { + const inputRef = useRef(null); + const listRef = useRef(null); + const lastFocusRef = useRef(null); + const reactId = useId(); + const listboxId = `cp-listbox-${reactId}`; + + // Portal mount guard — overlay must render into document.body so backdrop-filter + // ancestors don't pin position:fixed children to a stale containing block. + const [portalReady, setPortalReady] = useState(false); + useEffect(() => { + setPortalReady(true); + }, []); + + // Focus management + body scroll lock + useEffect(() => { + if (open) { + lastFocusRef.current = document.activeElement as HTMLElement | null; + const t = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(t); + } else { + lastFocusRef.current?.focus(); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + const onInputKey = useCallback( + (e: ReactKeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(Math.min(selectedIndex + 1, Math.max(0, results.length - 1))); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(Math.max(0, selectedIndex - 1)); + } else if (e.key === 'Enter') { + const hit = results[selectedIndex]; + if (hit) { + e.preventDefault(); + onNavigate(hit); + } + } else if (e.key === 'Home') { + e.preventDefault(); + setSelectedIndex(0); + } else if (e.key === 'End') { + e.preventDefault(); + setSelectedIndex(Math.max(0, results.length - 1)); + } + }, + [results, selectedIndex, setSelectedIndex, onClose, onNavigate], + ); + + useEffect(() => { + const node = listRef.current?.querySelector( + `[data-cp-index="${selectedIndex}"]`, + ); + node?.scrollIntoView?.({ block: 'nearest' }); + }, [selectedIndex]); + + if (!open) return null; + if (!portalReady) return null; + + const isSheet = mobileVariant === 'sheet'; + const showEmpty = + !loading && !errored && results.length === 0 && query.trim().length > 0; + const showInitial = + !loading && !errored && results.length === 0 && query.trim().length === 0; + + return createPortal( +
+
e.stopPropagation()} + > +
+ + + + + setQuery(e.target.value)} + onKeyDown={onInputKey} + placeholder={placeholder} + aria-label="Search query" + aria-controls={listboxId} + aria-activedescendant={ + results[selectedIndex] ? `cp-opt-${selectedIndex}` : undefined + } + aria-autocomplete="list" + className="cp-input" + autoComplete="off" + autoCorrect="off" + spellCheck={false} + /> +
+ +
+ {loading && } + {errored && } + {showInitial && } + {showEmpty && ( + + )} + {!loading && !errored && results.length > 0 && ( + <> +
+ Results + + · {results.length} {results.length === 1 ? 'match' : 'matches'} + +
+ + + )} +
+ +
+ + + + navigate + + + + open + + + esc + close + + {(scope || project) && ( + + + Scoped to + + {project ? `${scope ?? ''}${scope ? '/' : ''}${project}` : scope} + + {results.length > 0 && ( + + {results.length} + + )} + + )} +
+
+ +