mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(docs): keep search v2 result order and initial selection
This commit is contained in:
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', () => {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user