mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
feat(docs): update search v2 ui (#51175)
This commit is contained in:
1 parent
47fd296fc1
commit
dcf266c360
14 files changed
+425
-108
No files matched your search
@@ -51,7 +51,6 @@ const TopNavBar: FC = () => {
|
||||
<DevToolbarTrigger />
|
||||
{searchVariant === 'search-v2-active' ? (
|
||||
<SearchV2Trigger
|
||||
className="[&>div>p]:text-foreground-lighter"
|
||||
placeholder={
|
||||
<>
|
||||
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 }
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
hideClose
|
||||
centered={false}
|
||||
dialogOverlayProps={{
|
||||
className:
|
||||
'data-closed:animate-out! data-closed:fade-out-0 data-closed:fill-mode-forwards data-closed:duration-150 data-closed:ease-enter pt-20 sm:pt-20',
|
||||
}}
|
||||
size="large"
|
||||
className="overflow-hidden rounded-lg border-0 p-0 inset-ring inset-ring-border shadow-(--shadow-codeblock)!"
|
||||
>
|
||||
<SearchV2Panel onResultSelect={handleSelect} onResultOpen={handleResultOpen} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(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<HTMLButtonElement>) {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* hideClose: this is a search box, not a form — closing is Escape/click-outside only, no "X" */}
|
||||
<DialogContent hideClose size="large" className="overflow-hidden p-0 shadow-lg">
|
||||
<Command>
|
||||
<VisuallyHidden.VisuallyHidden>
|
||||
<DialogTitle>Search docs</DialogTitle>
|
||||
<DialogDescription>Search the Supabase documentation</DialogDescription>
|
||||
</VisuallyHidden.VisuallyHidden>
|
||||
<CommandInput
|
||||
placeholder="Search docs..."
|
||||
aria-label="Search the Supabase documentation"
|
||||
onValueChange={handleValueChange}
|
||||
wrapperClassName="[&_svg]:h-5 [&_svg]:w-5 border-0"
|
||||
className="h-14 text-base"
|
||||
/>
|
||||
{/*
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<VisuallyHidden.VisuallyHidden>
|
||||
<DialogTitle>Search docs</DialogTitle>
|
||||
<DialogDescription>Search the Supabase documentation</DialogDescription>
|
||||
</VisuallyHidden.VisuallyHidden>
|
||||
<div className="relative flex items-center gap-3 pr-4">
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
placeholder="Search docs..."
|
||||
aria-label="Search the Supabase documentation"
|
||||
onValueChange={handleValueChange}
|
||||
wrapperClassName="flex-1 border-0 pl-3 pr-0 text-foreground-lighter [&_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 ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={handleClear}
|
||||
onKeyDown={handleClearKeyDown}
|
||||
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>
|
||||
) : null}
|
||||
<LoadingBeam
|
||||
isActive={isSearching}
|
||||
direction={isDeleting ? 'backward' : 'forward'}
|
||||
className={isListVisible ? '-bottom-px' : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{getStatusMessage()}
|
||||
</div>
|
||||
<CommandList label="Search results">
|
||||
{searchState.status === 'initial' && (
|
||||
<CommandEmpty>Start typing to search the docs.</CommandEmpty>
|
||||
)}
|
||||
{searchState.status === 'loading' && results.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground-muted">
|
||||
<Loader2 className="animate-spin" size={14} aria-hidden="true" />
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
{searchState.status === 'noResults' && <CommandEmpty>No results found.</CommandEmpty>}
|
||||
{searchState.status === 'error' && (
|
||||
<CommandEmpty>Something went wrong. Please try again.</CommandEmpty>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<CommandGroup heading="Results" forceMount>
|
||||
{results.map((page) => (
|
||||
<CommandItem
|
||||
key={page.path}
|
||||
value={page.path}
|
||||
forceMount
|
||||
onSelect={() => handleSelect(page.path)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">
|
||||
{highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)}
|
||||
</span>
|
||||
{page.excerpt && (
|
||||
<span className="text-xs text-foreground-muted">
|
||||
{highlightMatches(page.excerpt, highlightQuery)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{getStatusMessage()}
|
||||
</div>
|
||||
<CommandList
|
||||
label="Search results"
|
||||
className="h-(--cmdk-list-height) max-h-[min(477px,70dvh)] inset-shadow-2xs inset-shadow-border 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 className="pt-1.25">
|
||||
{results.map((result) => (
|
||||
<SearchV2Result
|
||||
key={result.path}
|
||||
result={result}
|
||||
highlightQuery={highlightQuery}
|
||||
onResultSelect={handleSelect}
|
||||
onResultOpen={onResultOpen}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
{results.length > 0 ? <SearchV2Footer /> : null}
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchV2Footer() {
|
||||
return (
|
||||
<footer
|
||||
aria-hidden
|
||||
className="flex items-center justify-end gap-3 border-t px-4 py-2.5 text-xs text-foreground-lighter mx-px max-lg:hidden"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
Navigate
|
||||
<KeyboardShortcut keys={['ArrowUp']} />
|
||||
<KeyboardShortcut keys={['ArrowDown']} />
|
||||
</span>
|
||||
<span className="h-3 w-px bg-border" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
Open
|
||||
<KeyboardShortcut keys={['Enter']} />
|
||||
</span>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-1.5 text-foreground-lighter">
|
||||
<Search
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="group-hover:text-foreground-light transition-colors"
|
||||
/>
|
||||
<p className="flex text-xs pr-2 text-foreground-muted">{placeholder}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search aria-hidden className="size-4 shrink-0" strokeWidth={2.25} />
|
||||
<p className="flex pr-2 text-sm">{placeholder}</p>
|
||||
</div>
|
||||
<KeyboardShortcut
|
||||
keys={['Meta', 'k']}
|
||||
aria-hidden
|
||||
className="hidden md:inline-flex border border-default bg-surface-300 text-foreground-lighter shadow-xs shadow-background-surface-100"
|
||||
/>
|
||||
<KeyboardShortcut keys={['Meta', 'k']} aria-hidden className="hidden md:inline-flex" />
|
||||
</button>
|
||||
<SearchV2Dialog open={open} onOpenChange={handleOpenChange} />
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={run}
|
||||
aria-hidden
|
||||
data-active={isActive ? '' : undefined}
|
||||
data-direction={runDirection}
|
||||
className={cn('loading-beam', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
@property --loading-beam-x {
|
||||
syntax: '<number>';
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@property --scroll-fade-size {
|
||||
syntax: '<length>';
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, [])
|
||||
}
|
||||
Reference in new issue
Block a user