Files
supabase/apps/docs/features/SearchV2/SearchV2Dialog.tsx
T

212 lines
7.8 KiB
TypeScript

'use client'
import { LoadingBeam } from '~/features/ui/LoadingBeam'
import { useDocsSearchV2, type DocsSearchV2Result } from 'common'
import { useRouter } from 'next/navigation'
import { VisuallyHidden } from 'radix-ui'
import { useRef, useState, type KeyboardEvent } from 'react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
KeyboardShortcut,
} from 'ui'
import { getIsSearching } from './SearchV2.utils'
import { SearchV2Result } from './SearchV2Result'
interface SearchV2DialogProps {
open: boolean
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 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="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>
</Dialog>
)
}
function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) {
const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2()
const [query, setQuery] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// 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
? searchState.results
: 'staleResults' in searchState
? 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 {
resetSearch()
}
}
// Announced via the aria-live region below — a sighted user sees the spinner/list update,
// but a screen reader user gets no equivalent signal unless we say so explicitly. Also covers
// the result count, which isn't reliably announced by the listbox/option roles alone.
function getStatusMessage(): string {
if (searchState.status === 'loading') return 'Searching the docs…'
if (searchState.status === 'noResults') return 'No results found.'
if (searchState.status === 'error') return 'Something went wrong. Please try again.'
if (results.length > 0) {
return `${results.length} result${results.length === 1 ? '' : 's'} found.`
}
return ''
}
return (
<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>
</VisuallyHidden.VisuallyHidden>
<div className="relative flex items-center gap-3 pr-4 max-lg:pr-5">
<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 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 ? (
<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' : 'max-lg:-bottom-px'}
/>
</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"
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 className="pt-1.25">
{results.map((result) => (
<SearchV2Result
key={result.path}
result={result}
highlightQuery={highlightQuery}
onResultSelect={onResultSelect}
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 max-lg:hidden lg:mx-px"
>
<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>
)
}