HR-ATS-Portal/frontend/src/theme/ThemeProvider.jsx

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>
}