|
| 1 | +import { ScriptOnce } from '@tanstack/react-router' |
| 2 | +import { clientOnly, createIsomorphicFn } from '@tanstack/react-start' |
| 3 | +import * as React from 'react' |
| 4 | +import { createContext, ReactNode, useEffect, useState } from 'react' |
| 5 | +import { z } from 'zod' |
| 6 | + |
| 7 | +const themeModeSchema = z.enum(['light', 'dark', 'auto']) |
| 8 | +const resolvedThemeSchema = z.enum(['light', 'dark']) |
| 9 | +const themeKey = 'theme' |
| 10 | + |
| 11 | +type ThemeMode = z.infer<typeof themeModeSchema> |
| 12 | +type ResolvedTheme = z.infer<typeof resolvedThemeSchema> |
| 13 | + |
| 14 | +const getStoredThemeMode = createIsomorphicFn() |
| 15 | + .server((): ThemeMode => 'auto') |
| 16 | + .client((): ThemeMode => { |
| 17 | + try { |
| 18 | + const storedTheme = localStorage.getItem(themeKey) |
| 19 | + return themeModeSchema.parse(storedTheme) |
| 20 | + } catch { |
| 21 | + return 'auto' |
| 22 | + } |
| 23 | + }) |
| 24 | + |
| 25 | +const setStoredThemeMode = clientOnly((theme: ThemeMode) => { |
| 26 | + try { |
| 27 | + const parsedTheme = themeModeSchema.parse(theme) |
| 28 | + localStorage.setItem(themeKey, parsedTheme) |
| 29 | + } catch {} |
| 30 | +}) |
| 31 | + |
| 32 | +const getSystemTheme = createIsomorphicFn() |
| 33 | + .server((): ResolvedTheme => 'light') |
| 34 | + .client((): ResolvedTheme => { |
| 35 | + return window.matchMedia('(prefers-color-scheme: dark)').matches |
| 36 | + ? 'dark' |
| 37 | + : 'light' |
| 38 | + }) |
| 39 | + |
| 40 | +const updateThemeClass = clientOnly((themeMode: ThemeMode) => { |
| 41 | + const root = document.documentElement |
| 42 | + root.classList.remove('light', 'dark', 'auto') |
| 43 | + const newTheme = themeMode === 'auto' ? getSystemTheme() : themeMode |
| 44 | + root.classList.add(newTheme) |
| 45 | + |
| 46 | + if (themeMode === 'auto') { |
| 47 | + root.classList.add('auto') |
| 48 | + } |
| 49 | +}) |
| 50 | + |
| 51 | +const setupPreferredListener = clientOnly(() => { |
| 52 | + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') |
| 53 | + const handler = () => updateThemeClass('auto') |
| 54 | + mediaQuery.addEventListener('change', handler) |
| 55 | + return () => mediaQuery.removeEventListener('change', handler) |
| 56 | +}) |
| 57 | + |
| 58 | +const getNextTheme = clientOnly((current: ThemeMode): ThemeMode => { |
| 59 | + const themes: ThemeMode[] = |
| 60 | + getSystemTheme() === 'dark' |
| 61 | + ? ['auto', 'light', 'dark'] |
| 62 | + : ['auto', 'dark', 'light'] |
| 63 | + return themes[(themes.indexOf(current) + 1) % themes.length] |
| 64 | +}) |
| 65 | + |
| 66 | +const themeDetectorScript = (function () { |
| 67 | + function themeFn() { |
| 68 | + try { |
| 69 | + const storedTheme = localStorage.getItem('theme') || 'auto' |
| 70 | + const validTheme = ['light', 'dark', 'auto'].includes(storedTheme) |
| 71 | + ? storedTheme |
| 72 | + : 'auto' |
| 73 | + |
| 74 | + if (validTheme === 'auto') { |
| 75 | + const autoTheme = window.matchMedia('(prefers-color-scheme: dark)') |
| 76 | + .matches |
| 77 | + ? 'dark' |
| 78 | + : 'light' |
| 79 | + document.documentElement.classList.add(autoTheme, 'auto') |
| 80 | + } else { |
| 81 | + document.documentElement.classList.add(validTheme) |
| 82 | + } |
| 83 | + } catch (e) { |
| 84 | + const autoTheme = window.matchMedia('(prefers-color-scheme: dark)') |
| 85 | + .matches |
| 86 | + ? 'dark' |
| 87 | + : 'light' |
| 88 | + document.documentElement.classList.add(autoTheme, 'auto') |
| 89 | + } |
| 90 | + } |
| 91 | + return `(${themeFn.toString()})();` |
| 92 | +})() |
| 93 | + |
| 94 | +type ThemeContextProps = { |
| 95 | + themeMode: ThemeMode |
| 96 | + resolvedTheme: ResolvedTheme |
| 97 | + setTheme: (theme: ThemeMode) => void |
| 98 | + toggleMode: () => void |
| 99 | +} |
| 100 | +const ThemeContext = createContext<ThemeContextProps | undefined>(undefined) |
| 101 | + |
| 102 | +type ThemeProviderProps = { |
| 103 | + children: ReactNode |
| 104 | +} |
| 105 | +export function ThemeProvider({ children }: ThemeProviderProps) { |
| 106 | + const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredThemeMode) |
| 107 | + |
| 108 | + useEffect(() => { |
| 109 | + if (themeMode !== 'auto') return |
| 110 | + return setupPreferredListener() |
| 111 | + }, [themeMode]) |
| 112 | + |
| 113 | + const resolvedTheme = themeMode === 'auto' ? getSystemTheme() : themeMode |
| 114 | + |
| 115 | + const setTheme = (newTheme: ThemeMode) => { |
| 116 | + setThemeMode(newTheme) |
| 117 | + setStoredThemeMode(newTheme) |
| 118 | + updateThemeClass(newTheme) |
| 119 | + } |
| 120 | + |
| 121 | + const toggleMode = () => { |
| 122 | + setTheme(getNextTheme(themeMode)) |
| 123 | + } |
| 124 | + |
| 125 | + return ( |
| 126 | + <ThemeContext.Provider |
| 127 | + value={{ themeMode, resolvedTheme, setTheme, toggleMode }} |
| 128 | + > |
| 129 | + <ScriptOnce children={themeDetectorScript} /> |
| 130 | + {children} |
| 131 | + </ThemeContext.Provider> |
| 132 | + ) |
| 133 | +} |
| 134 | + |
| 135 | +export const useTheme = () => { |
| 136 | + const context = React.useContext(ThemeContext) |
| 137 | + if (!context) { |
| 138 | + throw new Error('useTheme must be used within a ThemeProvider') |
| 139 | + } |
| 140 | + return context |
| 141 | +} |
0 commit comments