From dcf266c360ffcd3e2ba38291ccbbaa57b28007cc Mon Sep 17 00:00:00 2001 From: Anthony Lio Date: Mon, 5 Oct 2026 23:33:19 +0300 Subject: [PATCH] feat(docs): update search v2 ui (#51175) --- .../Navigation/NavigationMenu/TopNavBar.tsx | 1 - .../features/SearchV2/SearchV2.utils.test.tsx | 24 +- .../docs/features/SearchV2/SearchV2.utils.tsx | 30 ++- .../docs/features/SearchV2/SearchV2Dialog.tsx | 223 ++++++++++++------ .../docs/features/SearchV2/SearchV2Result.tsx | 61 +++++ .../features/SearchV2/SearchV2Trigger.tsx | 24 +- apps/docs/features/ui/LoadingBeam.tsx | 33 +++ apps/docs/styles/code-block.css | 1 + apps/docs/styles/globals.css | 2 + apps/docs/styles/loading-beam.css | 80 +++++++ apps/docs/styles/scroll-fade.css | 32 +++ packages/common/hooks/index.ts | 1 + packages/common/hooks/useDocsSearchV2.ts | 9 +- packages/common/hooks/useMountEffect.ts | 12 + 14 files changed, 425 insertions(+), 108 deletions(-) create mode 100644 apps/docs/features/SearchV2/SearchV2Result.tsx create mode 100644 apps/docs/features/ui/LoadingBeam.tsx create mode 100644 apps/docs/styles/loading-beam.css create mode 100644 apps/docs/styles/scroll-fade.css create mode 100644 packages/common/hooks/useMountEffect.ts diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 1c06cc8b765..451bb8b77b0 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -51,7 +51,6 @@ const TopNavBar: FC = () => { {searchVariant === 'search-v2-active' ? ( Search 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 d475a107065..e0ffd7f79c7 100644 --- a/apps/docs/features/SearchV2/SearchV2Dialog.tsx +++ b/apps/docs/features/SearchV2/SearchV2Dialog.tsx @@ -1,10 +1,10 @@ 'use client' +import { LoadingBeam } from '~/features/ui/LoadingBeam' import { useDocsSearchV2, type DocsSearchV2Result } from 'common' -import { Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { VisuallyHidden } from 'radix-ui' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, type KeyboardEvent } from 'react' import { Command, CommandEmpty, @@ -16,9 +16,11 @@ import { DialogContent, DialogDescription, DialogTitle, + KeyboardShortcut, } from 'ui' -import { formatHeadingPath, highlightMatches } from './SearchV2.utils' +import { getIsSearching } from './SearchV2.utils' +import { SearchV2Result } from './SearchV2Result' import { useSendTelemetryEvent } from '@/lib/telemetry' interface SearchV2DialogProps { @@ -26,28 +28,62 @@ interface SearchV2DialogProps { onOpenChange: (open: boolean) => void } +interface SearchV2PanelProps { + onResultSelect: (path: string) => void + onResultOpen: () => void +} + export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { const router = useRouter() + + function handleSelect(path: string) { + router.push(path) + onOpenChange(false) + } + + function handleResultOpen() { + onOpenChange(false) + } + + return ( + + + + + + ) +} + +function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { const sendTelemetryEvent = useSendTelemetryEvent() const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2() - const [highlightQuery, setHighlightQuery] = useState('') + const [query, setQuery] = useState('') + const [isDeleting, setIsDeleting] = useState(false) + const inputRef = useRef(null) - // Clear stale results once the dialog closes - useEffect(() => { - if (!open) resetSearch() - }, [open, resetSearch]) + // 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 + : '' - // 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. useEffect(() => { if (searchState.status === 'results' || searchState.status === 'noResults') { - setHighlightQuery(searchState.query) sendTelemetryEvent({ action: 'docs_search_v2_search_submitted', properties: { query: searchState.query }, }) - } else if (searchState.status === 'initial') { - setHighlightQuery('') } }, [searchState, sendTelemetryEvent]) @@ -58,7 +94,25 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { ? searchState.staleResults : [] + const isListVisible = + results.length > 0 || searchState.status === 'noResults' || searchState.status === 'error' + const isSearching = getIsSearching({ searchState, query }) + + function handleClear() { + setQuery('') + resetSearch() + 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) { + if (event.key === 'Enter') event.stopPropagation() + } + function handleValueChange(value: string) { + setIsDeleting(value.length < query.length) + setQuery(value) if (value) { handleDocsSearchDebounced(value) } else { @@ -71,8 +125,7 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { action: 'docs_search_v2_result_clicked', properties: { resultPath: path, query: highlightQuery }, }) - router.push(path) - onOpenChange(false) + onResultSelect(path) } // Announced via the aria-live region below — a sighted user sees the spinner/list update, @@ -89,69 +142,89 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { } return ( - - {/* hideClose: this is a search box, not a form — closing is Escape/click-outside only, no "X" */} - - - - Search docs - Search the Supabase documentation - - - {/* + + + Search docs + Search the Supabase documentation + +
+ + {query ? ( + + ) : null} + +
+ {/* Screen-reader-only status announcement. Focus stays in the input as results come in (that's what lets people keep typing), so nothing else here gets read aloud on its own — this is what tells a screen reader user a search ran and how many results it found. */} -
- {getStatusMessage()} -
- - {searchState.status === 'initial' && ( - Start typing to search the docs. - )} - {searchState.status === 'loading' && results.length === 0 && ( -
-
- )} - {searchState.status === 'noResults' && No results found.} - {searchState.status === 'error' && ( - Something went wrong. Please try again. - )} - {results.length > 0 && ( - - {results.map((page) => ( - handleSelect(page.path)} - > -
- - {highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)} - - {page.excerpt && ( - - {highlightMatches(page.excerpt, highlightQuery)} - - )} -
-
- ))} -
- )} -
-
-
-
+
+ {getStatusMessage()} +
+ + {searchState.status === 'noResults' && No results found.} + {searchState.status === 'error' && ( + Something went wrong. Please try again. + )} + {results.length > 0 && ( + + {results.map((result) => ( + + ))} + + )} + + {results.length > 0 ? : null} + + ) +} + +function SearchV2Footer() { + return ( +
+ + Navigate + + + + + + Open + + +
) } diff --git a/apps/docs/features/SearchV2/SearchV2Result.tsx b/apps/docs/features/SearchV2/SearchV2Result.tsx new file mode 100644 index 00000000000..d19147e9d8c --- /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 21d9c73cda2..fadb6e383cb 100644 --- a/apps/docs/features/SearchV2/SearchV2Trigger.tsx +++ b/apps/docs/features/SearchV2/SearchV2Trigger.tsx @@ -66,30 +66,22 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search }) }} className={cn( - 'group cursor-pointer', + 'cursor-pointer', 'grow md:min-w-44 xl:min-w-56 h-[30px] rounded-md', - 'pl-1.5 md:pl-2 pr-1', + 'pl-2 pr-1', 'flex items-center justify-between', - 'bg-transparent text-foreground-lighter border border-strong', - 'hover:bg-popover hover:border-control-hover', + 'border border-default bg-surface-75 text-foreground-lighter shadow-(--shadow-codeblock)', + 'hover:border-strong hover:text-foreground-light', 'focus-ring', 'transition-colors', className )} > -
- -

