102 lines
3.8 KiB
TypeScript
102 lines
3.8 KiB
TypeScript
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
|
|
|
/**
|
|
* Theme: 'light' (Earth) | 'dark' (Claude-warm) | 'system' (follow prefers-color-scheme).
|
|
*
|
|
* <p>Persist'ится в {@code localStorage[STORAGE_KEY]}. На mount читает saved
|
|
* preference или fallback'ает к 'system'. Меняет {@code data-theme} attr на
|
|
* {@code <html>} → CSS custom properties в styles.css переключают палитру.
|
|
*
|
|
* <p>{@code system} mode — если user ничего не выбрал, следим за prefers-color-scheme
|
|
* media query. matchMedia listener обновляет attr реально-time при OS theme change.
|
|
*/
|
|
|
|
const STORAGE_KEY = 'ord-theme'
|
|
|
|
export type ThemePreference = 'light' | 'dark' | 'system'
|
|
export type ResolvedTheme = 'light' | 'dark'
|
|
|
|
type ThemeContextValue = {
|
|
preference: ThemePreference
|
|
resolved: ResolvedTheme
|
|
setPreference: (next: ThemePreference) => void
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
|
|
function readSystemPreference(): ResolvedTheme {
|
|
if (typeof window === 'undefined') return 'light'
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
}
|
|
|
|
function readSavedPreference(): ThemePreference {
|
|
if (typeof window === 'undefined') return 'system'
|
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
if (raw === 'light' || raw === 'dark' || raw === 'system') return raw
|
|
return 'system'
|
|
}
|
|
|
|
function applyToDom(theme: ResolvedTheme) {
|
|
if (typeof document === 'undefined') return
|
|
document.documentElement.setAttribute('data-theme', theme)
|
|
// shadcn convention — добавляем .dark class тоже на случай third-party
|
|
// компонентов которые ждут именно его. Custom variant в styles.css всё равно
|
|
// их связывает, но эта мера держит совместимость на корню.
|
|
if (theme === 'dark') {
|
|
document.documentElement.classList.add('dark')
|
|
} else {
|
|
document.documentElement.classList.remove('dark')
|
|
}
|
|
// <meta name="color-scheme"> чтобы system browser UI (scrollbars, autofill)
|
|
// был в гармонии с темой.
|
|
let meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]')
|
|
if (!meta) {
|
|
meta = document.createElement('meta')
|
|
meta.name = 'color-scheme'
|
|
document.head.appendChild(meta)
|
|
}
|
|
meta.content = theme
|
|
}
|
|
|
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
const [preference, setPreferenceState] = useState<ThemePreference>(() => readSavedPreference())
|
|
const [systemResolved, setSystemResolved] = useState<ResolvedTheme>(() => readSystemPreference())
|
|
|
|
// Слушаем OS theme change при mode='system'.
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return
|
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
const handler = () => setSystemResolved(mq.matches ? 'dark' : 'light')
|
|
mq.addEventListener('change', handler)
|
|
return () => mq.removeEventListener('change', handler)
|
|
}, [])
|
|
|
|
const resolved: ResolvedTheme = preference === 'system' ? systemResolved : preference
|
|
|
|
// Применяем к DOM каждый раз когда resolved меняется.
|
|
useEffect(() => {
|
|
applyToDom(resolved)
|
|
}, [resolved])
|
|
|
|
const setPreference = (next: ThemePreference) => {
|
|
setPreferenceState(next)
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem(STORAGE_KEY, next)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ThemeContext.Provider value={{ preference, resolved, setPreference }}>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useTheme() {
|
|
const ctx = useContext(ThemeContext)
|
|
if (!ctx) {
|
|
throw new Error('useTheme must be used inside <ThemeProvider>')
|
|
}
|
|
return ctx
|
|
}
|