diff --git a/packages/appearance/package.json b/packages/appearance/package.json index 9853141..f9b15aa 100644 --- a/packages/appearance/package.json +++ b/packages/appearance/package.json @@ -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" } } \ No newline at end of file diff --git a/packages/appearance/src/color-vision.ts b/packages/appearance/src/color-vision.ts index bed37a8..1590eb4 100644 --- a/packages/appearance/src/color-vision.ts +++ b/packages/appearance/src/color-vision.ts @@ -1,4 +1,4 @@ -import type { ThemeScheme } from "./themes" +import type { ThemeScheme } from "./internal/themes" /** * Color-vision assistance modes. diff --git a/packages/appearance/src/components/appearance-atoms.test.tsx b/packages/appearance/src/components/appearance-atoms.test.tsx index cfe7b75..a5a35ba 100644 --- a/packages/appearance/src/components/appearance-atoms.test.tsx +++ b/packages/appearance/src/components/appearance-atoms.test.tsx @@ -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) diff --git a/packages/appearance/src/components/appearance-controller.tsx b/packages/appearance/src/components/appearance-controller.tsx index 484a1dd..e4f7b03 100644 --- a/packages/appearance/src/components/appearance-controller.tsx +++ b/packages/appearance/src/components/appearance-controller.tsx @@ -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) diff --git a/packages/appearance/src/components/appearance-settings.tsx b/packages/appearance/src/components/appearance-settings.tsx index 244def2..32d3bf3 100644 --- a/packages/appearance/src/components/appearance-settings.tsx +++ b/packages/appearance/src/components/appearance-settings.tsx @@ -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 (
@@ -39,10 +43,7 @@ export function AppearanceSettings({ className }: AppearanceSettingsProps) {
{ - setBaseColor(config.baseColor) - setAccentColor(config.accentColor) - }} + onConfigChange={setThemeConfig} scheme={resolvedTheme} /> diff --git a/packages/appearance/src/components/brightness-field.tsx b/packages/appearance/src/components/fields/brightness-field.tsx similarity index 95% rename from packages/appearance/src/components/brightness-field.tsx rename to packages/appearance/src/components/fields/brightness-field.tsx index 9984348..3f35e30 100644 --- a/packages/appearance/src/components/brightness-field.tsx +++ b/packages/appearance/src/components/fields/brightness-field.tsx @@ -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 diff --git a/packages/appearance/src/components/color-vision-mode-field.tsx b/packages/appearance/src/components/fields/color-vision-mode-field.tsx similarity index 95% rename from packages/appearance/src/components/color-vision-mode-field.tsx rename to packages/appearance/src/components/fields/color-vision-mode-field.tsx index f7e0fde..8a7969e 100644 --- a/packages/appearance/src/components/color-vision-mode-field.tsx +++ b/packages/appearance/src/components/fields/color-vision-mode-field.tsx @@ -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 diff --git a/packages/appearance/src/components/fields/index.ts b/packages/appearance/src/components/fields/index.ts new file mode 100644 index 0000000..62b0eb4 --- /dev/null +++ b/packages/appearance/src/components/fields/index.ts @@ -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" diff --git a/packages/appearance/src/components/motion-mode-field.tsx b/packages/appearance/src/components/fields/motion-mode-field.tsx similarity index 95% rename from packages/appearance/src/components/motion-mode-field.tsx rename to packages/appearance/src/components/fields/motion-mode-field.tsx index 553189a..741622c 100644 --- a/packages/appearance/src/components/motion-mode-field.tsx +++ b/packages/appearance/src/components/fields/motion-mode-field.tsx @@ -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 diff --git a/packages/appearance/src/components/text-scale-field.tsx b/packages/appearance/src/components/fields/text-scale-field.tsx similarity index 94% rename from packages/appearance/src/components/text-scale-field.tsx rename to packages/appearance/src/components/fields/text-scale-field.tsx index 43799ce..23bf9ec 100644 --- a/packages/appearance/src/components/text-scale-field.tsx +++ b/packages/appearance/src/components/fields/text-scale-field.tsx @@ -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 diff --git a/packages/appearance/src/components/theme-mode-field.tsx b/packages/appearance/src/components/fields/theme-mode-field.tsx similarity index 96% rename from packages/appearance/src/components/theme-mode-field.tsx rename to packages/appearance/src/components/fields/theme-mode-field.tsx index 7ed3eec..d09fd48 100644 --- a/packages/appearance/src/components/theme-mode-field.tsx +++ b/packages/appearance/src/components/fields/theme-mode-field.tsx @@ -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 diff --git a/packages/appearance/src/components/appearance-option-list.tsx b/packages/appearance/src/components/option-lists/appearance-option-list.tsx similarity index 100% rename from packages/appearance/src/components/appearance-option-list.tsx rename to packages/appearance/src/components/option-lists/appearance-option-list.tsx diff --git a/packages/appearance/src/components/option-lists/index.ts b/packages/appearance/src/components/option-lists/index.ts new file mode 100644 index 0000000..9aac83f --- /dev/null +++ b/packages/appearance/src/components/option-lists/index.ts @@ -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" diff --git a/packages/appearance/src/components/text-scale-option-list.tsx b/packages/appearance/src/components/option-lists/text-scale-option-list.tsx similarity index 93% rename from packages/appearance/src/components/text-scale-option-list.tsx rename to packages/appearance/src/components/option-lists/text-scale-option-list.tsx index 21aa0e4..3cf63ce 100644 --- a/packages/appearance/src/components/text-scale-option-list.tsx +++ b/packages/appearance/src/components/option-lists/text-scale-option-list.tsx @@ -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, diff --git a/packages/appearance/src/components/theme-mode-option-list.tsx b/packages/appearance/src/components/option-lists/theme-mode-option-list.tsx similarity index 94% rename from packages/appearance/src/components/theme-mode-option-list.tsx rename to packages/appearance/src/components/option-lists/theme-mode-option-list.tsx index b9c3f0c..ae386f5 100644 --- a/packages/appearance/src/components/theme-mode-option-list.tsx +++ b/packages/appearance/src/components/option-lists/theme-mode-option-list.tsx @@ -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, diff --git a/packages/appearance/src/components/theme/index.ts b/packages/appearance/src/components/theme/index.ts new file mode 100644 index 0000000..4384eef --- /dev/null +++ b/packages/appearance/src/components/theme/index.ts @@ -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" diff --git a/packages/appearance/src/components/theme-color-picker.tsx b/packages/appearance/src/components/theme/theme-color-picker.tsx similarity index 98% rename from packages/appearance/src/components/theme-color-picker.tsx rename to packages/appearance/src/components/theme/theme-color-picker.tsx index 380bda8..256a0a9 100644 --- a/packages/appearance/src/components/theme-color-picker.tsx +++ b/packages/appearance/src/components/theme/theme-color-picker.tsx @@ -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" diff --git a/packages/appearance/src/components/theme-config-card.tsx b/packages/appearance/src/components/theme/theme-config-card.tsx similarity index 93% rename from packages/appearance/src/components/theme-config-card.tsx rename to packages/appearance/src/components/theme/theme-config-card.tsx index 361dd0d..1cfb063 100644 --- a/packages/appearance/src/components/theme-config-card.tsx +++ b/packages/appearance/src/components/theme/theme-config-card.tsx @@ -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, diff --git a/packages/appearance/src/components/theme-toggle-button.tsx b/packages/appearance/src/components/theme/theme-toggle-button.tsx similarity index 79% rename from packages/appearance/src/components/theme-toggle-button.tsx rename to packages/appearance/src/components/theme/theme-toggle-button.tsx index aa16a6c..f65436f 100644 --- a/packages/appearance/src/components/theme-toggle-button.tsx +++ b/packages/appearance/src/components/theme/theme-toggle-button.tsx @@ -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, @@ -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 ( - + ) } diff --git a/packages/appearance/src/display.ts b/packages/appearance/src/display.ts index af59c0d..ec29652 100644 --- a/packages/appearance/src/display.ts +++ b/packages/appearance/src/display.ts @@ -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" diff --git a/packages/appearance/src/effects.ts b/packages/appearance/src/effects.ts index 14f917e..bb80a75 100644 --- a/packages/appearance/src/effects.ts +++ b/packages/appearance/src/effects.ts @@ -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. */ diff --git a/packages/appearance/src/hooks/hooks.test.tsx b/packages/appearance/src/hooks/hooks.test.tsx new file mode 100644 index 0000000..278335f --- /dev/null +++ b/packages/appearance/src/hooks/hooks.test.tsx @@ -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( + + {node} + + ) +} + +function PreferenceProbe() { + const [accentColor, setAccentColor] = useAccentColor() + const [themeMode] = useThemeMode() + const preferences = useAppearancePreferences() + + return ( + + ) +} + +function ThemeConfigProbe() { + const [config, setConfig] = useThemeConfig() + + return ( + + ) +} + +function ResolvedPreferenceProbe() { + const resolvedTheme = useResolvedTheme() + const resolvedMotion = useResolvedMotion() + const toggleTheme = useToggleTheme() + + return ( + + ) +} + +describe("appearance hooks", () => { + it("exposes typed preference-specific hooks and the complete snapshot", () => { + renderPreferences() + + 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() + + 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() + + const button = screen.getByRole("button") + expect(button.textContent).toBe("light:full") + + fireEvent.click(button) + expect(button.textContent).toBe("dark:full") + }) +}) diff --git a/packages/appearance/src/hooks/index.ts b/packages/appearance/src/hooks/index.ts new file mode 100644 index 0000000..556c328 --- /dev/null +++ b/packages/appearance/src/hooks/index.ts @@ -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" diff --git a/packages/appearance/src/hooks/use-appearance-preference.ts b/packages/appearance/src/hooks/use-appearance-preference.ts new file mode 100644 index 0000000..f8d9a5d --- /dev/null +++ b/packages/appearance/src/hooks/use-appearance-preference.ts @@ -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 = [ + AppearancePreferenceValue, + React.Dispatch>>, +] + +/** Reads and updates one preference owned by the Appearance package. */ +export function useAppearancePreference( + key: Key +): AppearancePreferenceState { + 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>, +] { + 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> + >( + (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]) +} diff --git a/packages/appearance/src/hooks/use-resolved-motion.ts b/packages/appearance/src/hooks/use-resolved-motion.ts new file mode 100644 index 0000000..9100d50 --- /dev/null +++ b/packages/appearance/src/hooks/use-resolved-motion.ts @@ -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 +} diff --git a/packages/appearance/src/hooks/use-resolved-theme.ts b/packages/appearance/src/hooks/use-resolved-theme.ts new file mode 100644 index 0000000..230f7a4 --- /dev/null +++ b/packages/appearance/src/hooks/use-resolved-theme.ts @@ -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 +} diff --git a/packages/appearance/src/hooks/use-toggle-theme.ts b/packages/appearance/src/hooks/use-toggle-theme.ts new file mode 100644 index 0000000..080799b --- /dev/null +++ b/packages/appearance/src/hooks/use-toggle-theme.ts @@ -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]) +} diff --git a/packages/appearance/src/index.ts b/packages/appearance/src/index.ts index d1ac1f2..8109e76 100644 --- a/packages/appearance/src/index.ts +++ b/packages/appearance/src/index.ts @@ -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" diff --git a/packages/appearance/src/internal/system-preferences.ts b/packages/appearance/src/internal/system-preferences.ts new file mode 100644 index 0000000..6da3f7b --- /dev/null +++ b/packages/appearance/src/internal/system-preferences.ts @@ -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" +} diff --git a/packages/appearance/src/themes.ts b/packages/appearance/src/internal/themes.ts similarity index 100% rename from packages/appearance/src/themes.ts rename to packages/appearance/src/internal/themes.ts diff --git a/packages/appearance/src/theme.ts b/packages/appearance/src/theme.ts index 09bd12d..185487c 100644 --- a/packages/appearance/src/theme.ts +++ b/packages/appearance/src/theme.ts @@ -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" diff --git a/packages/appearance/src/use-resolved-theme.ts b/packages/appearance/src/use-resolved-theme.ts deleted file mode 100644 index 4c3d153..0000000 --- a/packages/appearance/src/use-resolved-theme.ts +++ /dev/null @@ -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 -}