From e0466f9b99e17006125590fb5a04e8ce53b942f6 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sat, 6 Jun 2026 22:46:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20useThemePreference=20=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20compact=20=E7=8A=B6=E6=80=81=E4=B8=8E=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/shared/hooks/use-theme-preference.ts | 44 +++++++++++++- tests/web/hooks/use-theme-preference.test.ts | 60 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/web/shared/hooks/use-theme-preference.ts b/src/web/shared/hooks/use-theme-preference.ts index f7c0299..12f8c28 100644 --- a/src/web/shared/hooks/use-theme-preference.ts +++ b/src/web/shared/hooks/use-theme-preference.ts @@ -6,10 +6,13 @@ export type EffectiveTheme = "dark" | "light"; export type ThemePreference = "dark" | "light" | "system"; const PREFERENCE_CHANGE_EVENT = "theme-preference-change"; +const COMPACT_CHANGE_EVENT = "theme-compact-change"; export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference"; export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)"; +export const COMPACT_STORAGE_KEY = "theme.compact"; + export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean { try { return matchMedia(THEME_MEDIA_QUERY).matches; @@ -30,6 +33,14 @@ export function readThemePreference(storage: Storage = window.localStorage): The } } +export function readCompactPreference(storage: Storage = window.localStorage): boolean { + try { + return storage.getItem(COMPACT_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme { if (preference === "dark" || preference === "light") return preference; return systemPrefersDark ? "dark" : "light"; @@ -37,6 +48,7 @@ export function resolveEffectiveTheme(preference: ThemePreference, systemPrefers export function useThemePreference() { const [preference, setPreferenceState] = useState(() => readThemePreference()); + const [compact, setCompactState] = useState(() => readCompactPreference()); const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark()); const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark); @@ -52,6 +64,9 @@ export function useThemePreference() { const apiTheme = parseThemePreference(data.theme); setPreferenceState((prev) => (prev !== apiTheme ? apiTheme : prev)); writeThemePreference(apiTheme); + const apiCompact = typeof data.compact === "boolean" ? data.compact : false; + setCompactState((prev) => (prev !== apiCompact ? apiCompact : prev)); + writeCompactPreference(apiCompact); }) .catch(() => { // API 不可用时维持 localStorage 缓存值 @@ -78,12 +93,26 @@ export function useThemePreference() { return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener); }, []); + useEffect(() => { + const handleCompactEvent = (event: CustomEvent) => { + const next = typeof event.detail === "boolean" ? event.detail : false; + setCompactState((prev) => (prev !== next ? next : prev)); + }; + window.addEventListener(COMPACT_CHANGE_EVENT, handleCompactEvent as EventListener); + return () => window.removeEventListener(COMPACT_CHANGE_EVENT, handleCompactEvent as EventListener); + }, []); + const setPreference = useCallback((nextPreference: ThemePreference) => { setPreferenceState(nextPreference); writeThemePreference(nextPreference); }, []); - return { effectiveTheme, preference, setPreference }; + const setCompact = useCallback((nextCompact: boolean) => { + setCompactState(nextCompact); + writeCompactPreference(nextCompact); + }, []); + + return { compact, effectiveTheme, preference, setCompact, setPreference }; } export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) { @@ -98,3 +127,16 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora // jsdom 等环境可能不支持 CustomEvent } } + +export function writeCompactPreference(value: boolean, storage: Storage = window.localStorage): void { + try { + storage.setItem(COMPACT_STORAGE_KEY, String(value)); + } catch { + // 存储不可用时不阻断 + } + try { + window.dispatchEvent(new CustomEvent(COMPACT_CHANGE_EVENT, { detail: value })); + } catch { + // jsdom 等环境兼容 + } +} diff --git a/tests/web/hooks/use-theme-preference.test.ts b/tests/web/hooks/use-theme-preference.test.ts index 18caf30..51d9bf8 100644 --- a/tests/web/hooks/use-theme-preference.test.ts +++ b/tests/web/hooks/use-theme-preference.test.ts @@ -1,12 +1,15 @@ import { describe, expect, test } from "bun:test"; import { + COMPACT_STORAGE_KEY, getSystemPrefersDark, parseThemePreference, + readCompactPreference, readThemePreference, resolveEffectiveTheme, THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY, + writeCompactPreference, writeThemePreference, } from "../../../src/web/shared/hooks/use-theme-preference"; @@ -75,3 +78,60 @@ describe("theme preference 纯逻辑", () => { expect(resolveEffectiveTheme("system", false)).toBe("light"); }); }); + +function createCompactStorage(initial?: string): Storage { + const values = new Map(); + if (initial !== undefined) values.set(COMPACT_STORAGE_KEY, initial); + return { + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +} + +describe("compact preference 纯逻辑", () => { + test("读取 compact 默认值 false", () => { + const storage = createCompactStorage(); + expect(readCompactPreference(storage)).toBe(false); + }); + + test("读取 compact=true", () => { + const storage = createCompactStorage("true"); + expect(readCompactPreference(storage)).toBe(true); + }); + + test("读取 compact=false", () => { + const storage = createCompactStorage("false"); + expect(readCompactPreference(storage)).toBe(false); + }); + + test("读取 compact 非法值回退 false", () => { + const storage = createCompactStorage("yes"); + expect(readCompactPreference(storage)).toBe(false); + }); + + test("写入 compact 值", () => { + const storage = createCompactStorage(); + writeCompactPreference(true, storage); + expect(storage.getItem(COMPACT_STORAGE_KEY)).toBe("true"); + }); + + test("storage 异常时不抛错", () => { + const brokenStorage = { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + } as unknown as Storage; + + expect(readCompactPreference(brokenStorage)).toBe(false); + expect(() => writeCompactPreference(true, brokenStorage)).not.toThrow(); + }); +});