1
8
9export type Theme = "light" | "dark" | "system";
10export type ResolvedTheme = "light" | "dark";
11
12
13export function themeLocalStorageKey(): string {
14 return "factory.theme";
15}
16
17
18export function systemDarkQuery(): string {
19 return "(prefers-color-scheme: dark)";
20}
21
22
23export function resolveTheme(pref: Theme, systemDark: boolean): ResolvedTheme {
24 if (pref === "light") return "light";
25 if (pref === "dark") return "dark";
26 return systemDark ? "dark" : "light";
27}
28
29
30export interface ThemeDocument {
31 dataset: Record<string, string | undefined>;
32 addEventListener?: (type: string, handler: (e: unknown) => void) => void;
33 removeEventListener?: (type: string, handler: (e: unknown) => void) => void;
34}
35
36interface MediaQueryLike {
37 matches: boolean;
38 addEventListener?: (type: string, handler: () => void) => void;
39 removeEventListener?: (type: string, handler: () => void) => void;
40
41 addListener?: (handler: () => void) => void;
42 removeListener?: (handler: () => void) => void;
43}
44
45
50const SYSTEM_LISTENERS = new WeakMap<MediaQueryLike, () => void>();
51
52function detachSystemListener(media: MediaQueryLike | null): void {
53 if (!media) return;
54 const handler = SYSTEM_LISTENERS.get(media);
55 if (!handler) return;
56 media.removeEventListener?.("change", handler);
57 media.removeListener?.(handler);
58 SYSTEM_LISTENERS.delete(media);
59}
60
61
72export function applyThemeTo(
73 document: ThemeDocument,
74 pref: Theme,
75 windowLike?: unknown,
76): ResolvedTheme {
77 const media =
78 typeof (windowLike as { matchMedia?: (q: string) => MediaQueryLike } | undefined)
79 ?.matchMedia === "function"
80 ? (windowLike as { matchMedia: (q: string) => MediaQueryLike }).matchMedia(systemDarkQuery())
81 : null;
82
83 const resolved = resolveTheme(pref, media ? media.matches : false);
84 document.dataset.theme = resolved;
85
86 if (pref === "system" && media) {
87 detachSystemListener(media);
88 const handler = (): void => {
89 document.dataset.theme = resolveTheme(pref, media.matches);
90 };
91
92
93 if (media.addEventListener) {
94 media.addEventListener("change", handler);
95 } else if (media.addListener) {
96 media.addListener(handler);
97 }
98 SYSTEM_LISTENERS.set(media, handler);
99 } else {
100 detachSystemListener(media);
101 }
102 return resolved;
103}
104