From e9a773621a75b4752138f55f6c13d6431d4dfbf5 Mon Sep 17 00:00:00 2001 From: anthony lio Date: Fri, 2 Oct 2026 12:02:46 +0300 Subject: [PATCH] fix(docs): keep search v2 result order and initial selection --- .../features/SearchV2/SearchV2.utils.test.tsx | 24 +-- .../docs/features/SearchV2/SearchV2.utils.tsx | 30 +++- .../docs/features/SearchV2/SearchV2Dialog.tsx | 168 +++--------------- .../docs/features/SearchV2/SearchV2Result.tsx | 61 +++++++ .../features/SearchV2/SearchV2Trigger.tsx | 2 +- apps/docs/styles/code-block.css | 1 + apps/docs/styles/loading-beam.css | 4 + packages/common/hooks/useDocsSearchV2.ts | 5 +- 8 files changed, 135 insertions(+), 160 deletions(-) create mode 100644 apps/docs/features/SearchV2/SearchV2Result.tsx diff --git a/apps/docs/features/SearchV2/SearchV2.utils.test.tsx b/apps/docs/features/SearchV2/SearchV2.utils.test.tsx index d79b031082c..632dafa905b 100644 --- a/apps/docs/features/SearchV2/SearchV2.utils.test.tsx +++ b/apps/docs/features/SearchV2/SearchV2.utils.test.tsx @@ -20,13 +20,13 @@ describe('formatHeadingPath', () => { function renderHighlight(text: string, query: string) { const result = highlightMatches(text, query) - if (typeof result === 'string') return { html: result, strongTexts: [] as string[] } + if (typeof result === 'string') return { html: result, markTexts: [] as string[] } const html = renderToStaticMarkup(<>{result}) const $ = load(html) return { html: $.root().text(), - strongTexts: $('strong') + markTexts: $('mark') .map((_, el) => $(el).text()) .get(), } @@ -37,19 +37,19 @@ describe('highlightMatches', () => { const result = highlightMatches('Bring your own MCP', 'mcp server') expect(typeof result).not.toBe('string') - const { strongTexts, html } = renderHighlight('Bring your own MCP', 'mcp server') - expect(strongTexts).toEqual(['MCP']) + const { markTexts, html } = renderHighlight('Bring your own MCP', 'mcp server') + expect(markTexts).toEqual(['MCP']) expect(html).toBe('Bring your own MCP') }) it('highlights multiple non-overlapping token matches independently', () => { - const { strongTexts } = renderHighlight('MCP servers for your server', 'mcp server') - expect(strongTexts).toEqual(['MCP', 'server', 'server']) + const { markTexts } = renderHighlight('MCP servers for your server', 'mcp server') + expect(markTexts).toEqual(['MCP', 'server', 'server']) }) it('merges overlapping/adjacent matches into a single run', () => { - const { strongTexts } = renderHighlight('server', 'server serv') - expect(strongTexts).toEqual(['server']) + const { markTexts } = renderHighlight('server', 'server serv') + expect(markTexts).toEqual(['server']) }) it('returns the original string unchanged when there is no match', () => { @@ -58,8 +58,8 @@ describe('highlightMatches', () => { }) it('is case-insensitive but preserves the original casing of the matched text', () => { - const { strongTexts } = renderHighlight('Bring your own MCP', 'MCP') - expect(strongTexts).toEqual(['MCP']) + const { markTexts } = renderHighlight('Bring your own MCP', 'MCP') + expect(markTexts).toEqual(['MCP']) }) it('returns the text unchanged for an empty or whitespace-only query', () => { @@ -68,8 +68,8 @@ describe('highlightMatches', () => { }) it('excludes common prepositions/articles/conjunctions from highlighting', () => { - const { strongTexts } = renderHighlight('The best MCP server for you', 'the mcp server') - expect(strongTexts).toEqual(['MCP', 'server']) + const { markTexts } = renderHighlight('The best MCP server for you', 'the mcp server') + expect(markTexts).toEqual(['MCP', 'server']) }) it('returns the text unchanged when the query is made up entirely of ignored words', () => { diff --git a/apps/docs/features/SearchV2/SearchV2.utils.tsx b/apps/docs/features/SearchV2/SearchV2.utils.tsx index d7db0f83e3c..b309ec1f7df 100644 --- a/apps/docs/features/SearchV2/SearchV2.utils.tsx +++ b/apps/docs/features/SearchV2/SearchV2.utils.tsx @@ -1,5 +1,13 @@ +import type { useDocsSearchV2 } from 'common' import type { ReactNode } from 'react' +type DocsSearchV2State = ReturnType['searchState'] + +interface GetIsSearchingParams { + searchState: DocsSearchV2State + query: string +} + /** Common English prepositions/articles/conjunctions, excluded from highlighting so a query like "the mcp server" doesn't bold "the". */ const IGNORED_WORDS = new Set([ 'a', @@ -65,7 +73,7 @@ function getMatchRanges(text: string, query: string): Array<[number, number]> { /** * Highlight every case-insensitive, per-word partial match of `query` inside `text`. * Returns the plain string when there's no match, otherwise a fragment with matches - * wrapped in , preserving the original casing of `text`. + * wrapped in , preserving the original casing of `text`. */ function highlightMatches(text: string, query: string): ReactNode { const ranges = getMatchRanges(text, query) @@ -75,7 +83,11 @@ function highlightMatches(text: string, query: string): ReactNode { let cursor = 0 ranges.forEach(([start, end], i) => { if (start > cursor) nodes.push(text.slice(cursor, start)) - nodes.push({text.slice(start, end)}) + nodes.push( + + {text.slice(start, end)} + + ) cursor = end }) if (cursor < text.length) nodes.push(text.slice(cursor)) @@ -83,4 +95,16 @@ function highlightMatches(text: string, query: string): ReactNode { return <>{nodes} } -export { formatHeadingPath, highlightMatches } +// drives the loading beam: on while a request is in flight or about to be sent +function getIsSearching({ searchState, query }: GetIsSearchingParams): boolean { + if (searchState.status === 'loading') return true + if (searchState.status === 'error') return false + + const trimmedQuery = query.trim() + const settledQuery = 'query' in searchState ? searchState.query : null + + // so a search is on its way and the beam starts on the keystroke, not when the request fires + return trimmedQuery !== '' && trimmedQuery !== settledQuery +} + +export { formatHeadingPath, getIsSearching, highlightMatches } diff --git a/apps/docs/features/SearchV2/SearchV2Dialog.tsx b/apps/docs/features/SearchV2/SearchV2Dialog.tsx index a2826ab34b9..fa3f0b9480b 100644 --- a/apps/docs/features/SearchV2/SearchV2Dialog.tsx +++ b/apps/docs/features/SearchV2/SearchV2Dialog.tsx @@ -2,12 +2,10 @@ import { LoadingBeam } from '~/features/ui/LoadingBeam' import { useDocsSearchV2, type DocsSearchV2Result } from 'common' -import Link from 'next/link' import { useRouter } from 'next/navigation' import { VisuallyHidden } from 'radix-ui' -import { useRef, useState, type KeyboardEvent, type MouseEvent } from 'react' +import { useRef, useState, type KeyboardEvent } from 'react' import { - cn, Command, CommandEmpty, CommandGroup, @@ -21,7 +19,8 @@ import { KeyboardShortcut, } from 'ui' -import { formatHeadingPath, highlightMatches } from './SearchV2.utils' +import { getIsSearching } from './SearchV2.utils' +import { SearchV2Result } from './SearchV2Result' interface SearchV2DialogProps { open: boolean @@ -33,42 +32,6 @@ interface SearchV2PanelProps { onResultOpen: () => void } -interface SearchV2ResultProps { - result: DocsSearchV2Result - highlightQuery: string - onResultSelect: (path: string) => void - onResultOpen: () => void -} - -type PointerOpen = 'none' | 'same-tab' | 'new-tab' - -type DocsSearchV2State = ReturnType['searchState'] - -interface GetIsSearchingParams { - searchState: DocsSearchV2State - query: string -} - -const OVERLAY_CLASS = cn( - 'data-closed:animate-out! data-closed:fade-out-0 data-closed:fill-mode-forwards', - 'data-closed:duration-150 data-closed:ease-enter', - 'max-lg:bg-transparent max-lg:backdrop-blur-none', - 'max-lg:top-(--header-height) max-lg:p-0!', - 'max-lg:data-closed:duration-200!' -) - -const CONTENT_CLASS = cn( - 'overflow-hidden rounded-lg border-0 p-0', - 'shadow-[inset_0_0_0_1px_var(--border-default),var(--shadow-codeblock,0_0_#0000)]!', - 'max-lg:flex max-lg:flex-1 max-lg:flex-col max-lg:max-w-none! max-lg:rounded-none!', - 'max-lg:shadow-none!', - 'max-lg:data-[state=open]:zoom-in-100! max-lg:data-[state=closed]:zoom-out-100!', - 'max-lg:motion-safe:data-[state=open]:slide-in-from-top-[100%]!', - 'max-lg:motion-safe:data-[state=closed]:slide-out-to-top-[100%]!', - 'max-lg:motion-reduce:data-[state=open]:fade-in-0', - 'max-lg:duration-300 max-lg:data-[state=closed]:duration-200! max-lg:ease-enter' -) - export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { const router = useRouter() @@ -86,9 +49,12 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { @@ -100,13 +66,15 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2() const [query, setQuery] = useState('') const [isDeleting, setIsDeleting] = useState(false) - const [lastSettledQuery, setLastSettledQuery] = useState('') const inputRef = useRef(null) - // Only update the highlighted query once a new result set actually lands, so highlights - // don't shift on every keystroke while the debounced search is still in flight. - const highlightQuery = getSettledQuery(searchState) ?? lastSettledQuery - if (highlightQuery !== lastSettledQuery) setLastSettledQuery(highlightQuery) + // highlight with the query the visible results belong to, not the one being typed + const highlightQuery = + 'query' in searchState + ? searchState.query + : 'staleQuery' in searchState + ? searchState.staleQuery + : '' const results: DocsSearchV2Result[] = 'results' in searchState @@ -115,7 +83,7 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { ? searchState.staleResults : [] - const hasListContent = + const isListVisible = results.length > 0 || searchState.status === 'noResults' || searchState.status === 'error' const isSearching = getIsSearching({ searchState, query }) @@ -125,8 +93,10 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { inputRef.current?.focus() } + // cmdk's root turns enter into "open highlighted result", so keep it on the button. + // other keys must bubble, or tab never reaches the dialog's focus trap function handleClearKeyDown(event: KeyboardEvent) { - event.stopPropagation() + if (event.key === 'Enter') event.stopPropagation() } function handleValueChange(value: string) { @@ -153,7 +123,7 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { } return ( - + Search docs Search the Supabase documentation @@ -165,10 +135,7 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { placeholder="Search docs..." aria-label="Search the Supabase documentation" onValueChange={handleValueChange} - wrapperClassName={cn( - 'flex-1 border-0 pl-3 pr-0 text-foreground-lighter max-lg:pl-5', - '[&_svg]:size-4.5 [&_svg]:stroke-[2.25] [&_svg]:opacity-100' - )} + wrapperClassName="flex-1 border-0 pl-3 pr-0 text-foreground-lighter max-lg:pl-5 [&_svg]:size-4.5 [&_svg]:stroke-2 [&_svg]:opacity-100" className="h-12 pl-2.5 text-base text-foreground placeholder:text-foreground-lighter" /> {query ? ( @@ -177,7 +144,7 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { tabIndex={0} onClick={handleClear} onKeyDown={handleClearKeyDown} - className="shrink-0 rounded-sm text-sm text-foreground-lighter transition-colors hover:text-foreground-light focus-ring" + className="relative -mx-2 shrink-0 cursor-pointer rounded-md px-2 py-1 text-xs text-foreground-lighter transition-colors duration-150 after:absolute after:inset-x-0 after:-inset-y-2 after:content-[''] hover:bg-overlay-hover hover:text-foreground focus-ring" > Clear @@ -185,7 +152,7 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { {/* @@ -198,21 +165,14 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { {searchState.status === 'noResults' && No results found.} {searchState.status === 'error' && ( Something went wrong. Please try again. )} {results.length > 0 && ( - + {results.map((result) => ( ) } - -function SearchV2Result({ - result, - highlightQuery, - onResultSelect, - onResultOpen, -}: SearchV2ResultProps) { - const parentHeadings = result.headingPath.slice(0, -1) - const heading = result.headingPath.at(-1) ?? result.title - - const pointerOpenRef = useRef('none') - - function handleLinkClick(event: MouseEvent) { - const isNewTab = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey - pointerOpenRef.current = isNewTab ? 'new-tab' : 'same-tab' - } - - function handleSelect() { - const pointerOpen = pointerOpenRef.current - pointerOpenRef.current = 'none' - if (pointerOpen === 'none') return onResultSelect(result.path) - if (pointerOpen === 'same-tab') onResultOpen() - } - - return ( - - -
-

- {parentHeadings.length > 0 ? ( - - {highlightMatches(`${formatHeadingPath(parentHeadings)} >`, highlightQuery)} - - ) : null} - - {highlightMatches(heading, highlightQuery)} - -

- {result.excerpt ? ( -

- {highlightMatches(result.excerpt, highlightQuery)} -

- ) : null} -
- -
- ) -} - -function getSettledQuery(searchState: DocsSearchV2State): string | null { - if (searchState.status === 'results' || searchState.status === 'noResults') { - return searchState.query - } - if (searchState.status === 'initial') return '' - return null -} - -function getIsSearching({ searchState, query }: GetIsSearchingParams): boolean { - if (searchState.status === 'loading') return true - if (searchState.status === 'error') return false - const trimmedQuery = query.trim() - const settledQuery = 'query' in searchState ? searchState.query : null - return trimmedQuery !== '' && trimmedQuery !== settledQuery -} diff --git a/apps/docs/features/SearchV2/SearchV2Result.tsx b/apps/docs/features/SearchV2/SearchV2Result.tsx new file mode 100644 index 00000000000..c5450749de1 --- /dev/null +++ b/apps/docs/features/SearchV2/SearchV2Result.tsx @@ -0,0 +1,61 @@ +'use client' + +import type { DocsSearchV2Result } from 'common' +import Link from 'next/link' +import { useRef, type MouseEvent } from 'react' +import { CommandItem } from 'ui' + +import { formatHeadingPath, highlightMatches } from './SearchV2.utils' + +interface SearchV2ResultProps { + result: DocsSearchV2Result + highlightQuery: string + onResultSelect: (path: string) => void + onResultOpen: () => void +} + +export function SearchV2Result({ + result, + highlightQuery, + onResultSelect, + onResultOpen, +}: SearchV2ResultProps) { + // clicks navigate via the link and also fire onSelect, so onSelect only navigates for Enter + const isPointerSelectRef = useRef(false) + + function handleLinkClick(event: MouseEvent) { + isPointerSelectRef.current = true + const isNewTab = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey + if (!isNewTab) onResultOpen() + } + + function handleSelect() { + if (isPointerSelectRef.current) { + isPointerSelectRef.current = false + return + } + onResultSelect(result.path) + } + + return ( + + +
+

+ {highlightMatches(formatHeadingPath(result.headingPath), highlightQuery)} +

+ {result.excerpt ? ( +

+ {highlightMatches(result.excerpt, highlightQuery)} +

+ ) : null} +
+ +
+ ) +} diff --git a/apps/docs/features/SearchV2/SearchV2Trigger.tsx b/apps/docs/features/SearchV2/SearchV2Trigger.tsx index d23a44778ef..2aa28205889 100644 --- a/apps/docs/features/SearchV2/SearchV2Trigger.tsx +++ b/apps/docs/features/SearchV2/SearchV2Trigger.tsx @@ -48,7 +48,7 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search 'grow md:min-w-44 xl:min-w-56 h-[30px] rounded-md', 'pl-2 pr-1', 'flex items-center justify-between', - 'border border-default bg-surface-75 text-foreground-lighter shadow-codeblock', + 'border border-default bg-surface-75 text-foreground-lighter shadow-(--shadow-codeblock)', 'hover:border-strong hover:text-foreground-light', 'focus-ring', 'transition-colors', diff --git a/apps/docs/styles/code-block.css b/apps/docs/styles/code-block.css index 762e9e829c3..66bc752d432 100644 --- a/apps/docs/styles/code-block.css +++ b/apps/docs/styles/code-block.css @@ -39,6 +39,7 @@ } [data-theme='light'], .light { + --shadow-codeblock: 0 0 #0000; --code-token-keyword: #5f2fc4; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; diff --git a/apps/docs/styles/loading-beam.css b/apps/docs/styles/loading-beam.css index da770e12e04..c82758df11f 100644 --- a/apps/docs/styles/loading-beam.css +++ b/apps/docs/styles/loading-beam.css @@ -31,6 +31,10 @@ animation: loading-beam-sweep 2s ease-in-out -0.25s infinite paused; } + :is([data-theme='light'], .light) .loading-beam { + --loading-beam-color: var(--primary); + } + .loading-beam[data-direction='backward'] { animation-delay: -1.25s; } diff --git a/packages/common/hooks/useDocsSearchV2.ts b/packages/common/hooks/useDocsSearchV2.ts index ddeb55e430a..26af620d52d 100644 --- a/packages/common/hooks/useDocsSearchV2.ts +++ b/packages/common/hooks/useDocsSearchV2.ts @@ -23,7 +23,7 @@ interface DocsSearchV2Result { type SearchState = | { status: 'initial'; key: number } - | { status: 'loading'; key: number; staleResults: DocsSearchV2Result[] } + | { status: 'loading'; key: number; staleResults: DocsSearchV2Result[]; staleQuery: string } | { status: 'results'; key: number; results: DocsSearchV2Result[]; query: string } | { status: 'noResults'; key: number; query: string } | { status: 'error'; key: number; message: string } @@ -76,6 +76,8 @@ function reducer(state: SearchState, action: Action): SearchState { key: action.key, staleResults: 'results' in state ? state.results : 'staleResults' in state ? state.staleResults : [], + // keep highlighted query while loading + staleQuery: 'query' in state ? state.query : 'staleQuery' in state ? state.staleQuery : '', } case 'reset': return { status: 'initial', key: action.key } @@ -124,6 +126,7 @@ const useDocsSearchV2 = () => { debouncedSearch.cancel() }, [debouncedSearch]) + // the dialog unmounts on close, so cancel any pending search instead of fetching after it's gone useMountEffect(() => debounceCancel) const resetSearch = useCallback(() => {