feat(hooks): useThemePreference 扩展 compact 状态与同步
This commit is contained in:
@@ -6,10 +6,13 @@ export type EffectiveTheme = "dark" | "light";
|
|||||||
export type ThemePreference = "dark" | "light" | "system";
|
export type ThemePreference = "dark" | "light" | "system";
|
||||||
|
|
||||||
const PREFERENCE_CHANGE_EVENT = "theme-preference-change";
|
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_PREFERENCE_STORAGE_KEY = "theme.preference";
|
||||||
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
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 {
|
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
||||||
try {
|
try {
|
||||||
return matchMedia(THEME_MEDIA_QUERY).matches;
|
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 {
|
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
|
||||||
if (preference === "dark" || preference === "light") return preference;
|
if (preference === "dark" || preference === "light") return preference;
|
||||||
return systemPrefersDark ? "dark" : "light";
|
return systemPrefersDark ? "dark" : "light";
|
||||||
@@ -37,6 +48,7 @@ export function resolveEffectiveTheme(preference: ThemePreference, systemPrefers
|
|||||||
|
|
||||||
export function useThemePreference() {
|
export function useThemePreference() {
|
||||||
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
||||||
|
const [compact, setCompactState] = useState<boolean>(() => readCompactPreference());
|
||||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
||||||
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
||||||
|
|
||||||
@@ -52,6 +64,9 @@ export function useThemePreference() {
|
|||||||
const apiTheme = parseThemePreference(data.theme);
|
const apiTheme = parseThemePreference(data.theme);
|
||||||
setPreferenceState((prev) => (prev !== apiTheme ? apiTheme : prev));
|
setPreferenceState((prev) => (prev !== apiTheme ? apiTheme : prev));
|
||||||
writeThemePreference(apiTheme);
|
writeThemePreference(apiTheme);
|
||||||
|
const apiCompact = typeof data.compact === "boolean" ? data.compact : false;
|
||||||
|
setCompactState((prev) => (prev !== apiCompact ? apiCompact : prev));
|
||||||
|
writeCompactPreference(apiCompact);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// API 不可用时维持 localStorage 缓存值
|
// API 不可用时维持 localStorage 缓存值
|
||||||
@@ -78,12 +93,26 @@ export function useThemePreference() {
|
|||||||
return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
|
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) => {
|
const setPreference = useCallback((nextPreference: ThemePreference) => {
|
||||||
setPreferenceState(nextPreference);
|
setPreferenceState(nextPreference);
|
||||||
writeThemePreference(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) {
|
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
|
||||||
@@ -98,3 +127,16 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora
|
|||||||
// jsdom 等环境可能不支持 CustomEvent
|
// 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 等环境兼容
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
COMPACT_STORAGE_KEY,
|
||||||
getSystemPrefersDark,
|
getSystemPrefersDark,
|
||||||
parseThemePreference,
|
parseThemePreference,
|
||||||
|
readCompactPreference,
|
||||||
readThemePreference,
|
readThemePreference,
|
||||||
resolveEffectiveTheme,
|
resolveEffectiveTheme,
|
||||||
THEME_MEDIA_QUERY,
|
THEME_MEDIA_QUERY,
|
||||||
THEME_PREFERENCE_STORAGE_KEY,
|
THEME_PREFERENCE_STORAGE_KEY,
|
||||||
|
writeCompactPreference,
|
||||||
writeThemePreference,
|
writeThemePreference,
|
||||||
} from "../../../src/web/shared/hooks/use-theme-preference";
|
} from "../../../src/web/shared/hooks/use-theme-preference";
|
||||||
|
|
||||||
@@ -75,3 +78,60 @@ describe("theme preference 纯逻辑", () => {
|
|||||||
expect(resolveEffectiveTheme("system", false)).toBe("light");
|
expect(resolveEffectiveTheme("system", false)).toBe("light");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createCompactStorage(initial?: string): Storage {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user