{placeholder}

+
+ +

{placeholder}

- + diff --git a/apps/docs/features/ui/LoadingBeam.tsx b/apps/docs/features/ui/LoadingBeam.tsx new file mode 100644 index 00000000000..e9dd73faf67 --- /dev/null +++ b/apps/docs/features/ui/LoadingBeam.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useState } from 'react' +import { cn } from 'ui' + +export interface LoadingBeamProps { + isActive: boolean + direction?: 'forward' | 'backward' + className?: string +} + +export const LoadingBeam = ({ isActive, direction = 'forward', className }: LoadingBeamProps) => { + const [run, setRun] = useState(0) + const [runDirection, setRunDirection] = useState(direction) + const [wasActive, setWasActive] = useState(isActive) + if (isActive !== wasActive) { + setWasActive(isActive) + if (isActive) { + setRun((current) => current + 1) + setRunDirection(direction) + } + } + + return ( +
+ ) +} 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/globals.css b/apps/docs/styles/globals.css index 2a39bcb51c0..82d4d622f81 100644 --- a/apps/docs/styles/globals.css +++ b/apps/docs/styles/globals.css @@ -1,6 +1,8 @@ @import 'config/tailwind.config.css'; @import './code-block.css'; @import './reference.css'; +@import './loading-beam.css'; +@import './scroll-fade.css'; @source '../app/**/*.{ts,tsx,mdx}'; @source '../components/**/*.tsx'; diff --git a/apps/docs/styles/loading-beam.css b/apps/docs/styles/loading-beam.css new file mode 100644 index 00000000000..c82758df11f --- /dev/null +++ b/apps/docs/styles/loading-beam.css @@ -0,0 +1,80 @@ +@property --loading-beam-x { + syntax: ''; + initial-value: 0.5; + inherits: true; +} + +@keyframes loading-beam-sweep { + 0%, + 100% { + --loading-beam-x: 0; + } + 50% { + --loading-beam-x: 1; + } +} + +@layer components { + .loading-beam { + --loading-beam-color: var(--foreground-default); + --loading-beam-half: 100px; + --loading-beam-center: calc( + var(--loading-beam-half) + var(--loading-beam-x) * (100% - var(--loading-beam-half) * 2) + ); + position: absolute; + inset: auto 0 0; + height: 16px; + overflow: hidden; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease-out; + 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; + } + + .loading-beam[data-active] { + opacity: 1; + transition-duration: 250ms; + animation-play-state: running; + } + + .loading-beam::before, + .loading-beam::after { + content: ''; + position: absolute; + } + + .loading-beam::before { + inset: 0 0 -8px; + filter: blur(4px); + background: radial-gradient( + 120px 12px at var(--loading-beam-center) calc(100% - 8px), + color-mix(in oklch, var(--loading-beam-color) 3.2%, transparent), + transparent + ); + } + + .loading-beam::after { + inset: auto 0 0; + height: 1px; + background: radial-gradient( + var(--loading-beam-half) 4px at var(--loading-beam-center) 100%, + color-mix(in oklch, var(--loading-beam-color) 44.5%, transparent), + color-mix(in oklch, var(--loading-beam-color) 16%, transparent) 45%, + transparent + ); + } + + @media (prefers-reduced-motion: reduce) { + .loading-beam { + animation: none; + } + } +} diff --git a/apps/docs/styles/scroll-fade.css b/apps/docs/styles/scroll-fade.css new file mode 100644 index 00000000000..e7525d8dd83 --- /dev/null +++ b/apps/docs/styles/scroll-fade.css @@ -0,0 +1,32 @@ +@property --scroll-fade-size { + syntax: ''; + initial-value: 0px; + inherits: false; +} + +@keyframes scroll-fade-bottom { + 0%, + 85% { + --scroll-fade-size: 24px; + } + 100% { + --scroll-fade-size: 0px; + } +} + +@layer components { + @supports (animation-timeline: scroll()) { + .scroll-fade-bottom { + mask-image: linear-gradient( + to bottom, + #000 calc(100% - var(--scroll-fade-size)), + rgb(0 0 0 / 0.8) calc(100% - var(--scroll-fade-size) * 0.75), + rgb(0 0 0 / 0.5) calc(100% - var(--scroll-fade-size) * 0.5), + rgb(0 0 0 / 0.2) calc(100% - var(--scroll-fade-size) * 0.25), + transparent 100% + ); + animation: scroll-fade-bottom linear both; + animation-timeline: scroll(self); + } + } +} diff --git a/packages/common/hooks/index.ts b/packages/common/hooks/index.ts index 23bbecba6a7..93b1984adde 100644 --- a/packages/common/hooks/index.ts +++ b/packages/common/hooks/index.ts @@ -11,6 +11,7 @@ export * from './useDocsSearchV2' export * from './useDragToClose' export * from './useEffectEvent' export * from './useIsomorphicLayoutEffect' +export * from './useMountEffect' export * from './useOnChange' export * from './useParams' export * from './useSearchParamsShallow' diff --git a/packages/common/hooks/useDocsSearchV2.ts b/packages/common/hooks/useDocsSearchV2.ts index 57ecd61aeeb..26af620d52d 100644 --- a/packages/common/hooks/useDocsSearchV2.ts +++ b/packages/common/hooks/useDocsSearchV2.ts @@ -3,6 +3,8 @@ import { compact, debounce } from 'lodash' import { useCallback, useMemo, useReducer, useRef } from 'react' +import { useMountEffect } from './useMountEffect' + // This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs'). const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' // Public URL of the docs deployment, which hosts the search API routes. @@ -21,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 } @@ -74,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 } @@ -122,6 +126,9 @@ 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(() => { debounceCancel() key.current += 1 diff --git a/packages/common/hooks/useMountEffect.ts b/packages/common/hooks/useMountEffect.ts new file mode 100644 index 00000000000..8bf47840b61 --- /dev/null +++ b/packages/common/hooks/useMountEffect.ts @@ -0,0 +1,12 @@ +'use client' + +import { useEffect, type EffectCallback } from 'react' + +/** + * runs an effect once on mount, with its cleanup on unmount, the escape hatch for one-time + * sync with an external system and derive state or use event handlers everywhere else + */ +export function useMountEffect(effect: EffectCallback): void { + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(effect, []) +}