|
1 | 1 | import { ScriptOnce } from "@tanstack/react-router" |
2 | | -import { |
3 | | - createContext, |
4 | | - use, |
5 | | - useCallback, |
6 | | - useEffect, |
7 | | - useMemo, |
8 | | - useState |
9 | | -} from "react" |
10 | | - |
11 | | -/** |
12 | | - * Theme provider for TanStack Start applications with dark/light mode support. |
13 | | - * Handles system preferences, localStorage persistence, and FOUC prevention. |
14 | | - * |
15 | | - * @example |
16 | | - * ```tsx |
17 | | - * // In your root route component (e.g., __root.tsx) |
18 | | - * import { ThemeProvider } from '@/components/theme-provider' |
19 | | - * |
20 | | - * export default function RootRoute() { |
21 | | - * return ( |
22 | | - * <ThemeProvider defaultTheme="system" storageKey="app-theme"> |
23 | | - * <Outlet /> |
24 | | - * </ThemeProvider> |
25 | | - * ) |
26 | | - * } |
27 | | - * |
28 | | - * // In any component |
29 | | - * import { useTheme } from '@/components/theme-provider' |
30 | | - * |
31 | | - * function ThemeToggle() { |
32 | | - * const { theme, setTheme, resolvedTheme } = useTheme() |
33 | | - * |
34 | | - * return ( |
35 | | - * <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}> |
36 | | - * Current theme: {theme} (displaying: {resolvedTheme}) |
37 | | - * </button> |
38 | | - * ) |
39 | | - * } |
40 | | - * |
41 | | - * // For theme-aware images or icons |
42 | | - * function Logo() { |
43 | | - * const { resolvedTheme } = useTheme() |
44 | | - * |
45 | | - * return ( |
46 | | - * <img |
47 | | - * src={resolvedTheme === 'dark' ? '/logo-dark.svg' : '/logo-light.svg'} |
48 | | - * alt="Logo" |
49 | | - * /> |
50 | | - * ) |
51 | | - * } |
52 | | - * ``` |
53 | | - */ |
| 2 | +import { createContext, use, useEffect, useState } from "react" |
54 | 3 |
|
55 | 4 | type Theme = "dark" | "light" | "system" |
56 | | -type ResolvedTheme = "dark" | "light" |
57 | | - |
58 | | -interface ThemeProviderProps { |
59 | | - children: React.ReactNode |
60 | | - defaultTheme?: Theme |
61 | | - storageKey?: string |
62 | | -} |
63 | 5 |
|
64 | | -interface ThemeProviderState { |
| 6 | +const ThemeContext = createContext<{ |
65 | 7 | theme: Theme |
66 | 8 | setTheme: (theme: Theme) => void |
67 | | - resolvedTheme?: ResolvedTheme |
68 | | -} |
69 | | - |
70 | | -const ThemeProviderContext = createContext<ThemeProviderState | undefined>( |
71 | | - undefined |
72 | | -) |
73 | | - |
74 | | -const MEDIA = "(prefers-color-scheme: dark)" |
75 | | -const THEMES = ["light", "dark"] as const |
| 9 | +}>({ |
| 10 | + theme: "system", |
| 11 | + setTheme: () => { |
| 12 | + throw new Error("useTheme must be used within a ThemeProvider") |
| 13 | + } |
| 14 | +}) |
76 | 15 |
|
77 | 16 | export function ThemeProvider({ |
78 | 17 | children, |
79 | 18 | defaultTheme = "system", |
80 | 19 | storageKey = "theme" |
81 | | -}: ThemeProviderProps) { |
| 20 | +}: { |
| 21 | + children: React.ReactNode |
| 22 | + defaultTheme?: Theme |
| 23 | + storageKey?: string |
| 24 | +}) { |
82 | 25 | const [theme, setThemeState] = useState<Theme>(() => { |
83 | 26 | if (typeof window === "undefined") return defaultTheme |
84 | | - |
85 | | - try { |
86 | | - const stored = localStorage.getItem(storageKey) as Theme | null |
87 | | - return stored ?? defaultTheme |
88 | | - } catch { |
89 | | - return defaultTheme |
90 | | - } |
| 27 | + return (localStorage.getItem(storageKey) as Theme) || defaultTheme |
91 | 28 | }) |
92 | 29 |
|
93 | | - // Track the resolved theme for "system" mode |
94 | | - const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => { |
95 | | - if (typeof window === "undefined") return "light" |
96 | | - return window.matchMedia(MEDIA).matches ? "dark" : "light" |
97 | | - }) |
98 | | - |
99 | | - // Apply theme to DOM |
100 | | - const applyTheme = useCallback((targetTheme: ResolvedTheme) => { |
101 | | - const root = document.documentElement |
102 | | - |
103 | | - // Only update if different |
104 | | - if (!root.classList.contains(targetTheme)) { |
105 | | - root.classList.remove(...THEMES) |
106 | | - root.classList.add(targetTheme) |
| 30 | + const setTheme = (newTheme: Theme) => { |
| 31 | + setThemeState(newTheme) |
| 32 | + if (newTheme === "system") { |
| 33 | + localStorage.removeItem(storageKey) |
| 34 | + } else { |
| 35 | + localStorage.setItem(storageKey, newTheme) |
107 | 36 | } |
| 37 | + } |
108 | 38 |
|
109 | | - // Also set color-scheme for native elements |
110 | | - root.style.colorScheme = targetTheme |
111 | | - }, []) |
112 | | - |
113 | | - // Handle theme changes |
114 | | - const setTheme = useCallback( |
115 | | - (newTheme: Theme) => { |
116 | | - setThemeState(newTheme) |
117 | | - |
118 | | - try { |
119 | | - if (newTheme === "system") { |
120 | | - localStorage.removeItem(storageKey) |
121 | | - } else { |
122 | | - localStorage.setItem(storageKey, newTheme) |
123 | | - } |
124 | | - } catch { |
125 | | - // Ignore storage errors (e.g., private browsing) |
126 | | - } |
127 | | - }, |
128 | | - [storageKey] |
129 | | - ) |
130 | | - |
131 | | - // Handle system preference changes |
132 | | - const handleMediaQuery = useCallback( |
133 | | - (e: MediaQueryListEvent | MediaQueryList) => { |
134 | | - const newResolvedTheme = e.matches ? "dark" : "light" |
135 | | - setResolvedTheme(newResolvedTheme) |
136 | | - |
137 | | - if (theme === "system") { |
138 | | - applyTheme(newResolvedTheme) |
139 | | - } |
140 | | - }, |
141 | | - [theme, applyTheme] |
142 | | - ) |
143 | | - |
144 | | - // Listen for system preference changes |
145 | | - useEffect(() => { |
146 | | - const media = window.matchMedia(MEDIA) |
147 | | - |
148 | | - // Modern browsers |
149 | | - media.addEventListener("change", handleMediaQuery) |
150 | | - handleMediaQuery(media) // Initial check |
151 | | - |
152 | | - return () => media.removeEventListener("change", handleMediaQuery) |
153 | | - }, [handleMediaQuery]) |
154 | | - |
155 | | - // Apply theme whenever it changes |
156 | | - useEffect(() => { |
157 | | - const targetTheme = theme === "system" ? resolvedTheme : theme |
158 | | - applyTheme(targetTheme) |
159 | | - }, [theme, resolvedTheme, applyTheme]) |
160 | | - |
161 | | - // Handle storage events for multi-tab sync |
162 | 39 | useEffect(() => { |
163 | | - const handleStorage = (e: StorageEvent) => { |
164 | | - if (e.key === storageKey) { |
165 | | - const newTheme = (e.newValue as Theme) || defaultTheme |
166 | | - setThemeState(newTheme) |
167 | | - } |
| 40 | + const apply = () => { |
| 41 | + const isDark = |
| 42 | + theme === "dark" || |
| 43 | + (theme === "system" && |
| 44 | + matchMedia("(prefers-color-scheme: dark)").matches) |
| 45 | + |
| 46 | + document.documentElement.classList.toggle("dark", isDark) |
| 47 | + document.documentElement.style.colorScheme = isDark ? "dark" : "light" |
168 | 48 | } |
169 | 49 |
|
170 | | - window.addEventListener("storage", handleStorage) |
171 | | - return () => window.removeEventListener("storage", handleStorage) |
172 | | - }, [storageKey, defaultTheme]) |
| 50 | + apply() |
173 | 51 |
|
174 | | - const value = useMemo( |
175 | | - () => ({ |
176 | | - theme, |
177 | | - setTheme, |
178 | | - resolvedTheme: theme === "system" ? resolvedTheme : theme |
179 | | - }), |
180 | | - [theme, setTheme, resolvedTheme] |
181 | | - ) |
| 52 | + if (theme === "system") { |
| 53 | + const media = matchMedia("(prefers-color-scheme: dark)") |
| 54 | + media.addEventListener("change", apply) |
| 55 | + return () => media.removeEventListener("change", apply) |
| 56 | + } |
| 57 | + }, [theme]) |
182 | 58 |
|
183 | 59 | return ( |
184 | | - <ThemeProviderContext value={value}> |
| 60 | + <ThemeContext value={{ theme, setTheme }}> |
185 | 61 | <ScriptOnce> |
186 | 62 | {`(() => { |
187 | 63 | const stored = localStorage.getItem('${storageKey}'); |
188 | 64 | const theme = stored || '${defaultTheme}'; |
189 | 65 | const isDark = theme === 'dark' || |
190 | | - (theme === 'system' && window.matchMedia('${MEDIA}').matches); |
191 | | -
|
192 | | - document.documentElement.classList.add(isDark ? 'dark' : 'light'); |
| 66 | + (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); |
| 67 | + document.documentElement.classList.toggle('dark', isDark); |
193 | 68 | document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'; |
194 | 69 | })()`} |
195 | 70 | </ScriptOnce> |
196 | 71 | {children} |
197 | | - </ThemeProviderContext> |
| 72 | + </ThemeContext> |
198 | 73 | ) |
199 | 74 | } |
200 | 75 |
|
201 | | -export function useTheme() { |
202 | | - const context = use(ThemeProviderContext) |
203 | | - |
204 | | - if (!context) { |
205 | | - throw new Error("useTheme must be used within a ThemeProvider") |
206 | | - } |
207 | | - |
208 | | - return context |
209 | | -} |
| 76 | +export const useTheme = () => use(ThemeContext) |
0 commit comments