// SearchModal — cmd-k modal with live filter + keyboard navigation. // // Generic-enough that any app can wire it: pass a `search(query)` // function (returns a list of hits in whatever shape you have) and // render each hit with the `renderItem` prop. Selecting a hit calls // `onSelect(hit)` — typical use: navigate to `hit.href`. // // Keyboard: // ⌘K / Ctrl+K toggle open // Esc close // ↑ / ↓ move highlight // Enter select highlighted hit // // Visual: Fumadocs/Linear-style centered modal, dark backdrop, blur, // rounded sharp-cornered card, monospaced ⌘K + Esc hints in the // header. Uses the kit's tokens so light + dark modes both work. // // Implementation notes: // - The modal is conditionally mounted (only in the DOM when open) // so we don't pay portal/listener cost on every page. // - The cmd-k global listener is always attached when this component // is in the tree, so the user can hit ⌘K from anywhere. import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, } from 'react' import { cn } from '../cn' import { SearchIcon, ArrowReturnIcon } from './docIcons' export interface SearchModalProps { /** Search function — called on every keystroke. Should return * results synchronously (the kit doesn't manage async state here; * if you need async, debounce + useState in the caller). */ readonly search: (query: string) => ReadonlyArray /** Renders a single hit. Receives the item + whether it's currently * highlighted (for hover/keyboard focus styles). */ readonly renderItem: (item: TItem, isActive: boolean) => ReactNode /** Stable key for an item — used for React keys + the active index * match across renders. */ readonly itemKey: (item: TItem) => string /** Selecting a hit (click OR Enter). Caller typically navigates. */ readonly onSelect: (item: TItem) => void /** Placeholder for the input. */ readonly placeholder?: string /** Label shown when there are no hits + the query has 1+ char. */ readonly emptyLabel?: string /** Initial empty state — when the input is empty. Show recent * searches, sections, whatever fits. */ readonly empty?: ReactNode /** Open state — controlled. Default uses internal state. */ readonly open?: boolean readonly onOpenChange?: (open: boolean) => void /** User-facing strings for the modal's own chrome (dialog/backdrop * aria-labels, footer hint captions, initial-state prompt). Defaults * are English so the modal works unconfigured. */ readonly labels?: SearchModalLabels } export interface SearchModalLabels { /** `aria-label` of the dialog. Default `'Search'`. */ readonly dialog?: string /** `aria-label` of the click-to-close backdrop. Default `'Close search'`. */ readonly close?: string /** Prompt shown when the input is empty (and no `empty` node given). * Default `'Start typing to search.'`. */ readonly startTyping?: string /** Footer caption next to the ↑↓ keys. Default `'navigate'`. */ readonly navigate?: string /** Footer caption next to the return key. Default `'select'`. */ readonly select?: string /** Footer caption next to the ⌘K key. Default `'toggle'`. */ readonly toggle?: string } const DEFAULT_SEARCH_LABELS: Required = { dialog: 'Search', close: 'Close search', startTyping: 'Start typing to search.', navigate: 'navigate', select: 'select', toggle: 'toggle', } export const SearchModal = ({ search, renderItem, itemKey, onSelect, placeholder = 'Search…', emptyLabel = 'No results.', empty, open: controlledOpen, onOpenChange, labels, }: SearchModalProps): ReactNode => { const t = { ...DEFAULT_SEARCH_LABELS, ...labels } const isControlled = controlledOpen !== undefined const [internalOpen, setInternalOpen] = useState(false) const open = isControlled ? controlledOpen : internalOpen const setOpen = useCallback((next: boolean): void => { if (!isControlled) setInternalOpen(next) onOpenChange?.(next) }, [isControlled, onOpenChange]) const [query, setQuery] = useState('') const [activeIdx, setActiveIdx] = useState(0) const inputRef = useRef(null) const listboxId = useId() // Re-run search on every query change. Cheap for our doc-sized // catalogues; if the result set grows past 1000 entries, debounce // here. const hits = useMemo(() => (query.trim() ? search(query) : []), [query, search]) // Reset highlight + scroll position when results change. useEffect(() => { setActiveIdx(0) }, [query]) // Global ⌘K / Ctrl+K toggle. useEffect(() => { const handler = (e: globalThis.KeyboardEvent): void => { const k = e.key.toLowerCase() if (k === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen(!open) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [open, setOpen]) // Focus the input + reset state on open. useEffect(() => { if (!open) return setQuery('') setActiveIdx(0) // RAF lets the modal mount before we grab focus, so the autofocus // ring doesn't paint twice. const id = requestAnimationFrame(() => inputRef.current?.focus()) return () => cancelAnimationFrame(id) }, [open]) const close = useCallback((): void => setOpen(false), [setOpen]) const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); close(); return } if (e.key === 'ArrowDown') { e.preventDefault() setActiveIdx((i) => Math.min(i + 1, Math.max(hits.length - 1, 0))) return } if (e.key === 'ArrowUp') { e.preventDefault() setActiveIdx((i) => Math.max(i - 1, 0)) return } if (e.key === 'Enter') { const hit = hits[activeIdx] if (hit) { e.preventDefault(); onSelect(hit); close() } } } if (!open) return null return (
{/* Backdrop — click closes */} ) })} )}
{t.navigate} {t.select}
⌘K {t.toggle}
) } // ---- SearchTrigger — drop-in button for top bars ---- // // Renders a fake "input" that looks like the search field but is a // real button. Click → caller toggles the modal. Keeps the topbar // visually consistent across breakpoints while making the modal the // single search surface. interface SearchTriggerProps { readonly onClick: () => void readonly placeholder?: string /** `aria-label` for the trigger button. Default `'Open search'`. */ readonly openLabel?: string readonly className?: string } export const SearchTrigger = ({ onClick, placeholder = 'Search docs…', openLabel = 'Open search', className, }: SearchTriggerProps): ReactNode => ( )