feat(docs): add bilingual package documentation site

This commit is contained in:
Maofeng
2026-09-20 15:53:28 +08:00
parent 60f6f1fb1a
commit dc80bd8ae0
111 changed files with 6531 additions and 0 deletions
View File
@@ -0,0 +1,179 @@
import { I18nProvider } from "@workspace/i18n"
import {
SearchDialog,
SearchProvider,
type SearchAdapter,
type SearchResultItem,
} from "@workspace/search"
import { messages as searchMessagesEnUS } from "@workspace/search/locales/en-US"
import { messages as searchMessagesZhHans } from "@workspace/search/locales/zh-Hans"
import React from "react"
import {
getDocHref,
getDocPackages,
getDocSearchText,
getDocSectionLabel,
} from "../content/registry"
import type {
DocLocale,
DocPackage,
DocPage,
DocTocItem,
} from "../content/types"
import { useLocale } from "../lib/locale"
const searchCatalogs = {
"en-US": searchMessagesEnUS,
"zh-Hans": searchMessagesZhHans,
} as const
type DocsSearchPayload = {
href: string
}
export function DocsSearchProvider({
children,
}: {
children: React.ReactNode
}) {
const locale = useLocale()
const adapter = React.useMemo(() => createDocsSearchAdapter(locale), [locale])
return (
<I18nProvider catalogs={searchCatalogs} locale={locale}>
<SearchProvider
adapter={adapter}
historyStorageKey={`docs-search-history:${locale}`}
onSelect={({ item }) => {
if (isDocsSearchPayload(item.payload)) navigate(item.payload.href)
}}
>
{children}
<SearchDialog className="font-sans" />
</SearchProvider>
</I18nProvider>
)
}
function createDocsSearchAdapter(locale: DocLocale): SearchAdapter {
const docPackages = getDocPackages(locale)
return {
async search({ query, signal }) {
const normalizedQuery = normalize(query)
if (!normalizedQuery) return { groups: [] }
const terms = normalizedQuery.split(/\s+/).filter(Boolean)
const groups = docPackages.flatMap((docPackage) => {
const items = docPackage.pages
.map((page) => ({
item: createResultItem(docPackage, page, locale),
score: getMatchScore(
normalizedQuery,
terms,
docPackage.label,
page
),
}))
.filter((result) => result.score >= 0)
.sort(
(left, right) =>
right.score - left.score ||
left.item.title.localeCompare(right.item.title, locale)
)
.map((result) => result.item)
return items.length > 0
? [
{
id: docPackage.key,
label: docPackage.label,
items,
total: items.length,
},
]
: []
})
if (signal.aborted) return { groups: [] }
return { groups }
},
}
}
function createResultItem(
docPackage: DocPackage,
page: DocPage,
locale: DocLocale
): SearchResultItem {
return {
id: `${page.section}/${page.slug}`,
title: page.title,
description: page.description,
keywords: [
docPackage.label,
getDocSectionLabel(page.section, locale),
...page.toc.flatMap(flattenTocTitles),
getDocSearchText(page),
],
meta: [getDocSectionLabel(page.section, locale)],
payload: {
href: getDocHref(docPackage, page),
} satisfies DocsSearchPayload,
}
}
function getMatchScore(
query: string,
terms: readonly string[],
packageLabel: string,
page: DocPage
) {
const title = normalize(page.title)
const description = normalize(page.description)
const packageName = normalize(packageLabel)
const toc = normalize(page.toc.flatMap(flattenTocTitles).join(" "))
const body = normalize(getDocSearchText(page))
const searchableText = [packageName, title, description, toc, body].join(" ")
if (!terms.every((term) => searchableText.includes(term))) return -1
let score = 0
if (title === query) score += 100
else if (title.startsWith(query)) score += 70
else if (title.includes(query)) score += 50
if (packageName === query) score += 40
else if (packageName.includes(query)) score += 20
if (description.includes(query)) score += 15
if (toc.includes(query)) score += 10
if (page.section === "overview") score += 2
return score
}
function flattenTocTitles(item: DocTocItem): readonly string[] {
return [item.title, ...(item.items?.flatMap(flattenTocTitles) ?? [])]
}
function normalize(value: string) {
return value.normalize("NFKC").toLocaleLowerCase().trim()
}
function isDocsSearchPayload(value: unknown): value is DocsSearchPayload {
return (
typeof value === "object" &&
value !== null &&
"href" in value &&
typeof value.href === "string"
)
}
function navigate(href: string) {
const destination = new URL(href, window.location.href)
if (destination.href === window.location.href) return
window.history.pushState(null, "", destination)
window.scrollTo({ left: 0, top: 0 })
window.dispatchEvent(new PopStateEvent("popstate"))
}
+264
View File
@@ -0,0 +1,264 @@
import { Icon } from "@icones/react"
import { usePreference } from "@workspace/preferences"
import { useEffect, useId, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { LocaleFlag } from "./locale-flag"
import { getLocalizedHref } from "../content/registry"
import type { DocLocale } from "../content/types"
import { useLocale, useTranslate } from "../lib/locale"
import { useIsMobile } from "../lib/use-mobile"
type LocaleOption = {
label: string
locale: DocLocale
}
const localeOptions: readonly LocaleOption[] = [
{ label: "简体中文", locale: "zh-Hans" },
{ label: "English", locale: "en-US" },
]
export function LanguageSwitcher() {
const locale = useLocale()
const t = useTranslate()
const isMobile = useIsMobile()
const [, setLocaleMode] = usePreference("locale-mode")
const [, setPreferredLocale] = usePreference("locale")
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
const selectedLocale = getLocaleOption(locale)
useEffect(() => {
if (!open) return
function isInsideSwitcher(target: EventTarget | null) {
return (
target instanceof Node &&
(rootRef.current?.contains(target) || menuRef.current?.contains(target))
)
}
function closeAndRestoreFocus() {
setOpen(false)
window.requestAnimationFrame(() => triggerRef.current?.focus())
}
function handlePointerDown(event: PointerEvent) {
if (isInsideSwitcher(event.target)) return
if (isMobile) {
closeAndRestoreFocus()
} else {
setOpen(false)
}
}
function handleFocusIn(event: FocusEvent) {
if (!isMobile && !isInsideSwitcher(event.target)) setOpen(false)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault()
closeAndRestoreFocus()
return
}
if (!isMobile || event.key !== "Tab") return
const items = getMenuItems(menuRef.current)
const firstItem = items[0]
const lastItem = items.at(-1)
if (!firstItem || !lastItem) return
if (event.shiftKey && document.activeElement === firstItem) {
event.preventDefault()
lastItem.focus()
} else if (!event.shiftKey && document.activeElement === lastItem) {
event.preventDefault()
firstItem.focus()
}
}
const previousOverflow = document.body.style.overflow
if (isMobile) {
document.body.style.overflow = "hidden"
window.requestAnimationFrame(() => {
menuRef.current
?.querySelector<HTMLElement>('[aria-checked="true"]')
?.focus()
})
}
document.addEventListener("pointerdown", handlePointerDown)
document.addEventListener("focusin", handleFocusIn)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown)
document.removeEventListener("focusin", handleFocusIn)
document.removeEventListener("keydown", handleKeyDown)
if (isMobile) document.body.style.overflow = previousOverflow
}
}, [isMobile, open])
function focusMenuItem(position: "first" | "last") {
setOpen(true)
window.requestAnimationFrame(() => {
const items = getMenuItems(menuRef.current)
const item = position === "first" ? items[0] : items.at(-1)
item?.focus()
})
}
function selectLocale(nextLocale: DocLocale) {
setPreferredLocale(nextLocale)
setLocaleMode("manual")
setOpen(false)
window.requestAnimationFrame(() => triggerRef.current?.focus())
}
const menu = (
<div
ref={menuRef}
id={menuId}
role={isMobile ? "dialog" : undefined}
aria-label={t({ zh: "语言", us: "Language" })}
aria-modal={isMobile || undefined}
className="w-[min(80vw,20rem)] rounded-xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur-md md:w-auto md:min-w-40 md:p-1 dark:border-slate-800 dark:bg-slate-950/95 dark:shadow-black/60"
>
<p className="px-2 py-2 text-sm text-slate-500 md:text-xs dark:text-slate-400">
{t({ zh: "语言", us: "Language" })}
</p>
<ul
role="menu"
aria-label={t({ zh: "语言", us: "Languages" })}
onKeyDown={(event) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Home" &&
event.key !== "End"
) {
return
}
event.preventDefault()
const items = getMenuItems(event.currentTarget)
const currentIndex = items.indexOf(
document.activeElement as HTMLButtonElement
)
let nextIndex = currentIndex
if (event.key === "Home") nextIndex = 0
if (event.key === "End") nextIndex = items.length - 1
if (event.key === "ArrowDown") {
nextIndex = (currentIndex + 1) % items.length
}
if (event.key === "ArrowUp") {
nextIndex = (currentIndex - 1 + items.length) % items.length
}
items[nextIndex]?.focus()
}}
>
{localeOptions.map((option) => {
const selected = option.locale === locale
return (
<li role="none" key={option.locale}>
<a
role="menuitemradio"
aria-checked={selected}
lang={option.locale}
href={getLocalizedHref(window.location.pathname, option.locale)}
className="flex h-11 w-full cursor-pointer items-center gap-2 rounded-lg px-2 text-left whitespace-nowrap transition-colors hover:bg-slate-100 focus:bg-slate-100 focus:outline-none aria-checked:text-indigo-600 dark:hover:bg-slate-900 dark:focus:bg-slate-900 dark:aria-checked:text-indigo-400"
onClick={() => selectLocale(option.locale)}
>
<LocaleFlag
locale={option.locale}
className="size-5 shrink-0"
/>
<span>{option.label}</span>
{selected && (
<Icon
name="tabler:check"
className="ml-auto size-4 shrink-0"
/>
)}
</a>
</li>
)
})}
</ul>
</div>
)
return (
<div ref={rootRef} className="flex items-center md:relative">
<button
ref={triggerRef}
type="button"
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup={isMobile ? "dialog" : "menu"}
aria-label={t({ zh: "选择语言", us: "Choose language" })}
title={t({ zh: "选择语言", us: "Choose language" })}
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors outline-none hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950"
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault()
focusMenuItem("first")
} else if (event.key === "ArrowUp") {
event.preventDefault()
focusMenuItem("last")
}
}}
>
<LocaleFlag locale={selectedLocale.locale} className="size-7" />
</button>
{open &&
(isMobile ? (
createPortal(
<div className="fixed inset-0 z-100 flex items-center justify-center p-4">
<button
type="button"
tabIndex={-1}
aria-label={t({
zh: "关闭语言菜单",
us: "Close language menu",
})}
className="absolute inset-0 cursor-default bg-black/60 backdrop-blur-sm"
onClick={() => {
setOpen(false)
window.requestAnimationFrame(() =>
triggerRef.current?.focus()
)
}}
/>
<div className="relative">{menu}</div>
</div>,
document.body
)
) : (
<div className="absolute top-full right-0 z-100 pt-2">{menu}</div>
))}
</div>
)
}
function getMenuItems(root: ParentNode | null) {
return Array.from(
root?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []
)
}
function getLocaleOption(locale: DocLocale) {
return localeOptions.find((option) => option.locale === locale)!
}
+94
View File
@@ -0,0 +1,94 @@
import { Icon } from "@icones/react"
import { cn } from "../lib/utils"
import { useTranslate } from "../lib/locale"
import React from "react"
import { SearchTrigger } from "./search-trigger"
import { SettingsLink } from "./settings-link"
// oxlint-disable-next-line react/only-export-components -- Shared layout tokens are consumed by App.
export const rootCSSVariables = {
"--cw": "60rem",
"--max-lw": "18rem",
"--max-rw": "15rem",
"--min-lw": "12rem",
"--min-rw": "10rem",
"--w": "calc(var(--max-lw) + var(--cw) + var(--max-rw))",
"--page-pl": "calc(2rem + max(0px, (100vw - var(--w)) * 6 / 11 - 1px))",
"--page-pr": "calc(2rem + max(0px, 100vw - var(--w)) * 5 / 11)",
} as const as React.CSSProperties
export function Container({ className, ...rest }: React.ComponentProps<"div">) {
return (
<div
className={cn("px-4 md:pr-(--page-pr) md:pl-(--page-pl)", className)}
{...rest}
/>
)
}
export function HomeButton({
href = "/",
label,
}: {
href?: string
label?: string
}) {
const t = useTranslate()
return (
<a
href={href}
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400"
>
<Icon name="tabler:smart-home" className="size-5" />
{label ?? t({ zh: "首页", us: "Home" })}
</a>
)
}
export function BackButton({
href,
children,
}: {
href?: string
children?: React.ReactNode
}) {
const t = useTranslate()
function handleClick(e: React.MouseEvent) {
if (!href) {
e.preventDefault()
history.back()
}
}
return (
<a
href={href ?? "/"}
onClick={href ? undefined : handleClick}
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400"
>
<span className="inline-flex h-6 w-4 items-center justify-center rounded-xl bg-slate-200 dark:bg-slate-800">
<Icon name="tabler:chevron-compact-left" className="size-4" />
</span>
{children ?? <span>{t({ zh: "返回", us: "Back" })}</span>}
</a>
)
}
export function SlashLine({ className }: { className?: string }) {
return (
<div
className={cn("mx-2 h-4 w-px rotate-30 bg-current opacity-35", className)}
/>
)
}
export function Actions({ className }: { className?: string }) {
return (
<div className={cn("inline-flex items-center gap-2", className)}>
<SearchTrigger />
<SettingsLink />
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { Icon } from "@icones/react"
import type { DocLocale } from "../content/types"
export function LocaleFlag({
className,
locale,
}: {
className?: string
locale: DocLocale
}) {
return locale === "zh-Hans" ? (
<Icon name="flag:cn-circle" className={className} />
) : (
<Icon name="flag:us-circle" className={className} />
)
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, type ReactNode } from "react"
import type { DocLocale } from "../content/types"
import { LocaleContext } from "../lib/locale"
export function LocaleProvider({
children,
locale,
}: {
children: ReactNode
locale: DocLocale
}) {
useEffect(() => {
document.documentElement.lang = locale
}, [locale])
return (
<LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>
)
}
+93
View File
@@ -0,0 +1,93 @@
import type { MdxComponents } from "../content/types"
/* oxlint-disable react/only-export-components -- This module exports an MDX component registry. */
export const mdxComponents: MdxComponents = {
a: MdxAnchor,
blockquote: MdxBlockquote,
code: MdxCode,
h2: MdxHeading2,
h3: MdxHeading3,
pre: MdxPre,
}
function MdxHeading2({ children, id, ...props }: React.ComponentProps<"h2">) {
return (
<h2
id={id}
className="group scroll-mt-24 pt-7 text-2xl font-semibold tracking-tight text-slate-950 first:pt-0 dark:text-slate-100"
{...props}
>
{id ? (
<a className="text-inherit no-underline" href={`#${id}`}>
{children}
<span className="ml-2 text-indigo-500 opacity-0 transition-opacity group-hover:opacity-100 dark:text-indigo-400">
#
</span>
</a>
) : (
children
)}
</h2>
)
}
function MdxHeading3({ children, id, ...props }: React.ComponentProps<"h3">) {
return (
<h3
id={id}
className="group scroll-mt-24 pt-5 text-xl font-semibold tracking-tight text-slate-950 dark:text-slate-100"
{...props}
>
{id ? (
<a className="text-inherit no-underline" href={`#${id}`}>
{children}
<span className="ml-2 text-indigo-500 opacity-0 transition-opacity group-hover:opacity-100 dark:text-indigo-400">
#
</span>
</a>
) : (
children
)}
</h3>
)
}
function MdxAnchor({ className, ...props }: React.ComponentProps<"a">) {
return (
<a
className={`font-medium text-indigo-600 underline decoration-indigo-300 underline-offset-4 transition-colors hover:text-indigo-500 dark:text-indigo-400 dark:decoration-indigo-800 dark:hover:text-indigo-300 ${className ?? ""}`}
{...props}
/>
)
}
function MdxBlockquote({
className,
...props
}: React.ComponentProps<"blockquote">) {
return (
<blockquote
className={`rounded-r-lg border-l-2 border-indigo-500 bg-indigo-50 px-4 py-3 text-slate-700 dark:border-indigo-400 dark:bg-indigo-950/30 dark:text-slate-300 ${className ?? ""}`}
{...props}
/>
)
}
function MdxCode({ className, ...props }: React.ComponentProps<"code">) {
return (
<code
className={`rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.9em] text-slate-900 dark:bg-slate-900 dark:text-slate-100 ${className ?? ""}`}
{...props}
/>
)
}
function MdxPre({ className, ...props }: React.ComponentProps<"pre">) {
return (
<pre
className={`overflow-x-auto rounded-xl border border-slate-200 bg-slate-950 p-4 font-mono text-sm leading-6 text-slate-100 dark:border-slate-800 dark:bg-black/40 [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-inherit ${className ?? ""}`}
{...props}
/>
)
}
/* oxlint-enable react/only-export-components */
@@ -0,0 +1,228 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from "react"
import {
PreferencesProvider,
usePreference,
usePreferences,
type PreferenceChangeHandler,
type PreferenceUpdate,
type Preferences,
} from "@workspace/preferences"
import type { DocLocale } from "../content/types"
import { navigateToLocale } from "../lib/locale-navigation"
import {
detectLocale,
docsPreferenceEffects,
isDocsPreferenceUpdate,
parseDocsPreferences,
persistDocsPreference,
readDocsPreferences,
} from "../lib/preferences"
const preferencesChannelName = "docs-preferences-sync"
type PreferencesMessage =
| { type: "request" }
| { preferences: unknown; type: "snapshot" }
| { type: "update"; update: unknown }
export function DocsPreferencesProvider({
children,
routeLocale,
}: {
children: ReactNode
routeLocale: DocLocale
}) {
const [initialPreferences] = useState(() => readDocsPreferences(routeLocale))
const channelRef = useRef<BroadcastChannel>(null)
const applyingExternalUpdateRef = useRef(false)
const handlePreferenceChange = useCallback<PreferenceChangeHandler>(
(update) => {
persistDocsPreference(update)
if (!applyingExternalUpdateRef.current) {
channelRef.current?.postMessage({ type: "update", update })
}
},
[]
)
return (
<PreferencesProvider
effects={docsPreferenceEffects}
initialPreferences={initialPreferences}
onPreferenceChange={handlePreferenceChange}
>
<PreferencesRuntime
applyingExternalUpdateRef={applyingExternalUpdateRef}
channelRef={channelRef}
routeLocale={routeLocale}
/>
{children}
</PreferencesProvider>
)
}
function PreferencesRuntime({
applyingExternalUpdateRef,
channelRef,
routeLocale,
}: {
applyingExternalUpdateRef: MutableRefObject<boolean>
channelRef: MutableRefObject<BroadcastChannel | null>
routeLocale: DocLocale
}) {
const preferences = usePreferences()
const preferencesRef = useRef(preferences)
const [, setAccentColor] = usePreference("accent-color")
const [, setBackgroundShade] = usePreference("background-shade")
const [, setForegroundShade] = usePreference("foreground-shade")
const [, setLocaleMode] = usePreference("locale-mode")
const [, setLocale] = usePreference("locale")
const [, setThemeMode] = usePreference("theme-mode")
useLayoutEffect(() => {
preferencesRef.current = preferences
}, [preferences])
useEffect(() => {
if (!("BroadcastChannel" in window)) return
const channel = new BroadcastChannel(preferencesChannelName)
channelRef.current = channel
function applyUpdate(update: PreferenceUpdate) {
applyingExternalUpdateRef.current = true
try {
switch (update.key) {
case "accent-color":
setAccentColor(update.value)
break
case "background-shade":
setBackgroundShade(update.value)
break
case "foreground-shade":
setForegroundShade(update.value)
break
case "locale-mode":
setLocaleMode(update.value)
break
case "locale":
setLocale(update.value)
break
case "theme-mode":
setThemeMode(update.value)
break
}
} finally {
applyingExternalUpdateRef.current = false
}
}
function applySnapshot(nextPreferences: Preferences) {
preferencesRef.current = nextPreferences
for (const [key, value] of Object.entries(nextPreferences)) {
const update = { key, value }
if (isDocsPreferenceUpdate(update)) applyUpdate(update)
}
}
function synchronizeRoute(nextPreferences: Preferences) {
const locale =
nextPreferences["locale-mode"] === "auto"
? detectLocale()
: nextPreferences.locale
navigateToLocale(locale, true)
}
function handleMessage(event: MessageEvent<unknown>) {
const message = event.data as Partial<PreferencesMessage> | undefined
if (!message || typeof message !== "object") return
if (message.type === "request") {
channel.postMessage({
type: "snapshot",
preferences: preferencesRef.current,
} satisfies PreferencesMessage)
return
}
if (message.type === "update" && isDocsPreferenceUpdate(message.update)) {
const nextPreferences = {
...preferencesRef.current,
[message.update.key]: message.update.value,
}
preferencesRef.current = nextPreferences
applyUpdate(message.update)
synchronizeRoute(nextPreferences)
return
}
if (message.type === "snapshot") {
const nextPreferences = parseDocsPreferences(message.preferences)
if (!nextPreferences) return
applySnapshot(nextPreferences)
synchronizeRoute(nextPreferences)
}
}
channel.addEventListener("message", handleMessage)
channel.postMessage({ type: "request" } satisfies PreferencesMessage)
return () => {
channelRef.current = null
channel.close()
}
}, [
applyingExternalUpdateRef,
channelRef,
setAccentColor,
setBackgroundShade,
setForegroundShade,
setLocale,
setLocaleMode,
setThemeMode,
])
useEffect(() => {
if (preferences["locale-mode"] !== "auto") return
function synchronizeDetectedLocale() {
navigateToLocale(detectLocale(), true)
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") synchronizeDetectedLocale()
}
synchronizeDetectedLocale()
window.addEventListener("focus", synchronizeDetectedLocale)
window.addEventListener("languagechange", synchronizeDetectedLocale)
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
window.removeEventListener("focus", synchronizeDetectedLocale)
window.removeEventListener("languagechange", synchronizeDetectedLocale)
document.removeEventListener("visibilitychange", handleVisibilityChange)
}
}, [preferences])
useEffect(() => {
if (
preferences["locale-mode"] === "manual" &&
preferences.locale !== routeLocale
) {
setLocale(routeLocale)
}
}, [preferences, routeLocale, setLocale])
return null
}
+24
View File
@@ -0,0 +1,24 @@
import { Icon } from "@icones/react"
import { SearchTrigger as RawSearchTrigger } from "@workspace/search"
import { useTranslate } from "../lib/locale"
export function SearchTrigger() {
const t = useTranslate()
const label = t({ zh: "搜索文档", us: "Search documentation" })
return (
<RawSearchTrigger
type="button"
aria-label={label}
title={label}
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-lg px-2 text-slate-500 transition-colors sm:hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 sm:px-3 dark:text-slate-400 sm:dark:hover:bg-slate-900 dark:hover:text-slate-100"
>
<Icon name="tabler:search" className="size-5" />
<span className="hidden sm:inline">{label}</span>
<kbd className="hidden rounded border border-slate-200 bg-white px-1.5 py-0.5 font-sans text-[0.6875rem] leading-none text-slate-400 lg:inline dark:border-slate-700 dark:bg-slate-950 dark:text-slate-500">
K
</kbd>
</RawSearchTrigger>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { Icon } from "@icones/react"
import type { ComponentProps } from "react"
import { getLocalizedHref } from "../content/registry"
import { useLocale, useTranslate } from "../lib/locale"
import { cn } from "../lib/utils"
export type SettingsLinkProps = Omit<
ComponentProps<"a">,
"children" | "href"
> & {
label?: string
}
export function SettingsLink({
className,
label,
...props
}: SettingsLinkProps) {
const locale = useLocale()
const t = useTranslate()
const accessibleLabel = label ?? t({ zh: "设置", us: "Settings" })
return (
<a
href={getLocalizedHref("/settings", locale)}
aria-label={accessibleLabel}
title={accessibleLabel}
className={cn(
"inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition-colors hover:text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:text-slate-400 dark:hover:text-slate-100",
className
)}
{...props}
>
<Icon name="tabler:settings" className="size-5" />
<span className="sr-only">{accessibleLabel}</span>
</a>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { Icon } from "@icones/react"
import type { ComponentProps } from "react"
import { cn } from "../lib/utils"
export type SwatchProps = Omit<ComponentProps<"button">, "children"> & {
light: string
dark?: string
selected?: boolean
}
export function Swatch({
className,
light,
dark = light,
selected = false,
...props
}: SwatchProps) {
return (
<button
type="button"
aria-pressed={selected}
className={cn(
"relative inline-flex flex-col size-10 shrink-0 cursor-pointer gap-px overflow-hidden rounded-full border border-slate-200 transition-transform outline-none hover:scale-105 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 active:scale-95 aria-pressed:ring-2 aria-pressed:ring-indigo-500 aria-pressed:ring-offset-2 dark:border-slate-700 dark:ring-offset-(--docs-background-dark) text-xs text-center",
className
)}
{...props}
>
<span
aria-hidden="true"
className="block size-full dark:hidden"
style={{ backgroundColor: light }}
/>
<span
aria-hidden="true"
className="hidden dark:block size-full"
style={{ backgroundColor: dark }}
/>
{selected && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex size-5 items-center justify-center rounded-full bg-white/90 text-slate-950 shadow-sm ring-1 ring-black/10">
<Icon name="tabler:check" className="size-3.5" />
</span>
</span>
)}
</button>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { Icon } from "@icones/react"
import { usePreference } from "@workspace/preferences"
import { useResolvedTheme } from "../lib/appearance"
import { useTranslate } from "../lib/locale"
export function ThemeSwitch({ className }: { className?: string }) {
const t = useTranslate()
const [, setThemeMode] = usePreference("theme-mode")
const resolvedTheme = useResolvedTheme()
const dark = resolvedTheme === "dark"
const label = dark
? t({ zh: "切换到浅色主题", us: "Switch to light theme" })
: t({ zh: "切换到深色主题", us: "Switch to dark theme" })
return (
<button
type="button"
role="switch"
aria-checked={dark}
aria-label={label}
title={label}
className={[
"relative inline-flex h-7 w-13 shrink-0 cursor-pointer items-center rounded-full border transition-colors duration-200",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 motion-reduce:transition-none",
dark
? "border-zinc-700 bg-zinc-900 shadow-inner hover:border-zinc-600"
: "border-slate-300 bg-slate-100 shadow-inner hover:border-slate-400",
className,
]
.filter(Boolean)
.join(" ")}
onClick={() => {
setThemeMode(dark ? "light" : "dark")
}}
>
<span
aria-hidden="true"
className={[
"absolute top-px left-px inline-flex size-6 items-center justify-center rounded-full ring-1 shadow-sm transition-transform duration-200 ease-out motion-reduce:transition-none",
dark
? "translate-x-6 bg-black text-zinc-100 ring-zinc-700"
: "translate-x-0 bg-white text-slate-500 ring-slate-200",
].join(" ")}
>
<Icon
name="tabler:sun-high"
altName="tabler:moon"
showAlt={dark}
className="z-10 size-4"
/>
</span>
</button>
)
}