import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import { useDocsSearch } from 'fumadocs-core/search/client'; import { SearchDialog as Dialog, SearchDialogClose, SearchDialogContent, SearchDialogHeader, SearchDialogIcon, SearchDialogList, SearchDialogOverlay, } from 'fumadocs-ui/components/dialog/search'; import type { SharedProps } from 'fumadocs-ui/contexts/search'; import { config } from 'virtual:seemore/config'; import { stripBase } from '../../shared/base.js'; import { feature } from '../lib/features.js'; import { createSearchClient } from './client.js'; /** * Our own dialog rather than fumadocs' `DefaultSearchDialog`, because the default one talks * to a search *route*; seemore has no server, so the client is a static index read in a * worker. */ /** * Add the highlight query to a result URL. * * A content match points at a heading, so the URL already has a fragment — and a query * appended after one is part of the fragment, not a search param. */ function withHighlight(url: string, query: string): string { if (!feature('search.highlight') || query === '') return url; const hashAt = url.indexOf('#'); const path = hashAt === -1 ? url : url.slice(0, hashAt); const hash = hashAt === -1 ? '' : url.slice(hashAt); return `${path}?h=${encodeURIComponent(query)}${hash}`; } export function SearchDialog(props: SharedProps) { const navigate = useNavigate(); const client = useMemo(() => createSearchClient(config.search), []); const { search, setSearch, query } = useDocsSearch({ client }); const results = query.data === 'empty' || query.data === undefined ? [] : query.data; /** * A long index is parsed on the first query, so the wait is seconds rather than * milliseconds — long enough that the list's "No results found" reads as an answer. * * `query.isLoading` alone is not the whole wait: fumadocs debounces the input before it * flips, so a keystroke's worth of that wrong answer shows first. A query is pending from * the keystroke until the search settles. */ const [pending, setPending] = useState(false); useEffect(() => { setPending(search !== ''); }, [search]); useEffect(() => { if (!query.isLoading) setPending(false); }, [query.isLoading]); // `search.suggest`: complete the last word inline from the best result's title. const completion = useMemo(() => { if (!feature('search.suggest') || search === '' || results.length === 0) return ''; // A result's content arrives with the matched span wrapped in `` — and the match // is the query itself, so leaving the markup in place is a prefix test that can never // pass. const title = (results[0]?.content ?? '').replaceAll('', '').replaceAll('', ''); return title.toLowerCase().startsWith(search.toLowerCase()) ? title.slice(search.length) : ''; }, [results, search]); // An empty box has nothing to answer: `null` collapses the list, where an empty array // would answer "No results found". const items = search === '' ? null : results.map((result) => ({ ...result, external: false })); return ( { if (item.type === 'action') return; props.onOpenChange(false); // `search.highlight`: the query travels with the link, so a shared result highlights // too — which is why there is no separate `search.share`. void navigate(withHighlight(stripBase(config.base, item.url), search), { viewTransition: true }); }} > {/* The sticky header is `z-50` too, and it renders after this portal in the DOM — at mobile's `top-4` it wins the tie and paints over the input row. fumadocs' own header is `z-30`, so theirs never meets this; ours needs the dialog lifted above it. */} {/* Our own input rather than fumadocs' `SearchDialogInput`: that one overwrites `placeholder` with its own translated "Search" after spreading props, so the wording here would never reach the box. The state is already ours. */} setSearch(event.target.value)} placeholder="Search documentation…" onKeyDown={(event) => { if (event.key !== 'ArrowRight' || completion === '') return; event.preventDefault(); setSearch(search + completion); }} /> {completion === '' ? undefined : ( )} {query.error !== undefined ? ( // A search box that silently finds nothing is worse than one that says why.

{query.error.message}

) : pending && (items?.length ?? 0) === 0 ? ( // Only with nothing to show: results already on screen stay put while the next // query runs, under the input icon's own pulse.
) : ( )}
); } export default SearchDialog;