127 lines
4.5 KiB
JavaScript
127 lines
4.5 KiB
JavaScript
/* ============================================================
|
|
ThemeProvider — restores the one theme behaviour the React app dropped.
|
|
|
|
js/app.js's rule: an explicit past choice wins; otherwise follow the OS AND
|
|
KEEP FOLLOWING IT until the user picks a side themselves. The existing
|
|
src/theme.js reads the stored value at boot but never subscribes to the media
|
|
query, so a system theme change mid-session did nothing.
|
|
|
|
Charts no longer need a full-view re-render on theme change — <Chart/>
|
|
observes [data-theme] and redraws itself.
|
|
============================================================ */
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
|
|
const KEY = 'tf-theme'
|
|
const ThemeContext = createContext(null)
|
|
|
|
export function getStoredTheme() {
|
|
try {
|
|
return localStorage.getItem(KEY)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function applyTheme(theme, persist = true) {
|
|
document.documentElement.setAttribute('data-theme', theme)
|
|
if (persist) {
|
|
try {
|
|
localStorage.setItem(KEY, theme)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Run before first paint (from main.jsx) so there is no light-mode flash. */
|
|
export function initTheme() {
|
|
const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
|
|
const saved = getStoredTheme()
|
|
applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false)
|
|
}
|
|
|
|
export function useTheme() {
|
|
const ctx = useContext(ThemeContext)
|
|
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>')
|
|
return ctx
|
|
}
|
|
|
|
/**
|
|
* A counter that increments every time [data-theme] flips. Use it as an effect
|
|
* or useMemo dependency.
|
|
*
|
|
* Almost nothing needs this: a CSS custom property change repaints the whole
|
|
* document for free, which is why the app re-themes without any React
|
|
* involvement. It exists for the handful of places that CANNOT ride a variable
|
|
* — anything that bakes a colour into a canvas, a string, or a separate
|
|
* document at render time. Those read the palette through getComputedStyle
|
|
* exactly once and then hold a stale copy forever, because changing a custom
|
|
* property repaints CSS but never re-runs JavaScript.
|
|
*
|
|
* It watches the ATTRIBUTE rather than subscribing to this provider's state, on
|
|
* purpose. initTheme() runs before React mounts and applyTheme() can be called
|
|
* from outside the tree, so the attribute is the only source that is always
|
|
* current — and a component using this hook then needs no provider at all.
|
|
* Chart.jsx observes the same attribute directly, for the same reason.
|
|
*/
|
|
export function useThemeVersion() {
|
|
const [version, setVersion] = useState(0)
|
|
useEffect(() => {
|
|
if (typeof MutationObserver !== 'function') return undefined
|
|
const mo = new MutationObserver(() => setVersion((v) => v + 1))
|
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
|
return () => mo.disconnect()
|
|
}, [])
|
|
return version
|
|
}
|
|
|
|
export default function ThemeProvider({ children }) {
|
|
const [theme, setThemeState] = useState(
|
|
() => document.documentElement.getAttribute('data-theme') || 'light',
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!window.matchMedia) return undefined
|
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
const onSystemChange = (e) => {
|
|
// Once the user has chosen explicitly, stop following the OS.
|
|
if (getStoredTheme()) return
|
|
const next = e.matches ? 'dark' : 'light'
|
|
applyTheme(next, false)
|
|
setThemeState(next)
|
|
}
|
|
mq.addEventListener('change', onSystemChange)
|
|
return () => mq.removeEventListener('change', onSystemChange)
|
|
}, [])
|
|
|
|
const setTheme = useCallback((next) => {
|
|
applyTheme(next, true)
|
|
setThemeState(next)
|
|
}, [])
|
|
|
|
const toggleTheme = useCallback(() => {
|
|
setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark')
|
|
}, [setTheme])
|
|
|
|
/** The Settings > Appearance "System" option: forget the explicit choice. */
|
|
const useSystemTheme = useCallback(() => {
|
|
try {
|
|
localStorage.removeItem(KEY)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
|
const next = dark ? 'dark' : 'light'
|
|
applyTheme(next, false)
|
|
setThemeState(next)
|
|
}, [])
|
|
|
|
const value = useMemo(
|
|
() => ({ theme, setTheme, toggleTheme, useSystemTheme, isExplicit: Boolean(getStoredTheme()) }),
|
|
[theme, setTheme, toggleTheme, useSystemTheme],
|
|
)
|
|
|
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
|
}
|