feat(appearance): expand preference controls

This commit is contained in:
Maofeng
2026-09-29 21:29:38 +08:00
parent 9a6ec3a396
commit 983ebead02
8 changed files with 346 additions and 113 deletions
@@ -9,7 +9,9 @@ import { BrightnessField } from "./brightness-field"
import { ColorVisionModeField } from "./color-vision-mode-field"
import { MotionModeField } from "./motion-mode-field"
import { TextScaleField } from "./text-scale-field"
import { TextScaleOptionList } from "./text-scale-option-list"
import { accentColorSwatches, ThemeColorPicker } from "./theme-color-picker"
import { ThemeModeOptionList } from "./theme-mode-option-list"
afterEach(cleanup)
@@ -68,6 +70,39 @@ describe("appearance atoms", () => {
expect(setBrightness).toHaveBeenCalledWith("dim")
})
it("provides a radio-based text scale option list", () => {
const onValueChange = vi.fn()
renderLocalized(
<TextScaleOptionList value="125" onValueChange={onValueChange} />
)
expect(screen.getByRole("group", { name: "Text size" })).not.toBeNull()
expect(
(screen.getByRole("radio", { name: "125%" }) as HTMLInputElement).checked
).toBe(true)
fireEvent.click(screen.getByRole("radio", { name: "200%" }))
expect(onValueChange).toHaveBeenCalledWith("200")
})
it("provides a radio-based theme mode option list", () => {
const onValueChange = vi.fn()
renderLocalized(
<ThemeModeOptionList value="system" onValueChange={onValueChange} />
)
expect(screen.getByRole("group", { name: "Theme mode" })).not.toBeNull()
expect(
(screen.getByRole("radio", { name: "System" }) as HTMLInputElement)
.checked
).toBe(true)
fireEvent.click(screen.getByRole("radio", { name: "Dark" }))
expect(onValueChange).toHaveBeenCalledWith("dark")
})
it("reports the selected theme color", () => {
const onValueChange = vi.fn()
@@ -83,4 +118,27 @@ describe("appearance atoms", () => {
fireEvent.click(screen.getByRole("button", { name: "Rose" }))
expect(onValueChange).toHaveBeenCalledWith("rose")
})
it("renders compact color swatches without visible labels", () => {
const onValueChange = vi.fn()
renderLocalized(
<ThemeColorPicker
onValueChange={onValueChange}
swatches={accentColorSwatches}
title="Accent color"
value="blue"
variant="swatch"
/>
)
const blue = screen.getByRole("button", { name: "Blue" })
const rose = screen.getByRole("button", { name: "Rose" })
expect(blue.getAttribute("data-variant")).toBe("swatch")
expect(blue.getAttribute("aria-pressed")).toBe("true")
expect(rose.textContent).toBe("")
fireEvent.click(rose)
expect(onValueChange).toHaveBeenCalledWith("rose")
})
})
@@ -0,0 +1,111 @@
import { useId, type ComponentProps, type ReactNode } from "react"
import { CheckIcon } from "@workspace/ui/components/icon"
import { cn } from "@workspace/ui/lib/utils"
export interface AppearanceOption<TValue extends string> {
description?: ReactNode
disabled?: boolean
icon?: ReactNode
label: ReactNode
value: TValue
}
export interface AppearanceOptionListProps<TValue extends string> extends Omit<
ComponentProps<"fieldset">,
"onChange"
> {
items: readonly AppearanceOption<TValue>[]
label: ReactNode
name?: string
onValueChange: (value: TValue) => void
value: TValue
}
/**
* A controlled, accessible option list shared by appearance preferences.
* Native radio inputs provide keyboard navigation and form semantics while the
* labels supply the larger visual target.
*/
export function AppearanceOptionList<TValue extends string>({
className,
disabled,
items,
label,
name,
onValueChange,
value,
...props
}: AppearanceOptionListProps<TValue>) {
const generatedName = useId()
return (
<fieldset
data-slot="appearance-option-list"
className={cn(
"min-w-0 space-y-1 rounded-xl border p-1 disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={disabled}
{...props}
>
<legend className="sr-only">{label}</legend>
{items.map((item) => {
const checked = item.value === value
return (
<label
key={item.value}
data-checked={checked}
data-disabled={item.disabled || undefined}
className={cn(
"relative grid min-h-12 cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg px-3 py-2 transition-colors outline-none",
"has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2 has-[input:focus-visible]:ring-offset-background",
checked
? "bg-accent/12 text-accent dark:bg-accent/20"
: "text-foreground hover:bg-highlight hover:text-highlight-foreground",
item.disabled && "pointer-events-none opacity-50"
)}
>
<input
type="radio"
className="peer sr-only"
checked={checked}
disabled={item.disabled}
name={name ?? generatedName}
value={item.value}
onChange={() => onValueChange(item.value)}
/>
<span
className="inline-flex size-6 shrink-0 items-center justify-center [&_svg]:size-5"
aria-hidden="true"
>
{item.icon}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{item.label}
</span>
{item.description == null ? null : (
<span
className={cn(
"mt-0.5 block text-xs",
checked ? "text-accent/80" : "text-muted-foreground"
)}
>
{item.description}
</span>
)}
</span>
<CheckIcon
className={cn(
"size-4 shrink-0 transition-opacity",
checked ? "opacity-100" : "opacity-0"
)}
aria-hidden="true"
/>
</label>
)
})}
</fieldset>
)
}
@@ -0,0 +1,61 @@
import { useMessage } from "@workspace/i18n"
import { cn } from "@workspace/ui/lib/utils"
import type { TextScale } from "../display"
import { appearanceMessages } from "../messages"
import {
AppearanceOptionList,
type AppearanceOption,
} from "./appearance-option-list"
const textScaleItems = [
{
icon: <span className="text-xs font-semibold">A</span>,
label: "100%",
value: "100",
},
{
icon: <span className="text-sm font-semibold">A</span>,
label: "125%",
value: "125",
},
{
icon: <span className="text-base font-semibold">A</span>,
label: "150%",
value: "150",
},
{
icon: <span className="text-lg font-semibold">A</span>,
label: "200%",
value: "200",
},
] as const satisfies readonly AppearanceOption<TextScale>[]
export interface TextScaleOptionListProps {
className?: string
onValueChange: (value: TextScale) => void
value: TextScale
}
export function TextScaleOptionList({
className,
onValueChange,
value,
}: TextScaleOptionListProps) {
const title = useMessage(appearanceMessages.textScale)
const description = useMessage(appearanceMessages.textScaleDescription)
return (
<div className={cn("min-w-0 lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<AppearanceOptionList
className="mt-2 w-full max-w-64"
items={textScaleItems}
label={title}
value={value}
onValueChange={onValueChange}
/>
</div>
)
}
@@ -1,5 +1,6 @@
import type { CSSProperties, ReactNode } from "react"
import { useMessage } from "@workspace/i18n"
import { CheckIcon } from "@workspace/ui/components/icon"
import { cn } from "@workspace/ui/lib/utils"
import { themeColorMessages } from "../messages"
@@ -12,6 +13,7 @@ import {
} from "../theme"
export type ThemeColor = BaseColor | AccentColor
export type ThemeColorPickerVariant = "pill" | "swatch"
export interface ThemeColorSwatch<T extends ThemeColor = ThemeColor> {
dark: ThemeColorSwatchScheme
@@ -53,6 +55,7 @@ export interface ThemeColorPickerProps<T extends ThemeColor> {
swatches: readonly ThemeColorSwatch<T>[]
title: ReactNode
value: T
variant?: ThemeColorPickerVariant
}
export function ThemeColorPicker<T extends ThemeColor>({
@@ -61,18 +64,38 @@ export function ThemeColorPicker<T extends ThemeColor>({
swatches,
title,
value,
variant = "pill",
}: ThemeColorPickerProps<T>) {
return (
<fieldset className={cn("relative rounded-lg border p-4", className)}>
<legend className="absolute top-0 left-4 z-2 block -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground">
<fieldset
data-slot="theme-color-picker"
data-variant={variant}
className={cn(
variant === "pill" ? "relative rounded-lg border p-4" : "border-0 p-0",
className
)}
>
<legend
className={cn(
variant === "pill"
? "absolute top-0 left-4 z-2 block -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground"
: "mb-3 px-0 text-sm font-medium text-foreground"
)}
>
{title}
</legend>
<div className="flex flex-wrap items-center gap-2">
<div
className={cn(
"flex flex-wrap items-center",
variant === "pill" ? "gap-2" : "gap-3"
)}
>
{swatches.map((swatch) => (
<ThemeColorSwatchButton
key={swatch.name}
swatch={swatch}
pressed={value === swatch.name}
variant={variant}
onClick={() => onValueChange(swatch.name)}
/>
))}
@@ -85,12 +108,14 @@ export interface ThemeColorSwatchButtonProps {
onClick: VoidFunction
pressed: boolean
swatch: ThemeColorSwatch
variant?: ThemeColorPickerVariant
}
export function ThemeColorSwatchButton({
onClick,
pressed,
swatch,
variant = "pill",
}: ThemeColorSwatchButtonProps) {
const swatchTitle = useMessage(themeColorMessages[swatch.name])
const style = {
@@ -103,19 +128,35 @@ export function ThemeColorSwatchButton({
return (
<button
type="button"
data-slot="theme-color-swatch"
data-variant={variant}
aria-label={swatchTitle}
aria-pressed={pressed}
title={variant === "swatch" ? swatchTitle : undefined}
className={cn(
"h-8 min-w-8 appearance-none truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
"border-(--light-bg) dark:border-(--dark-bg)",
"bg-(--light-bg) dark:bg-(--dark-bg)",
"text-(--light-fg) dark:text-(--dark-fg)",
"ring-0 aria-pressed:ring-2"
"appearance-none bg-(--light-bg) dark:bg-(--dark-bg)",
variant === "pill"
? [
"h-8 min-w-8 truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
"border-(--light-bg) text-(--light-fg) dark:border-(--dark-bg) dark:text-(--dark-fg)",
"ring-0 aria-pressed:ring-2",
]
: [
"inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-black/10 p-0 ring-offset-2 ring-offset-background transition-[box-shadow,transform]",
"hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none",
"aria-pressed:ring-2 aria-pressed:ring-(--light-bg) dark:border-white/15 dark:aria-pressed:ring-(--dark-bg)",
]
)}
style={style}
onClick={onClick}
>
{swatchTitle}
{variant === "pill" ? (
swatchTitle
) : pressed ? (
<span className="inline-flex size-5 items-center justify-center rounded-full bg-(--light-fg) text-(--light-bg) shadow-sm dark:bg-(--dark-fg) dark:text-(--dark-bg)">
<CheckIcon className="size-3.5" />
</span>
) : null}
</button>
)
}
@@ -9,7 +9,6 @@ import {
baseColorSwatches,
ThemeColorPicker,
} from "./theme-color-picker"
import { ThemePreview } from "./theme-preview"
export interface ThemeConfigCardProps {
className?: string
@@ -40,7 +39,6 @@ export function ThemeConfigCard({
<p>
<LocalizedText message={appearanceMessages.themeColorsDescription} />
</p>
<ThemePreview config={config} scheme={scheme} />
<ThemeColorPicker
swatches={baseColorSwatches}
title={<LocalizedText message={appearanceMessages.baseColor} />}
@@ -0,0 +1,53 @@
import { useMessage } from "@workspace/i18n"
import { MonitorIcon, MoonIcon, SunIcon } from "@workspace/ui/components/icon"
import { cn } from "@workspace/ui/lib/utils"
import { appearanceMessages } from "../messages"
import type { ThemeMode } from "../preferences"
import {
AppearanceOptionList,
type AppearanceOption,
} from "./appearance-option-list"
export interface ThemeModeOptionListProps {
className?: string
onValueChange: (value: ThemeMode) => void
value: ThemeMode
}
export function ThemeModeOptionList({
className,
onValueChange,
value,
}: ThemeModeOptionListProps) {
const title = useMessage(appearanceMessages.themeMode)
const lightLabel = useMessage(appearanceMessages.lightMode)
const darkLabel = useMessage(appearanceMessages.darkMode)
const systemLabel = useMessage(appearanceMessages.followSystem)
const descriptions: Record<ThemeMode, string> = {
light: useMessage(appearanceMessages.lightModeDescription),
dark: useMessage(appearanceMessages.darkModeDescription),
system: useMessage(appearanceMessages.followSystemDescription),
}
const items: readonly AppearanceOption<ThemeMode>[] = [
{ icon: <SunIcon />, label: lightLabel, value: "light" },
{ icon: <MoonIcon />, label: darkLabel, value: "dark" },
{ icon: <MonitorIcon />, label: systemLabel, value: "system" },
]
return (
<div className={cn("min-w-0 lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{descriptions[value]}
</p>
<AppearanceOptionList
className="mt-2 w-full max-w-64"
items={items}
label={title}
value={value}
onValueChange={onValueChange}
/>
</div>
)
}
@@ -1,99 +0,0 @@
import type { CSSProperties } from "react"
import {
getTheme,
getThemeCssVariables,
type ThemeColorConfig,
type ThemeScheme,
} from "../theme"
import { cn } from "@workspace/ui/lib/utils"
export interface ThemePreviewProps {
className?: string
config: ThemeColorConfig
scheme: ThemeScheme
}
export function ThemePreview({ scheme, config, className }: ThemePreviewProps) {
const themeVariables = getThemeCssVariables(scheme, config)
const baseTheme = getTheme(config.baseColor) ?? getTheme("neutral")!
const style = {
...Object.fromEntries(
Object.entries(themeVariables).map(([key, value]) => [`--${key}`, value])
),
"--preview-base": baseTheme.cssVars[scheme].accent,
} as CSSProperties
return (
<div className={cn(className, `ui:${scheme}`)} style={style}>
<div className="isolate h-52 max-w-103.5 rounded-lg bg-sidebar p-3 ring ring-border">
<div className="flex gap-2.5 [&>div]:bg-foreground/10">
<div className="size-5 rounded-sm" />
<div className="ml-auto size-5 rounded-sm" />
<div className="size-5 rounded-sm" />
<div className="size-5 rounded-sm" />
</div>
<div className="flex gap-3">
<div className="flex w-16 flex-col gap-1 py-5 [&>div]:bg-foreground/10">
<div className="h-2.5 w-full rounded-r-full" />
<div className="h-2.5 w-full rounded-r-full bg-accent!" />
<div className="h-2.5 w-full rounded-r-full" />
<div className="h-2.5 w-full rounded-r-full" />
<div className="mt-auto h-2.5 w-full rounded-r-full" />
</div>
<div className="flex-1 py-5">
<div className="space-y-2.5">
<div className="flex h-2.5 items-center gap-2 [&>div]:bg-foreground/10">
<div className="h-2.5 w-3 rounded-sm" />
<div className="ml-auto size-2.5 rounded-sm" />
<div className="h-2.5 w-3.75 rounded-sm" />
<div className="h-2.5 w-5 rounded-sm bg-accent!" />
</div>
<div className="grid grid-cols-3 gap-2 [&>div]:rounded-sm [&>div]:bg-foreground/10">
<div className="flex h-18 items-end gap-2 p-2">
<div className="h-6 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-4 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-11 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-8 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-9 w-2 rounded-t-sm bg-(--preview-base)/55" />
</div>
<div className="flex h-18 flex-col items-stretch justify-between gap-1 p-2">
<div className="flex items-center gap-1 opacity-55">
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="ml-auto h-1 w-6 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
</div>
<div className="flex flex-wrap gap-1">
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-2 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55" />
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-6 rounded-sm bg-accent" />
<div className="h-2 w-6 rounded-sm bg-accent/75" />
</div>
</div>
<div className="h-18" />
<div className="h-10 flex-1" />
<div className="h-10 flex-1" />
<div className="h-10 flex-1" />
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+13 -3
View File
@@ -23,6 +23,11 @@ export {
AppearanceSettings,
type AppearanceSettingsProps,
} from "./components/appearance-settings"
export {
AppearanceOptionList,
type AppearanceOption,
type AppearanceOptionListProps,
} from "./components/appearance-option-list"
export {
BrightnessField,
type BrightnessFieldProps,
@@ -39,6 +44,10 @@ export {
TextScaleField,
type TextScaleFieldProps,
} from "./components/text-scale-field"
export {
TextScaleOptionList,
type TextScaleOptionListProps,
} from "./components/text-scale-option-list"
export {
ThemeColorPicker,
ThemeColorSwatchButton,
@@ -47,6 +56,7 @@ export {
createThemeColorSwatches,
type ThemeColor,
type ThemeColorPickerProps,
type ThemeColorPickerVariant,
type ThemeColorSwatch,
type ThemeColorSwatchButtonProps,
type ThemeColorSwatchScheme,
@@ -61,9 +71,9 @@ export {
type ThemeModeFieldProps,
} from "./components/theme-mode-field"
export {
ThemePreview,
type ThemePreviewProps,
} from "./components/theme-preview"
ThemeModeOptionList,
type ThemeModeOptionListProps,
} from "./components/theme-mode-option-list"
export {
ThemeToggle,
ThemeToggleButton,