From 983ebead0289fdaf08a2c5fc0640ceb04571aa3e Mon Sep 17 00:00:00 2001 From: Maofeng Date: Tue, 29 Sep 2026 21:29:38 +0800 Subject: [PATCH] feat(appearance): expand preference controls --- .../src/components/appearance-atoms.test.tsx | 58 +++++++++ .../src/components/appearance-option-list.tsx | 111 ++++++++++++++++++ .../src/components/text-scale-option-list.tsx | 61 ++++++++++ .../src/components/theme-color-picker.tsx | 59 ++++++++-- .../src/components/theme-config-card.tsx | 2 - .../src/components/theme-mode-option-list.tsx | 53 +++++++++ .../src/components/theme-preview.tsx | 99 ---------------- packages/appearance/src/index.ts | 16 ++- 8 files changed, 346 insertions(+), 113 deletions(-) create mode 100644 packages/appearance/src/components/appearance-option-list.tsx create mode 100644 packages/appearance/src/components/text-scale-option-list.tsx create mode 100644 packages/appearance/src/components/theme-mode-option-list.tsx delete mode 100644 packages/appearance/src/components/theme-preview.tsx diff --git a/packages/appearance/src/components/appearance-atoms.test.tsx b/packages/appearance/src/components/appearance-atoms.test.tsx index e06b186..cfe7b75 100644 --- a/packages/appearance/src/components/appearance-atoms.test.tsx +++ b/packages/appearance/src/components/appearance-atoms.test.tsx @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + 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") + }) }) diff --git a/packages/appearance/src/components/appearance-option-list.tsx b/packages/appearance/src/components/appearance-option-list.tsx new file mode 100644 index 0000000..7c78c4d --- /dev/null +++ b/packages/appearance/src/components/appearance-option-list.tsx @@ -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 { + description?: ReactNode + disabled?: boolean + icon?: ReactNode + label: ReactNode + value: TValue +} + +export interface AppearanceOptionListProps extends Omit< + ComponentProps<"fieldset">, + "onChange" +> { + items: readonly AppearanceOption[] + 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({ + className, + disabled, + items, + label, + name, + onValueChange, + value, + ...props +}: AppearanceOptionListProps) { + const generatedName = useId() + + return ( +
+ {label} + {items.map((item) => { + const checked = item.value === value + + return ( + + ) + })} +
+ ) +} diff --git a/packages/appearance/src/components/text-scale-option-list.tsx b/packages/appearance/src/components/text-scale-option-list.tsx new file mode 100644 index 0000000..21aa0e4 --- /dev/null +++ b/packages/appearance/src/components/text-scale-option-list.tsx @@ -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: A, + label: "100%", + value: "100", + }, + { + icon: A, + label: "125%", + value: "125", + }, + { + icon: A, + label: "150%", + value: "150", + }, + { + icon: A, + label: "200%", + value: "200", + }, +] as const satisfies readonly AppearanceOption[] + +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 ( +
+

{title}

+

{description}

+ +
+ ) +} diff --git a/packages/appearance/src/components/theme-color-picker.tsx b/packages/appearance/src/components/theme-color-picker.tsx index 148ebf5..380bda8 100644 --- a/packages/appearance/src/components/theme-color-picker.tsx +++ b/packages/appearance/src/components/theme-color-picker.tsx @@ -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 { dark: ThemeColorSwatchScheme @@ -53,6 +55,7 @@ export interface ThemeColorPickerProps { swatches: readonly ThemeColorSwatch[] title: ReactNode value: T + variant?: ThemeColorPickerVariant } export function ThemeColorPicker({ @@ -61,18 +64,38 @@ export function ThemeColorPicker({ swatches, title, value, + variant = "pill", }: ThemeColorPickerProps) { return ( -
- +
+ {title} -
+
{swatches.map((swatch) => ( 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 ( ) } diff --git a/packages/appearance/src/components/theme-config-card.tsx b/packages/appearance/src/components/theme-config-card.tsx index b4cf2d9..361dd0d 100644 --- a/packages/appearance/src/components/theme-config-card.tsx +++ b/packages/appearance/src/components/theme-config-card.tsx @@ -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({

- } diff --git a/packages/appearance/src/components/theme-mode-option-list.tsx b/packages/appearance/src/components/theme-mode-option-list.tsx new file mode 100644 index 0000000..b9c3f0c --- /dev/null +++ b/packages/appearance/src/components/theme-mode-option-list.tsx @@ -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 = { + light: useMessage(appearanceMessages.lightModeDescription), + dark: useMessage(appearanceMessages.darkModeDescription), + system: useMessage(appearanceMessages.followSystemDescription), + } + const items: readonly AppearanceOption[] = [ + { icon: , label: lightLabel, value: "light" }, + { icon: , label: darkLabel, value: "dark" }, + { icon: , label: systemLabel, value: "system" }, + ] + + return ( +
+

{title}

+

+ {descriptions[value]} +

+ +
+ ) +} diff --git a/packages/appearance/src/components/theme-preview.tsx b/packages/appearance/src/components/theme-preview.tsx deleted file mode 100644 index 5d081cc..0000000 --- a/packages/appearance/src/components/theme-preview.tsx +++ /dev/null @@ -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 ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) -} diff --git a/packages/appearance/src/index.ts b/packages/appearance/src/index.ts index bc3249a..d1ac1f2 100644 --- a/packages/appearance/src/index.ts +++ b/packages/appearance/src/index.ts @@ -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,