feat(ts): 实现 Polaris 可交互首页
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user