feat(ts): 实现 Polaris 可交互首页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 23:53:28 +08:00
co-authored by Claude Opus 4.8
parent 95c3da0f71
commit c3d2a40c1f
71 changed files with 15613 additions and 46 deletions
@@ -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>
)
}