feat(appearance): expand preference controls
This commit is contained in:
@@ -9,7 +9,9 @@ import { BrightnessField } from "./brightness-field"
|
|||||||
import { ColorVisionModeField } from "./color-vision-mode-field"
|
import { ColorVisionModeField } from "./color-vision-mode-field"
|
||||||
import { MotionModeField } from "./motion-mode-field"
|
import { MotionModeField } from "./motion-mode-field"
|
||||||
import { TextScaleField } from "./text-scale-field"
|
import { TextScaleField } from "./text-scale-field"
|
||||||
|
import { TextScaleOptionList } from "./text-scale-option-list"
|
||||||
import { accentColorSwatches, ThemeColorPicker } from "./theme-color-picker"
|
import { accentColorSwatches, ThemeColorPicker } from "./theme-color-picker"
|
||||||
|
import { ThemeModeOptionList } from "./theme-mode-option-list"
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
@@ -68,6 +70,39 @@ describe("appearance atoms", () => {
|
|||||||
expect(setBrightness).toHaveBeenCalledWith("dim")
|
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", () => {
|
it("reports the selected theme color", () => {
|
||||||
const onValueChange = vi.fn()
|
const onValueChange = vi.fn()
|
||||||
|
|
||||||
@@ -83,4 +118,27 @@ describe("appearance atoms", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Rose" }))
|
fireEvent.click(screen.getByRole("button", { name: "Rose" }))
|
||||||
expect(onValueChange).toHaveBeenCalledWith("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 type { CSSProperties, ReactNode } from "react"
|
||||||
import { useMessage } from "@workspace/i18n"
|
import { useMessage } from "@workspace/i18n"
|
||||||
|
import { CheckIcon } from "@workspace/ui/components/icon"
|
||||||
import { cn } from "@workspace/ui/lib/utils"
|
import { cn } from "@workspace/ui/lib/utils"
|
||||||
|
|
||||||
import { themeColorMessages } from "../messages"
|
import { themeColorMessages } from "../messages"
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
} from "../theme"
|
} from "../theme"
|
||||||
|
|
||||||
export type ThemeColor = BaseColor | AccentColor
|
export type ThemeColor = BaseColor | AccentColor
|
||||||
|
export type ThemeColorPickerVariant = "pill" | "swatch"
|
||||||
|
|
||||||
export interface ThemeColorSwatch<T extends ThemeColor = ThemeColor> {
|
export interface ThemeColorSwatch<T extends ThemeColor = ThemeColor> {
|
||||||
dark: ThemeColorSwatchScheme
|
dark: ThemeColorSwatchScheme
|
||||||
@@ -53,6 +55,7 @@ export interface ThemeColorPickerProps<T extends ThemeColor> {
|
|||||||
swatches: readonly ThemeColorSwatch<T>[]
|
swatches: readonly ThemeColorSwatch<T>[]
|
||||||
title: ReactNode
|
title: ReactNode
|
||||||
value: T
|
value: T
|
||||||
|
variant?: ThemeColorPickerVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeColorPicker<T extends ThemeColor>({
|
export function ThemeColorPicker<T extends ThemeColor>({
|
||||||
@@ -61,18 +64,38 @@ export function ThemeColorPicker<T extends ThemeColor>({
|
|||||||
swatches,
|
swatches,
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
|
variant = "pill",
|
||||||
}: ThemeColorPickerProps<T>) {
|
}: ThemeColorPickerProps<T>) {
|
||||||
return (
|
return (
|
||||||
<fieldset className={cn("relative rounded-lg border p-4", className)}>
|
<fieldset
|
||||||
<legend className="absolute top-0 left-4 z-2 block -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground">
|
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}
|
{title}
|
||||||
</legend>
|
</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) => (
|
{swatches.map((swatch) => (
|
||||||
<ThemeColorSwatchButton
|
<ThemeColorSwatchButton
|
||||||
key={swatch.name}
|
key={swatch.name}
|
||||||
swatch={swatch}
|
swatch={swatch}
|
||||||
pressed={value === swatch.name}
|
pressed={value === swatch.name}
|
||||||
|
variant={variant}
|
||||||
onClick={() => onValueChange(swatch.name)}
|
onClick={() => onValueChange(swatch.name)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -85,12 +108,14 @@ export interface ThemeColorSwatchButtonProps {
|
|||||||
onClick: VoidFunction
|
onClick: VoidFunction
|
||||||
pressed: boolean
|
pressed: boolean
|
||||||
swatch: ThemeColorSwatch
|
swatch: ThemeColorSwatch
|
||||||
|
variant?: ThemeColorPickerVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeColorSwatchButton({
|
export function ThemeColorSwatchButton({
|
||||||
onClick,
|
onClick,
|
||||||
pressed,
|
pressed,
|
||||||
swatch,
|
swatch,
|
||||||
|
variant = "pill",
|
||||||
}: ThemeColorSwatchButtonProps) {
|
}: ThemeColorSwatchButtonProps) {
|
||||||
const swatchTitle = useMessage(themeColorMessages[swatch.name])
|
const swatchTitle = useMessage(themeColorMessages[swatch.name])
|
||||||
const style = {
|
const style = {
|
||||||
@@ -103,19 +128,35 @@ export function ThemeColorSwatchButton({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="theme-color-swatch"
|
||||||
|
data-variant={variant}
|
||||||
aria-label={swatchTitle}
|
aria-label={swatchTitle}
|
||||||
aria-pressed={pressed}
|
aria-pressed={pressed}
|
||||||
|
title={variant === "swatch" ? swatchTitle : undefined}
|
||||||
className={cn(
|
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",
|
"appearance-none bg-(--light-bg) dark:bg-(--dark-bg)",
|
||||||
"border-(--light-bg) dark:border-(--dark-bg)",
|
variant === "pill"
|
||||||
"bg-(--light-bg) dark:bg-(--dark-bg)",
|
? [
|
||||||
"text-(--light-fg) dark:text-(--dark-fg)",
|
"h-8 min-w-8 truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
|
||||||
"ring-0 aria-pressed:ring-2"
|
"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}
|
style={style}
|
||||||
onClick={onClick}
|
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>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
baseColorSwatches,
|
baseColorSwatches,
|
||||||
ThemeColorPicker,
|
ThemeColorPicker,
|
||||||
} from "./theme-color-picker"
|
} from "./theme-color-picker"
|
||||||
import { ThemePreview } from "./theme-preview"
|
|
||||||
|
|
||||||
export interface ThemeConfigCardProps {
|
export interface ThemeConfigCardProps {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -40,7 +39,6 @@ export function ThemeConfigCard({
|
|||||||
<p>
|
<p>
|
||||||
<LocalizedText message={appearanceMessages.themeColorsDescription} />
|
<LocalizedText message={appearanceMessages.themeColorsDescription} />
|
||||||
</p>
|
</p>
|
||||||
<ThemePreview config={config} scheme={scheme} />
|
|
||||||
<ThemeColorPicker
|
<ThemeColorPicker
|
||||||
swatches={baseColorSwatches}
|
swatches={baseColorSwatches}
|
||||||
title={<LocalizedText message={appearanceMessages.baseColor} />}
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,11 @@ export {
|
|||||||
AppearanceSettings,
|
AppearanceSettings,
|
||||||
type AppearanceSettingsProps,
|
type AppearanceSettingsProps,
|
||||||
} from "./components/appearance-settings"
|
} from "./components/appearance-settings"
|
||||||
|
export {
|
||||||
|
AppearanceOptionList,
|
||||||
|
type AppearanceOption,
|
||||||
|
type AppearanceOptionListProps,
|
||||||
|
} from "./components/appearance-option-list"
|
||||||
export {
|
export {
|
||||||
BrightnessField,
|
BrightnessField,
|
||||||
type BrightnessFieldProps,
|
type BrightnessFieldProps,
|
||||||
@@ -39,6 +44,10 @@ export {
|
|||||||
TextScaleField,
|
TextScaleField,
|
||||||
type TextScaleFieldProps,
|
type TextScaleFieldProps,
|
||||||
} from "./components/text-scale-field"
|
} from "./components/text-scale-field"
|
||||||
|
export {
|
||||||
|
TextScaleOptionList,
|
||||||
|
type TextScaleOptionListProps,
|
||||||
|
} from "./components/text-scale-option-list"
|
||||||
export {
|
export {
|
||||||
ThemeColorPicker,
|
ThemeColorPicker,
|
||||||
ThemeColorSwatchButton,
|
ThemeColorSwatchButton,
|
||||||
@@ -47,6 +56,7 @@ export {
|
|||||||
createThemeColorSwatches,
|
createThemeColorSwatches,
|
||||||
type ThemeColor,
|
type ThemeColor,
|
||||||
type ThemeColorPickerProps,
|
type ThemeColorPickerProps,
|
||||||
|
type ThemeColorPickerVariant,
|
||||||
type ThemeColorSwatch,
|
type ThemeColorSwatch,
|
||||||
type ThemeColorSwatchButtonProps,
|
type ThemeColorSwatchButtonProps,
|
||||||
type ThemeColorSwatchScheme,
|
type ThemeColorSwatchScheme,
|
||||||
@@ -61,9 +71,9 @@ export {
|
|||||||
type ThemeModeFieldProps,
|
type ThemeModeFieldProps,
|
||||||
} from "./components/theme-mode-field"
|
} from "./components/theme-mode-field"
|
||||||
export {
|
export {
|
||||||
ThemePreview,
|
ThemeModeOptionList,
|
||||||
type ThemePreviewProps,
|
type ThemeModeOptionListProps,
|
||||||
} from "./components/theme-preview"
|
} from "./components/theme-mode-option-list"
|
||||||
export {
|
export {
|
||||||
ThemeToggle,
|
ThemeToggle,
|
||||||
ThemeToggleButton,
|
ThemeToggleButton,
|
||||||
|
|||||||
Reference in New Issue
Block a user