import { router } from '@inertiajs/react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { BookOpen, Search } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import type { KeyboardEvent } from 'react'; import ApiDocsController from '@/actions/App/Http/Controllers/ApiDocsController'; import { EndpointPath, MethodChip, endpointAnchor, revealEndpoint, } from '@/components/api-docs/endpoint'; import { searchApiDocs } from '@/components/api-docs/search'; import type { ApiDocsResult } from '@/components/api-docs/search'; import { DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import { isTyping } from '@/hooks/use-keyboard-shortcuts'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; import { cn } from '@/lib/utils'; import type { ApiDocsIndexEntry, ApiDocsSection } from '@/types'; const RESULTS_ID = 'api-docs-search-results'; /** * ⌘K or Ctrl+K from anywhere, or / when the reader is not typing somewhere else. */ export function isSearchShortcut(event: { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; target: EventTarget | null; }): boolean { if (hasModKey(event)) { return event.key.toLowerCase() === 'k'; } return event.key === '/' && !event.altKey && !isTyping(event.target); } /** * The result an arrow key moves to, round the ends, or null for any other key. */ export function moveActive( position: number, key: string, count: number, ): number | null { const step = { ArrowDown: 1, ArrowUp: -1 }[key]; if (step === undefined || count === 0) { return null; } return (Math.min(position, count - 1) + step + count) % count; } /** * What the reader typed and the result they are on, kept while the search closes so its contents stay still through * the exit, and started afresh each time it opens. */ export function useSearchQuery(isOpen: boolean) { const [query, setQuery] = useState(''); const [activeIndex, setActiveIndex] = useState(0); const [wasOpen, setWasOpen] = useState(isOpen); if (isOpen !== wasOpen) { setWasOpen(isOpen); if (isOpen) { setQuery(''); setActiveIndex(0); } } const type = (value: string) => { setQuery(value); setActiveIndex(0); }; return { query, type, activeIndex, setActiveIndex }; } function resultId(result: ApiDocsResult): string { return `api-docs-search-${result.kind}-${result.id}`; } /** * Puts the reader where they picked, as if they had tabbed there: on an endpoint's toggle, or on a guide's first * section. The page has already scrolled there, so focus does not scroll it again. */ export function focusResult(result: ApiDocsResult): void { const selector = result.kind === 'endpoint' ? `#${endpointAnchor(result.endpoint)} > button` : 'main article h2 a'; document .querySelector(selector) ?.focus({ preventScroll: true }); } /** * Where focus goes once the search has closed: to what was picked on this page, nowhere yet for a page still * arriving, since it takes focus once there, or back to the Search button when nothing was picked. The dialog has no * trigger of its own, so Radix alone would leave focus on the page's body. */ export function handOffFocus( pick: { result: ApiDocsResult; isHere: boolean } | null, returnFocus: () => void, ): void { if (pick === null) { returnFocus(); return; } if (pick.isHere) { focusResult(pick.result); } } type Visit = (url: string, options: { onSuccess: () => void }) => void; /** * Goes to a guide, or to an endpoint opened at its anchor, and says whether that stays on the page. An endpoint of * the page already shown is opened in place, as a link to it would, even when the hash already points at it, and the * search closes at once. Another page closes it only once it has arrived: a visit that fails or is cancelled leaves * the search open, as typed, with focus in it. Closing says which it was. */ export function goToResult( result: ApiDocsResult, current: string, close: (isHere: boolean) => void, visit: Visit = (url, options) => router.visit(url, options), reveal: (anchor: string) => void = revealEndpoint, ): boolean { const arrive = { onSuccess: () => { close(false); window.requestAnimationFrame(() => focusResult(result)); }, }; if (result.kind === 'guide') { visit(ApiDocsController.show(result.section.slug).url, arrive); return false; } const anchor = endpointAnchor(result.endpoint); if (result.section.slug === current) { close(true); reveal(anchor); return true; } visit( `${ApiDocsController.show(result.section.slug).url}#${anchor}`, arrive, ); return false; } /** * The field and what it finds: a combobox over a listbox of guides and endpoints, the active one on the Accent. */ export function SearchPanel({ query, results, activeIndex, onType, onActivate, onPick, }: { query: string; results: ApiDocsResult[]; activeIndex: number; onType: (query: string) => void; onActivate: (index: number) => void; onPick: (result: ApiDocsResult) => void; }) { const keys = useKeyLabels(); const activeResult = results[Math.min(activeIndex, results.length - 1)]; const onKeyDown = (event: KeyboardEvent) => { if (event.nativeEvent.isComposing || results.length === 0) { return; } const next = moveActive(activeIndex, event.key, results.length); if (next !== null) { event.preventDefault(); onActivate(next); } if (event.key === 'Enter' && activeResult) { event.preventDefault(); onPick(activeResult); } }; return ( <>
onType(event.target.value)} onKeyDown={onKeyDown} placeholder="Search endpoints and guides" aria-label="Search the API docs" role="combobox" aria-expanded={results.length > 0} aria-controls={results.length > 0 ? RESULTS_ID : undefined} aria-activedescendant={ activeResult ? resultId(activeResult) : undefined } aria-autocomplete="list" autoComplete="off" spellCheck={false} className="h-14 min-w-0 flex-1 bg-transparent text-[17px] outline-none placeholder:text-muted-foreground focus-visible:outline-none" /> esc
{results.length === 0 ? (

fatal: nothing matches '{query}'

) : ( )}
↑↓ to move ↵ to open {keys.combo(keys.mod, 'K')}{' '} anywhere in the docs
); } /** * The API docs' search: every guide and endpoint, found by the words of their method, path, title and summary, * as people search does, from ⌘K or / anywhere in the docs. */ export function EndpointSearch({ open: isOpen, onOpenChange, sections, index, current, returnFocus, }: { open: boolean; onOpenChange: (open: boolean) => void; sections: ApiDocsSection[]; index: ApiDocsIndexEntry[]; current: string; returnFocus: () => void; }) { const { query, type, activeIndex, setActiveIndex } = useSearchQuery(isOpen); const results = searchApiDocs(query, sections, index); const activeResult = results[Math.min(activeIndex, results.length - 1)]; const activeId = activeResult ? resultId(activeResult) : undefined; useEffect(() => { if (isOpen && activeId) { document .getElementById(activeId) ?.scrollIntoView({ block: 'nearest' }); } }, [isOpen, activeId]); const picked = useRef<{ result: ApiDocsResult; isHere: boolean } | null>( null, ); const isOpenNow = useRef(isOpen); useEffect(() => { isOpenNow.current = isOpen; }, [isOpen]); // A pick is only noted once it closes the search, and only while the search is still open: one whose page never // arrived, or arrived after the reader closed the search, leaves no pick behind for the next close. const pick = (result: ApiDocsResult) => { goToResult(result, current, (isHere) => { if (isOpenNow.current) { picked.current = { result, isHere }; onOpenChange(false); } }); }; const onCloseAutoFocus = (event: Event) => { const target = picked.current; picked.current = null; event.preventDefault(); handOffFocus(target, returnFocus); }; return ( Search the API docs Find a guide, or an endpoint by its method, path or what it does. ); }