Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
6.2 KiB
TypeScript
218 lines
6.2 KiB
TypeScript
"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>
|
||
)
|
||
}
|