refactor(appearance): organize components and hooks
This commit is contained in:
@@ -26,14 +26,33 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./color-vision": "./src/color-vision.ts",
|
||||
"./components/appearance-option-list": "./src/components/option-lists/appearance-option-list.tsx",
|
||||
"./components/brightness-field": "./src/components/fields/brightness-field.tsx",
|
||||
"./components/color-vision-mode-field": "./src/components/fields/color-vision-mode-field.tsx",
|
||||
"./components/motion-mode-field": "./src/components/fields/motion-mode-field.tsx",
|
||||
"./components/text-scale-field": "./src/components/fields/text-scale-field.tsx",
|
||||
"./components/text-scale-option-list": "./src/components/option-lists/text-scale-option-list.tsx",
|
||||
"./components/theme-color-picker": "./src/components/theme/theme-color-picker.tsx",
|
||||
"./components/theme-config-card": "./src/components/theme/theme-config-card.tsx",
|
||||
"./components/theme-mode-field": "./src/components/fields/theme-mode-field.tsx",
|
||||
"./components/theme-mode-option-list": "./src/components/option-lists/theme-mode-option-list.tsx",
|
||||
"./components/theme-toggle-button": "./src/components/theme/theme-toggle-button.tsx",
|
||||
"./components/fields": "./src/components/fields/index.ts",
|
||||
"./components/fields/*": "./src/components/fields/*.tsx",
|
||||
"./components/option-lists": "./src/components/option-lists/index.ts",
|
||||
"./components/option-lists/*": "./src/components/option-lists/*.tsx",
|
||||
"./components/theme": "./src/components/theme/index.ts",
|
||||
"./components/theme/*": "./src/components/theme/*.tsx",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./display": "./src/display.ts",
|
||||
"./effects": "./src/effects.ts",
|
||||
"./globals.css": "./src/styles/globals.css",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
"./locales": "./src/locales/catalogs.ts",
|
||||
"./locales/*": "./src/locales/*.ts",
|
||||
"./preferences": "./src/preferences.ts",
|
||||
"./theme": "./src/theme.ts",
|
||||
"./use-resolved-theme": "./src/use-resolved-theme.ts"
|
||||
"./use-resolved-theme": "./src/hooks/use-resolved-theme.ts"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ThemeScheme } from "./themes"
|
||||
import type { ThemeScheme } from "./internal/themes"
|
||||
|
||||
/**
|
||||
* Color-vision assistance modes.
|
||||
|
||||
@@ -5,13 +5,16 @@ import { I18nProvider } from "@workspace/i18n"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { messages as englishMessages } from "../locales/en-US"
|
||||
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"
|
||||
import { BrightnessField } from "./fields/brightness-field"
|
||||
import { ColorVisionModeField } from "./fields/color-vision-mode-field"
|
||||
import { MotionModeField } from "./fields/motion-mode-field"
|
||||
import { TextScaleField } from "./fields/text-scale-field"
|
||||
import { TextScaleOptionList } from "./option-lists/text-scale-option-list"
|
||||
import { ThemeModeOptionList } from "./option-lists/theme-mode-option-list"
|
||||
import {
|
||||
accentColorSwatches,
|
||||
ThemeColorPicker,
|
||||
} from "./theme/theme-color-picker"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import { useResolvedTheme } from "../use-resolved-theme"
|
||||
import { useToggleTheme } from "../hooks/use-toggle-theme"
|
||||
import {
|
||||
AppearanceDialog,
|
||||
AppearanceDialogContent,
|
||||
@@ -20,8 +19,7 @@ export function AppearanceController({
|
||||
onOpenChange,
|
||||
}: AppearanceControllerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [, setThemeMode] = usePreference("theme-mode")
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
const toggleDialog = useCallback(() => {
|
||||
if (isOpen) {
|
||||
@@ -31,10 +29,6 @@ export function AppearanceController({
|
||||
}
|
||||
}, [handle, isOpen])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setThemeMode])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open)
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import { useResolvedTheme } from "../use-resolved-theme"
|
||||
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 { ThemeConfigCard } from "./theme-config-card"
|
||||
import { ThemeModeField } from "./theme-mode-field"
|
||||
import {
|
||||
useBrightness,
|
||||
useColorVisionMode,
|
||||
useMotionMode,
|
||||
useTextScale,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
} from "../hooks/use-appearance-preference"
|
||||
import { useResolvedTheme } from "../hooks/use-resolved-theme"
|
||||
import { BrightnessField } from "./fields/brightness-field"
|
||||
import { ColorVisionModeField } from "./fields/color-vision-mode-field"
|
||||
import { MotionModeField } from "./fields/motion-mode-field"
|
||||
import { TextScaleField } from "./fields/text-scale-field"
|
||||
import { ThemeModeField } from "./fields/theme-mode-field"
|
||||
import { ThemeConfigCard } from "./theme/theme-config-card"
|
||||
|
||||
export interface AppearanceSettingsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettings({ className }: AppearanceSettingsProps) {
|
||||
const [themeMode, setThemeMode] = usePreference("theme-mode")
|
||||
const [accentColor, setAccentColor] = usePreference("accent-color")
|
||||
const [baseColor, setBaseColor] = usePreference("base-color")
|
||||
const [brightness, setBrightness] = usePreference("brightness")
|
||||
const [colorVisionMode, setColorVisionMode] =
|
||||
usePreference("color-vision-mode")
|
||||
const [motionMode, setMotionMode] = usePreference("motion-mode")
|
||||
const [textScale, setTextScale] = usePreference("text-scale")
|
||||
const [themeMode, setThemeMode] = useThemeMode()
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const [brightness, setBrightness] = useBrightness()
|
||||
const [colorVisionMode, setColorVisionMode] = useColorVisionMode()
|
||||
const [motionMode, setMotionMode] = useMotionMode()
|
||||
const [textScale, setTextScale] = useTextScale()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const themeConfig = { accentColor, baseColor }
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-10", className)}>
|
||||
@@ -39,10 +43,7 @@ export function AppearanceSettings({ className }: AppearanceSettingsProps) {
|
||||
</div>
|
||||
<ThemeConfigCard
|
||||
config={themeConfig}
|
||||
onConfigChange={(config) => {
|
||||
setBaseColor(config.baseColor)
|
||||
setAccentColor(config.accentColor)
|
||||
}}
|
||||
onConfigChange={setThemeConfig}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ import {
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { Brightness } from "../display"
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { Brightness } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface BrightnessFieldProps {
|
||||
className?: string
|
||||
+2
-2
@@ -9,8 +9,8 @@ import {
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { ColorVisionMode } from "../color-vision"
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { ColorVisionMode } from "../../color-vision"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface ColorVisionModeFieldProps {
|
||||
className?: string
|
||||
@@ -0,0 +1,12 @@
|
||||
export { BrightnessField, type BrightnessFieldProps } from "./brightness-field"
|
||||
export {
|
||||
ColorVisionModeField,
|
||||
type ColorVisionModeFieldProps,
|
||||
} from "./color-vision-mode-field"
|
||||
export { MotionModeField, type MotionModeFieldProps } from "./motion-mode-field"
|
||||
export { TextScaleField, type TextScaleFieldProps } from "./text-scale-field"
|
||||
export {
|
||||
ThemeModeField,
|
||||
ThemeModeIcon,
|
||||
type ThemeModeFieldProps,
|
||||
} from "./theme-mode-field"
|
||||
+2
-2
@@ -8,8 +8,8 @@ import {
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { MotionMode } from "../display"
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { MotionMode } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface MotionModeFieldProps {
|
||||
className?: string
|
||||
+2
-2
@@ -9,8 +9,8 @@ import {
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { TextScale } from "../display"
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { TextScale } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface TextScaleFieldProps {
|
||||
className?: string
|
||||
+2
-2
@@ -9,8 +9,8 @@ import {
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { ThemeMode } from "../preferences"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeMode } from "../../preferences"
|
||||
|
||||
export interface ThemeModeFieldProps {
|
||||
className?: string
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
type AppearanceOptionListProps,
|
||||
} from "./appearance-option-list"
|
||||
export {
|
||||
TextScaleOptionList,
|
||||
type TextScaleOptionListProps,
|
||||
} from "./text-scale-option-list"
|
||||
export {
|
||||
ThemeModeOptionList,
|
||||
type ThemeModeOptionListProps,
|
||||
} from "./theme-mode-option-list"
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { TextScale } from "../display"
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { TextScale } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
+2
-2
@@ -2,8 +2,8 @@ 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 { appearanceMessages } from "../../messages"
|
||||
import type { ThemeMode } from "../../preferences"
|
||||
import {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
ThemeColorPicker,
|
||||
ThemeColorSwatchButton,
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
createThemeColorSwatches,
|
||||
type ThemeColor,
|
||||
type ThemeColorPickerProps,
|
||||
type ThemeColorPickerVariant,
|
||||
type ThemeColorSwatch,
|
||||
type ThemeColorSwatchButtonProps,
|
||||
type ThemeColorSwatchScheme,
|
||||
} from "./theme-color-picker"
|
||||
export { ThemeConfigCard, type ThemeConfigCardProps } from "./theme-config-card"
|
||||
export {
|
||||
ThemeToggle,
|
||||
ThemeToggleButton,
|
||||
type ThemeToggleButtonProps,
|
||||
type ThemeToggleProps,
|
||||
} from "./theme-toggle-button"
|
||||
+2
-2
@@ -3,14 +3,14 @@ import { useMessage } from "@workspace/i18n"
|
||||
import { CheckIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { themeColorMessages } from "../messages"
|
||||
import { themeColorMessages } from "../../messages"
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
getTheme,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
} from "../theme"
|
||||
} from "../../theme"
|
||||
|
||||
export type ThemeColor = BaseColor | AccentColor
|
||||
export type ThemeColorPickerVariant = "pill" | "swatch"
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { LocalizedText } from "@workspace/i18n"
|
||||
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { ThemeColorConfig, ThemeScheme } from "../theme"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeColorConfig, ThemeScheme } from "../../theme"
|
||||
import {
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
+6
-10
@@ -1,13 +1,13 @@
|
||||
import type { ComponentProps } from "react"
|
||||
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { ThemeScheme } from "../theme"
|
||||
import { useResolvedTheme } from "../use-resolved-theme"
|
||||
import { useResolvedTheme } from "../../hooks/use-resolved-theme"
|
||||
import { useToggleTheme } from "../../hooks/use-toggle-theme"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeScheme } from "../../theme"
|
||||
|
||||
export interface ThemeToggleProps extends Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
@@ -50,14 +50,10 @@ export type ThemeToggleButtonProps = Omit<
|
||||
>
|
||||
|
||||
export function ThemeToggleButton(props: ThemeToggleButtonProps) {
|
||||
const [, setThemeMode] = usePreference("theme-mode")
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
return (
|
||||
<ThemeToggle
|
||||
{...props}
|
||||
theme={resolvedTheme}
|
||||
onThemeChange={setThemeMode}
|
||||
/>
|
||||
<ThemeToggle {...props} theme={resolvedTheme} onThemeChange={toggleTheme} />
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ThemeScheme } from "./themes"
|
||||
import type { ThemeScheme } from "./internal/themes"
|
||||
|
||||
export type Brightness = "dim" | "normal" | "bright"
|
||||
export type MotionMode = "system" | "reduce" | "full"
|
||||
|
||||
@@ -9,16 +9,14 @@ import {
|
||||
BRIGHTNESS_ATTRIBUTE,
|
||||
MOTION_ATTRIBUTE,
|
||||
TEXT_SCALE_ATTRIBUTE,
|
||||
type ResolvedMotionMode,
|
||||
} from "./display"
|
||||
import { buildThemeStyleSheet, THEME_STYLE_ELEMENT_ID } from "./theme"
|
||||
import {
|
||||
buildThemeStyleSheet,
|
||||
THEME_STYLE_ELEMENT_ID,
|
||||
type ThemeScheme,
|
||||
} from "./theme"
|
||||
|
||||
const systemThemeQuery = "(prefers-color-scheme: dark)"
|
||||
const reducedMotionQuery = "(prefers-reduced-motion: reduce)"
|
||||
getSystemMotion,
|
||||
getSystemTheme,
|
||||
subscribeToSystemMotion,
|
||||
subscribeToSystemTheme,
|
||||
} from "./internal/system-preferences"
|
||||
const appearancePreferenceKeys = [
|
||||
"accent-color",
|
||||
"base-color",
|
||||
@@ -49,14 +47,6 @@ function disableThemeTransitionsTemporarily() {
|
||||
})
|
||||
}
|
||||
|
||||
function getSystemTheme(): ThemeScheme {
|
||||
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
function getSystemMotion(): ResolvedMotionMode {
|
||||
return window.matchMedia(reducedMotionQuery).matches ? "reduce" : "full"
|
||||
}
|
||||
|
||||
export function applyAppearancePreferences(preferences: AppearancePreferences) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
@@ -123,14 +113,12 @@ export const subscribeSystemThemePreferenceEffect: PreferenceEffectCallback = ({
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
if (appearancePreferences["theme-mode"] !== "system") return
|
||||
|
||||
const mediaQuery = window.matchMedia(systemThemeQuery)
|
||||
const applySystemTheme = () => {
|
||||
disableThemeTransitionsTemporarily()
|
||||
applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", applySystemTheme)
|
||||
return () => mediaQuery.removeEventListener("change", applySystemTheme)
|
||||
return subscribeToSystemTheme(applySystemTheme)
|
||||
}
|
||||
|
||||
/** Keeps system-mode motion synchronized with the operating system setting. */
|
||||
@@ -139,13 +127,11 @@ export const subscribeSystemMotionPreferenceEffect: PreferenceEffectCallback =
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
if (appearancePreferences["motion-mode"] !== "system") return
|
||||
|
||||
const mediaQuery = window.matchMedia(reducedMotionQuery)
|
||||
const applySystemMotion = () => {
|
||||
applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", applySystemMotion)
|
||||
return () => mediaQuery.removeEventListener("change", applySystemMotion)
|
||||
return subscribeToSystemMotion(applySystemMotion)
|
||||
}
|
||||
|
||||
/** Subscribes to every operating-system preference used by Appearance. */
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { PreferencesProvider } from "@workspace/preferences"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { defaultAppearancePreferences } from "../preferences"
|
||||
import {
|
||||
useAccentColor,
|
||||
useAppearancePreferences,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
} from "./use-appearance-preference"
|
||||
import { useResolvedMotion } from "./use-resolved-motion"
|
||||
import { useResolvedTheme } from "./use-resolved-theme"
|
||||
import { useToggleTheme } from "./use-toggle-theme"
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: false,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderPreferences(node: React.ReactNode) {
|
||||
return render(
|
||||
<PreferencesProvider initialPreferences={defaultAppearancePreferences}>
|
||||
{node}
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function PreferenceProbe() {
|
||||
const [accentColor, setAccentColor] = useAccentColor()
|
||||
const [themeMode] = useThemeMode()
|
||||
const preferences = useAppearancePreferences()
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setAccentColor("rose")}>
|
||||
{accentColor}:{themeMode}:{preferences.brightness}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeConfigProbe() {
|
||||
const [config, setConfig] = useThemeConfig()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfig({ accentColor: "violet", baseColor: "zinc" })}
|
||||
>
|
||||
{config.accentColor}:{config.baseColor}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedPreferenceProbe() {
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const resolvedMotion = useResolvedMotion()
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
return (
|
||||
<button type="button" onClick={toggleTheme}>
|
||||
{resolvedTheme}:{resolvedMotion}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe("appearance hooks", () => {
|
||||
it("exposes typed preference-specific hooks and the complete snapshot", () => {
|
||||
renderPreferences(<PreferenceProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("blue:system:normal")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("rose:system:normal")
|
||||
})
|
||||
|
||||
it("updates accent and base colors through one theme config API", () => {
|
||||
renderPreferences(<ThemeConfigProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("blue:neutral")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("violet:zinc")
|
||||
})
|
||||
|
||||
it("resolves system preferences and toggles to an explicit theme", () => {
|
||||
renderPreferences(<ResolvedPreferenceProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("light:full")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("dark:full")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
useAccentColor,
|
||||
useAppearancePreference,
|
||||
useAppearancePreferences,
|
||||
useBaseColor,
|
||||
useBrightness,
|
||||
useColorVisionMode,
|
||||
useMotionMode,
|
||||
useTextScale,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
type AppearancePreferenceState,
|
||||
} from "./use-appearance-preference"
|
||||
export { useResolvedMotion, useSystemMotion } from "./use-resolved-motion"
|
||||
export { useResolvedTheme, useSystemTheme } from "./use-resolved-theme"
|
||||
export { useToggleTheme } from "./use-toggle-theme"
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from "react"
|
||||
import { usePreference, usePreferences } from "@workspace/preferences"
|
||||
|
||||
import type {
|
||||
AppearancePreferenceKey,
|
||||
AppearancePreferences,
|
||||
AppearancePreferenceValue,
|
||||
} from "../preferences"
|
||||
import type { ThemeColorConfig } from "../theme"
|
||||
|
||||
export type AppearancePreferenceState<Key extends AppearancePreferenceKey> = [
|
||||
AppearancePreferenceValue<Key>,
|
||||
React.Dispatch<React.SetStateAction<AppearancePreferenceValue<Key>>>,
|
||||
]
|
||||
|
||||
/** Reads and updates one preference owned by the Appearance package. */
|
||||
export function useAppearancePreference<Key extends AppearancePreferenceKey>(
|
||||
key: Key
|
||||
): AppearancePreferenceState<Key> {
|
||||
return usePreference(key)
|
||||
}
|
||||
|
||||
/** Returns the complete Appearance-owned portion of the shared preferences. */
|
||||
export function useAppearancePreferences(): AppearancePreferences {
|
||||
const preferences = usePreferences()
|
||||
const accentColor = preferences["accent-color"]
|
||||
const baseColor = preferences["base-color"]
|
||||
const brightness = preferences.brightness
|
||||
const colorVisionMode = preferences["color-vision-mode"]
|
||||
const motionMode = preferences["motion-mode"]
|
||||
const textScale = preferences["text-scale"]
|
||||
const themeMode = preferences["theme-mode"]
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
"accent-color": accentColor,
|
||||
"base-color": baseColor,
|
||||
brightness,
|
||||
"color-vision-mode": colorVisionMode,
|
||||
"motion-mode": motionMode,
|
||||
"text-scale": textScale,
|
||||
"theme-mode": themeMode,
|
||||
}),
|
||||
[
|
||||
accentColor,
|
||||
baseColor,
|
||||
brightness,
|
||||
colorVisionMode,
|
||||
motionMode,
|
||||
textScale,
|
||||
themeMode,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
export function useAccentColor() {
|
||||
return useAppearancePreference("accent-color")
|
||||
}
|
||||
|
||||
export function useBaseColor() {
|
||||
return useAppearancePreference("base-color")
|
||||
}
|
||||
|
||||
export function useBrightness() {
|
||||
return useAppearancePreference("brightness")
|
||||
}
|
||||
|
||||
export function useColorVisionMode() {
|
||||
return useAppearancePreference("color-vision-mode")
|
||||
}
|
||||
|
||||
export function useMotionMode() {
|
||||
return useAppearancePreference("motion-mode")
|
||||
}
|
||||
|
||||
export function useTextScale() {
|
||||
return useAppearancePreference("text-scale")
|
||||
}
|
||||
|
||||
export function useThemeMode() {
|
||||
return useAppearancePreference("theme-mode")
|
||||
}
|
||||
|
||||
/** Treats the two color preferences as one controlled theme configuration. */
|
||||
export function useThemeConfig(): [
|
||||
ThemeColorConfig,
|
||||
React.Dispatch<React.SetStateAction<ThemeColorConfig>>,
|
||||
] {
|
||||
const [accentColor, setAccentColor] = useAccentColor()
|
||||
const [baseColor, setBaseColor] = useBaseColor()
|
||||
const config = React.useMemo(
|
||||
() => ({ accentColor, baseColor }),
|
||||
[accentColor, baseColor]
|
||||
)
|
||||
const configRef = React.useRef(config)
|
||||
configRef.current = config
|
||||
|
||||
const setConfig = React.useCallback<
|
||||
React.Dispatch<React.SetStateAction<ThemeColorConfig>>
|
||||
>(
|
||||
(action) => {
|
||||
const nextConfig =
|
||||
typeof action === "function" ? action(configRef.current) : action
|
||||
configRef.current = nextConfig
|
||||
setAccentColor(nextConfig.accentColor)
|
||||
setBaseColor(nextConfig.baseColor)
|
||||
},
|
||||
[setAccentColor, setBaseColor]
|
||||
)
|
||||
|
||||
return React.useMemo(() => [config, setConfig], [config, setConfig])
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
import type { ResolvedMotionMode } from "../display"
|
||||
import {
|
||||
getServerSystemMotion,
|
||||
getSystemMotion,
|
||||
subscribeToSystemMotion,
|
||||
} from "../internal/system-preferences"
|
||||
import { useMotionMode } from "./use-appearance-preference"
|
||||
|
||||
/** Tracks the operating system reduced-motion preference. */
|
||||
export function useSystemMotion(): ResolvedMotionMode {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToSystemMotion,
|
||||
getSystemMotion,
|
||||
getServerSystemMotion
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves `system` to the current operating system motion preference. */
|
||||
export function useResolvedMotion(): ResolvedMotionMode {
|
||||
const [motionMode] = useMotionMode()
|
||||
const systemMotion = useSystemMotion()
|
||||
|
||||
return motionMode === "system" ? systemMotion : motionMode
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
import {
|
||||
getServerSystemTheme,
|
||||
getSystemTheme,
|
||||
subscribeToSystemTheme,
|
||||
} from "../internal/system-preferences"
|
||||
import type { ThemeScheme } from "../theme"
|
||||
import { useThemeMode } from "./use-appearance-preference"
|
||||
|
||||
/** Tracks the operating system color scheme independently of theme mode. */
|
||||
export function useSystemTheme(): ThemeScheme {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToSystemTheme,
|
||||
getSystemTheme,
|
||||
getServerSystemTheme
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves `system` to the current operating system color scheme. */
|
||||
export function useResolvedTheme(): ThemeScheme {
|
||||
const [themeMode] = useThemeMode()
|
||||
const systemTheme = useSystemTheme()
|
||||
|
||||
return themeMode === "system" ? systemTheme : themeMode
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react"
|
||||
|
||||
import { useThemeMode } from "./use-appearance-preference"
|
||||
import { useResolvedTheme } from "./use-resolved-theme"
|
||||
|
||||
/** Returns a stable callback that toggles between explicit light and dark modes. */
|
||||
export function useToggleTheme(): VoidFunction {
|
||||
const [, setThemeMode] = useThemeMode()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
return React.useCallback(() => {
|
||||
setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setThemeMode])
|
||||
}
|
||||
@@ -23,34 +23,34 @@ export {
|
||||
AppearanceSettings,
|
||||
type AppearanceSettingsProps,
|
||||
} from "./components/appearance-settings"
|
||||
export {
|
||||
BrightnessField,
|
||||
ColorVisionModeField,
|
||||
MotionModeField,
|
||||
TextScaleField,
|
||||
ThemeModeField,
|
||||
ThemeModeIcon,
|
||||
type BrightnessFieldProps,
|
||||
type ColorVisionModeFieldProps,
|
||||
type MotionModeFieldProps,
|
||||
type TextScaleFieldProps,
|
||||
type ThemeModeFieldProps,
|
||||
} from "./components/fields"
|
||||
export {
|
||||
AppearanceOptionList,
|
||||
TextScaleOptionList,
|
||||
ThemeModeOptionList,
|
||||
type AppearanceOption,
|
||||
type AppearanceOptionListProps,
|
||||
} from "./components/appearance-option-list"
|
||||
export {
|
||||
BrightnessField,
|
||||
type BrightnessFieldProps,
|
||||
} from "./components/brightness-field"
|
||||
export {
|
||||
ColorVisionModeField,
|
||||
type ColorVisionModeFieldProps,
|
||||
} from "./components/color-vision-mode-field"
|
||||
export {
|
||||
MotionModeField,
|
||||
type MotionModeFieldProps,
|
||||
} from "./components/motion-mode-field"
|
||||
export {
|
||||
TextScaleField,
|
||||
type TextScaleFieldProps,
|
||||
} from "./components/text-scale-field"
|
||||
export {
|
||||
TextScaleOptionList,
|
||||
type TextScaleOptionListProps,
|
||||
} from "./components/text-scale-option-list"
|
||||
type ThemeModeOptionListProps,
|
||||
} from "./components/option-lists"
|
||||
export {
|
||||
ThemeColorPicker,
|
||||
ThemeColorSwatchButton,
|
||||
ThemeConfigCard,
|
||||
ThemeToggle,
|
||||
ThemeToggleButton,
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
createThemeColorSwatches,
|
||||
@@ -60,26 +60,10 @@ export {
|
||||
type ThemeColorSwatch,
|
||||
type ThemeColorSwatchButtonProps,
|
||||
type ThemeColorSwatchScheme,
|
||||
} from "./components/theme-color-picker"
|
||||
export {
|
||||
ThemeConfigCard,
|
||||
type ThemeConfigCardProps,
|
||||
} from "./components/theme-config-card"
|
||||
export {
|
||||
ThemeModeField,
|
||||
ThemeModeIcon,
|
||||
type ThemeModeFieldProps,
|
||||
} from "./components/theme-mode-field"
|
||||
export {
|
||||
ThemeModeOptionList,
|
||||
type ThemeModeOptionListProps,
|
||||
} from "./components/theme-mode-option-list"
|
||||
export {
|
||||
ThemeToggle,
|
||||
ThemeToggleButton,
|
||||
type ThemeToggleButtonProps,
|
||||
type ThemeToggleProps,
|
||||
} from "./components/theme-toggle-button"
|
||||
} from "./components/theme"
|
||||
export {
|
||||
COLOR_VISION_ATTRIBUTE,
|
||||
colorVisionModes,
|
||||
@@ -125,4 +109,4 @@ export {
|
||||
type ThemeMode,
|
||||
} from "./preferences"
|
||||
export * from "./theme"
|
||||
export { useResolvedTheme } from "./use-resolved-theme"
|
||||
export * from "./hooks"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ResolvedMotionMode } from "../display"
|
||||
import type { ThemeScheme } from "../theme"
|
||||
|
||||
export const SYSTEM_THEME_QUERY = "(prefers-color-scheme: dark)"
|
||||
export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
|
||||
|
||||
function subscribeToMediaQuery(query: string, listener: VoidFunction) {
|
||||
if (typeof window === "undefined") return () => {}
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
return () => mediaQuery.removeEventListener("change", listener)
|
||||
}
|
||||
|
||||
export function subscribeToSystemTheme(listener: VoidFunction) {
|
||||
return subscribeToMediaQuery(SYSTEM_THEME_QUERY, listener)
|
||||
}
|
||||
|
||||
export function getSystemTheme(): ThemeScheme {
|
||||
if (typeof window === "undefined") return getServerSystemTheme()
|
||||
return window.matchMedia(SYSTEM_THEME_QUERY).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
export function getServerSystemTheme(): ThemeScheme {
|
||||
return "light"
|
||||
}
|
||||
|
||||
export function subscribeToSystemMotion(listener: VoidFunction) {
|
||||
return subscribeToMediaQuery(REDUCED_MOTION_QUERY, listener)
|
||||
}
|
||||
|
||||
export function getSystemMotion(): ResolvedMotionMode {
|
||||
if (typeof window === "undefined") return getServerSystemMotion()
|
||||
return window.matchMedia(REDUCED_MOTION_QUERY).matches ? "reduce" : "full"
|
||||
}
|
||||
|
||||
export function getServerSystemMotion(): ResolvedMotionMode {
|
||||
return "full"
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type BaseColor,
|
||||
type ThemeColorConfig,
|
||||
type ThemeScheme,
|
||||
} from "./themes"
|
||||
} from "./internal/themes"
|
||||
import {
|
||||
getColorVisionCssVariables,
|
||||
type ColorVisionMode,
|
||||
@@ -20,7 +20,7 @@ export {
|
||||
type Theme,
|
||||
type ThemeColorConfig,
|
||||
type ThemeScheme,
|
||||
} from "./themes"
|
||||
} from "./internal/themes"
|
||||
|
||||
export const THEME_STYLE_ELEMENT_ID = "ui-theme-variables"
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from "react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import type { ThemeMode } from "./preferences"
|
||||
import type { ThemeScheme } from "./theme"
|
||||
|
||||
const systemThemeQuery = "(prefers-color-scheme: dark)"
|
||||
|
||||
function subscribeToSystemTheme(listener: VoidFunction) {
|
||||
const mediaQuery = window.matchMedia(systemThemeQuery)
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
return () => mediaQuery.removeEventListener("change", listener)
|
||||
}
|
||||
|
||||
function getSystemTheme(): ThemeScheme {
|
||||
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
function getServerSystemTheme(): ThemeScheme {
|
||||
return "light"
|
||||
}
|
||||
|
||||
export function useResolvedTheme(): ThemeScheme {
|
||||
const themeMode: ThemeMode = usePreference("theme-mode")[0]
|
||||
const systemTheme = React.useSyncExternalStore(
|
||||
subscribeToSystemTheme,
|
||||
getSystemTheme,
|
||||
getServerSystemTheme
|
||||
)
|
||||
|
||||
return themeMode === "system" ? systemTheme : themeMode
|
||||
}
|
||||
Reference in New Issue
Block a user