diff --git a/app/components/theme.tsx b/app/components/theme.tsx new file mode 100644 index 0000000..5679a9a --- /dev/null +++ b/app/components/theme.tsx @@ -0,0 +1,30 @@ +import { Theme, type ThemeType, useTheme } from '#/context/providers/theme-provider' +import { clx } from '#/utils/ui-helper' + +export default function ThemeSwitcher() { + const [theme, setTheme] = useTheme() + + const handleThemeChange = (event: React.ChangeEvent) => { + setTheme(event.target.value as ThemeType) + } + + return ( +
+ +
+ ) +} diff --git a/app/context/providers/theme-provider.tsx b/app/context/providers/theme-provider.tsx new file mode 100644 index 0000000..d0a3a14 --- /dev/null +++ b/app/context/providers/theme-provider.tsx @@ -0,0 +1,123 @@ +import { useFetcher, useRevalidator } from '@remix-run/react' +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import type { Dispatch, SetStateAction } from 'react' +import type { EnumValues } from '#/utils/common' + +const Theme = { + DARK: 'dark', + LIGHT: 'light', + SYSTEM: 'system', +} as const + +type ThemeType = EnumValues + +type ThemeContextType = [ThemeType | null, Dispatch>] + +const ThemeContext = createContext(undefined) + +const prefersLightMQ = '(prefers-color-scheme: light)' + +const getTheme = (): ThemeType => { + if (typeof window === 'undefined') return Theme.SYSTEM + return window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK +} + +interface ThemeProviderProps { + children: React.ReactNode + specifiedTheme?: ThemeType +} + +function useThemeEffect(theme: ThemeType | null) { + const { revalidate } = useRevalidator() + const persistTheme = useFetcher() + + const setThemeClass = useCallback((newTheme: ThemeType) => { + document.documentElement.classList.remove(Theme.LIGHT, Theme.DARK) + document.documentElement.classList.add(newTheme) + }, []) + + // biome-ignore lint/correctness/useExhaustiveDependencies: render once + useEffect(() => { + if (theme) { + persistTheme.submit({ theme }, { action: 'set-theme', method: 'POST' }) + const resolvedTheme = theme === Theme.SYSTEM ? getTheme() : theme + setThemeClass(resolvedTheme) + } + }, [theme]) + + useEffect(() => { + const mediaQuery = window.matchMedia(prefersLightMQ) + const handleChange = () => { + if (theme === Theme.SYSTEM) { + setThemeClass(getTheme()) + } + revalidate() + } + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, [theme, revalidate, setThemeClass]) +} + +function ThemeProvider({ children, specifiedTheme }: ThemeProviderProps) { + const [theme, setTheme] = useState(() => { + if (specifiedTheme && Object.values(Theme).includes(specifiedTheme)) { + return specifiedTheme + } + return null + }) + + const mountRun = useRef(false) + + useEffect(() => { + if (!mountRun.current) { + mountRun.current = true + setTheme(specifiedTheme ?? getTheme()) + } + }, [specifiedTheme]) + + useThemeEffect(theme) + + return {children} +} + +function NonFlashOfWrongThemeEls({ ssrTheme }: { ssrTheme: boolean }) { + const [theme] = useTheme() + const resolvedTheme = theme === Theme.SYSTEM ? getTheme() : theme + const colorScheme = + resolvedTheme === Theme.LIGHT ? 'light' : resolvedTheme === Theme.DARK ? 'dark' : 'light dark' + + const setThemeScript = ` + (function() { + const theme = ${JSON.stringify(resolvedTheme)}; + const cl = document.documentElement.classList; + cl.remove('light', 'dark'); + cl.add(theme); + })(); + ` + + return ( + <> + + {!ssrTheme && ( +