fix(docs): keep search v2 result order and initial selection

This commit is contained in:
anthony lio committed 2026-10-02 22:42:27 +03:00
1 parent f36f8c7424
commit e9a773621a
8 files changed
+135 -160

No files matched your search

@@ -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', () => {
+27 -3
View File
@@ -1,5 +1,13 @@
import type { useDocsSearchV2 } from 'common'
import type { ReactNode } from 'react'
type DocsSearchV2State = ReturnType<typeof useDocsSearchV2>['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 <strong>, preserving the original casing of `text`.
* wrapped in <mark>, 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(<strong key={i}>{text.slice(start, end)}</strong>)
nodes.push(
<mark key={i} className="-mx-px rounded-sm bg-primary-bright/15 px-px text-primary">
{text.slice(start, end)}
</mark>
)
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 }
+25 -143
View File
@@ -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<typeof useDocsSearchV2>['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) {
<DialogContent
hideClose
centered={false}
dialogOverlayProps={{ className: OVERLAY_CLASS }}
dialogOverlayProps={{
className:
'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!',
}}
size="large"
className={CONTENT_CLASS}
className="overflow-hidden rounded-lg border-0 p-0 inset-ring inset-ring-border shadow-(--shadow-codeblock)! 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:inset-ring-0 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! max-lg:motion-safe:data-[state=closed]:slide-out-to-top! 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"
>
<SearchV2Panel onResultSelect={handleSelect} onResultOpen={handleResultOpen} />
</DialogContent>
@@ -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<HTMLInputElement>(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<HTMLButtonElement>) {
event.stopPropagation()
if (event.key === 'Enter') event.stopPropagation()
}
function handleValueChange(value: string) {
@@ -153,7 +123,7 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) {
}
return (
<Command className="bg-transparent max-lg:min-h-0 max-lg:flex-1">
<Command shouldFilter={false} className="bg-transparent max-lg:min-h-0 max-lg:flex-1">
<VisuallyHidden.VisuallyHidden>
<DialogTitle>Search docs</DialogTitle>
<DialogDescription>Search the Supabase documentation</DialogDescription>
@@ -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
</button>
@@ -185,7 +152,7 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) {
<LoadingBeam
isActive={isSearching}
direction={isDeleting ? 'backward' : 'forward'}
className={hasListContent ? '-bottom-px' : 'max-lg:-bottom-px'}
className={isListVisible ? '-bottom-px' : 'max-lg:-bottom-px'}
/>
</div>
{/*
@@ -198,21 +165,14 @@ function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) {
</div>
<CommandList
label="Search results"
className={cn(
'h-(--cmdk-list-height) max-h-[min(477px,70dvh)]',
'max-lg:h-auto max-lg:max-h-none max-lg:flex-1',
'shadow-[inset_0_1px_0_var(--border-default)] lg:mx-px',
'transition-[height] duration-150 ease-enter',
'motion-reduce:transition-none',
'scroll-fade-bottom'
)}
className="h-(--cmdk-list-height) max-h-[min(477px,70dvh)] max-lg:h-auto max-lg:max-h-none max-lg:flex-1 inset-shadow-2xs inset-shadow-border lg:mx-px transition-all duration-150 ease-enter motion-reduce:transition-none scroll-fade-bottom"
>
{searchState.status === 'noResults' && <CommandEmpty>No results found.</CommandEmpty>}
{searchState.status === 'error' && (
<CommandEmpty>Something went wrong. Please try again.</CommandEmpty>
)}
{results.length > 0 && (
<CommandGroup forceMount className="pt-1.25">
<CommandGroup className="pt-1.25">
{results.map((result) => (
<SearchV2Result
key={result.path}
@@ -249,81 +209,3 @@ function SearchV2Footer() {
</footer>
)
}
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<PointerOpen>('none')
function handleLinkClick(event: MouseEvent<HTMLAnchorElement>) {
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 (
<CommandItem
asChild
value={result.path}
forceMount
onSelect={handleSelect}
className="cursor-pointer rounded-md px-2 py-2 max-lg:px-4"
>
<Link href={result.path} prefetch={false} onClick={handleLinkClick}>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p
title={formatHeadingPath(result.headingPath)}
className="flex min-w-0 items-baseline gap-1.5 text-sm leading-5"
>
{parentHeadings.length > 0 ? (
<span className="min-w-0 truncate text-foreground-lighter">
{highlightMatches(`${formatHeadingPath(parentHeadings)} >`, highlightQuery)}
</span>
) : null}
<span className="max-w-full shrink-0 truncate font-medium text-foreground [&_strong]:font-semibold">
{highlightMatches(heading, highlightQuery)}
</span>
</p>
{result.excerpt ? (
<p
className={cn(
'line-clamp-2 text-pretty text-sm leading-5 text-foreground-lighter',
'[&_strong]:font-medium [&_strong]:text-foreground-light'
)}
>
{highlightMatches(result.excerpt, highlightQuery)}
</p>
) : null}
</div>
</Link>
</CommandItem>
)
}
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
}
@@ -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<HTMLAnchorElement>) {
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 (
<CommandItem
asChild
value={result.path}
onSelect={handleSelect}
className="cursor-pointer rounded-md px-2 py-2 max-lg:px-4"
>
<Link href={result.path} prefetch={false} tabIndex={-1} onClick={handleLinkClick}>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-sm leading-5 text-foreground">
{highlightMatches(formatHeadingPath(result.headingPath), highlightQuery)}
</p>
{result.excerpt ? (
<p className="line-clamp-2 text-pretty text-sm leading-5 text-foreground-lighter">
{highlightMatches(result.excerpt, highlightQuery)}
</p>
) : null}
</div>
</Link>
</CommandItem>
)
}
@@ -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',
+1
View File
@@ -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;
+4
View File
@@ -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;
}
+4 -1
View File
@@ -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(() => {