Files
lab/ts/web/nextjs/polaris/src/features/home/components/search-box.tsx
T
2026-09-02 23:53:28 +08:00

218 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { Search } from "lucide-react"
import {
useEffect,
useId,
useMemo,
useRef,
useState,
useSyncExternalStore,
type FormEvent,
type KeyboardEvent,
} from "react"
import { LocalSearchResults } from "@/features/home/components/local-search-results"
import { SearchProviderMenu } from "@/features/home/components/search-provider-menu"
import { searchLocalLinks } from "@/features/home/lib/local-search"
import {
parseStoredProvider,
SEARCH_PROVIDER_STORAGE_KEY,
} from "@/features/home/lib/provider-preference"
import { buildSearchUrl } from "@/features/home/lib/search-providers"
import type { Category, NavLink } from "@/types/navigation"
import type { SearchProvider } from "@/types/search"
type SearchBoxProps = {
links: NavLink[]
categories: Category[]
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
onExternalNavigate?: (url: string) => void
}
function subscribeHydration() {
return () => undefined
}
function getClientHydrationSnapshot() {
return true
}
function getServerHydrationSnapshot() {
return false
}
function readStoredProvider(
defaultProvider: SearchProvider,
enabledProviders: SearchProvider[],
): SearchProvider {
try {
const storedProvider = parseStoredProvider(
window.localStorage.getItem(SEARCH_PROVIDER_STORAGE_KEY),
defaultProvider,
)
return enabledProviders.includes(storedProvider)
? storedProvider
: defaultProvider
} catch {
return defaultProvider
}
}
export function SearchBox({
links,
categories,
defaultProvider,
enabledProviders,
onExternalNavigate,
}: SearchBoxProps) {
const inputRef = useRef<HTMLInputElement>(null)
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([])
const listboxId = useId()
const [query, setQuery] = useState("")
const [selectedProvider, setSelectedProvider] =
useState<SearchProvider | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const hydrated = useSyncExternalStore(
subscribeHydration,
getClientHydrationSnapshot,
getServerHydrationSnapshot,
)
// Server 与 hydration 首帧保持默认 Provider;hydration 后再容错读取浏览器偏好。
const restoredProvider = hydrated
? readStoredProvider(defaultProvider, enabledProviders)
: defaultProvider
const provider = selectedProvider ?? restoredProvider
const results = useMemo(
() =>
provider === "local" ? searchLocalLinks(query, links, categories, 8) : [],
[categories, links, provider, query],
)
const panelOpen = provider === "local" && query.trim().length > 0
const resolvedActiveIndex =
results.length > 0 ? Math.min(activeIndex, results.length - 1) : 0
const activeOptionId =
panelOpen && results.length > 0
? `${listboxId}-option-${resolvedActiveIndex}`
: undefined
const shortcutHint =
hydrated && navigator.userAgent.includes("Mac") ? "⌘K" : "Ctrl K"
useEffect(() => {
inputRef.current?.focus()
const focusSearch = (event: globalThis.KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener("keydown", focusSearch)
return () => window.removeEventListener("keydown", focusSearch)
}, [])
const changeProvider = (nextProvider: SearchProvider) => {
setSelectedProvider(nextProvider)
setActiveIndex(0)
try {
window.localStorage.setItem(SEARCH_PROVIDER_STORAGE_KEY, nextProvider)
} catch {
// Provider 已保留在当前组件状态,存储受限时不影响本次交互。
}
}
const openActiveLocalResult = () => {
if (results.length === 0) return
// 复用真实 anchor 的导航语义,键盘 Enter 不另造 click-only 跳转路径。
optionRefs.current[resolvedActiveIndex]?.click()
}
const submitSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
// Local 只打开站内结果;外部 Provider 只构建搜索 URL,两条路径严格分离。
if (provider === "local") {
openActiveLocalResult()
return
}
const url = buildSearchUrl(provider, query)
if (!url) return
if (onExternalNavigate) onExternalNavigate(url)
else window.location.assign(url)
}
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
event.preventDefault()
setQuery("")
setActiveIndex(0)
return
}
if (provider !== "local") return
if (event.key === "Enter") {
event.preventDefault()
openActiveLocalResult()
return
}
if (results.length === 0) return
if (event.key === "ArrowDown") {
event.preventDefault()
setActiveIndex((index) => (index + 1) % results.length)
} else if (event.key === "ArrowUp") {
event.preventDefault()
setActiveIndex((index) => (index - 1 + results.length) % results.length)
}
}
return (
<div className="search-box">
<form className="search-box__form" role="search" onSubmit={submitSearch}>
<SearchProviderMenu
provider={provider}
enabledProviders={enabledProviders}
onProviderChange={changeProvider}
/>
<Search className="search-box__icon" aria-hidden="true" />
<input
ref={inputRef}
className="search-box__input"
role="combobox"
aria-label="搜索网站、书签或关键词"
aria-autocomplete="list"
aria-expanded={panelOpen}
aria-controls={panelOpen ? listboxId : undefined}
aria-activedescendant={activeOptionId}
placeholder="搜索网站、书签或关键词..."
value={query}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
}}
onKeyDown={handleInputKeyDown}
/>
<kbd className="search-box__shortcut" aria-hidden="true">
{shortcutHint}
</kbd>
</form>
{panelOpen ? (
<LocalSearchResults
listboxId={listboxId}
results={results}
activeIndex={resolvedActiveIndex}
optionRefs={optionRefs}
onActiveIndexChange={setActiveIndex}
/>
) : null}
</div>
)
}