From 088b20bfdb3e8f22c90799c800b550a6760ef9e4 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Wed, 13 Aug 2025 09:14:07 -0600 Subject: [PATCH] Add a stable theme provider --- .../src/context/BusterStyles/BusterStyles.tsx | 7 +- .../BusterStyles/BusterThemeProvider.tsx | 124 ++++++++++++++++++ .../web-tss/src/context/BusterStyles/index.ts | 1 + .../src/hooks/useLocalStorageState.tsx | 7 +- 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 apps/web-tss/src/context/BusterStyles/BusterThemeProvider.tsx diff --git a/apps/web-tss/src/context/BusterStyles/BusterStyles.tsx b/apps/web-tss/src/context/BusterStyles/BusterStyles.tsx index be3204846..6a151b4b5 100644 --- a/apps/web-tss/src/context/BusterStyles/BusterStyles.tsx +++ b/apps/web-tss/src/context/BusterStyles/BusterStyles.tsx @@ -1,9 +1,14 @@ import type React from 'react'; import type { PropsWithChildren } from 'react'; import { BusterNotificationsProvider } from '../BusterNotifications/BusterNotifications'; +import { BusterThemeProvider } from './BusterThemeProvider'; // const ENABLE_DARK_MODE = false; export const BusterStyleProvider: React.FC = ({ children }) => { - return {children}; + return ( + + {children} + + ); }; diff --git a/apps/web-tss/src/context/BusterStyles/BusterThemeProvider.tsx b/apps/web-tss/src/context/BusterStyles/BusterThemeProvider.tsx new file mode 100644 index 000000000..eb613f35c --- /dev/null +++ b/apps/web-tss/src/context/BusterStyles/BusterThemeProvider.tsx @@ -0,0 +1,124 @@ +import type React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { createContext, useContextSelector } from 'use-context-selector'; +import { useLocalStorageState } from '../../hooks/useLocalStorageState'; + +// Define theme types +export type Theme = 'dark' | 'light' | 'system'; + +// Theme provider state type +export type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; + isDark: boolean; + isLight: boolean; + systemTheme: 'dark' | 'light'; +}; + +// Theme provider props +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +// Create the context +const ThemeProviderContext = createContext(null); + +// Theme provider component +export function BusterThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'buster-theme', +}: ThemeProviderProps) { + const [theme, setThemeState] = useLocalStorageState(storageKey, { + defaultValue: defaultTheme, + }); + + // Track system preference + const [systemTheme, setSystemTheme] = useState<'dark' | 'light'>(() => { + if (typeof window !== 'undefined') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return 'light'; + }); + + // Calculate actual theme (resolving 'system' to actual value) + const resolvedTheme = theme === 'system' ? systemTheme : theme; + const isDark = resolvedTheme === 'dark'; + const isLight = resolvedTheme === 'light'; + + // Apply theme to document root + useEffect(() => { + const root = window.document.documentElement; + + // Remove both classes first + root.classList.remove('light', 'dark'); + + // Add the appropriate class + root.classList.add(resolvedTheme); + }, [resolvedTheme]); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e: MediaQueryListEvent) => { + setSystemTheme(e.matches ? 'dark' : 'light'); + }; + + // Add event listener + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + // Theme setter that persists to localStorage + const setTheme = useCallback( + (newTheme: Theme) => { + localStorage.setItem(storageKey, newTheme); + setThemeState(newTheme); + }, + [storageKey, setThemeState] + ); + + // Context value + const value: ThemeProviderState = useMemo( + () => ({ + theme, + setTheme, + isDark, + isLight, + systemTheme, + }), + [theme, setTheme, isDark, isLight, systemTheme] + ); + + return {children}; +} + +// Selector hook with type safety +export function useBusterTheme(selector: (state: ThemeProviderState) => T): T { + return useContextSelector(ThemeProviderContext, (state) => { + if (!state) { + throw new Error('useBusterTheme must be used within a BusterThemeProvider'); + } + return selector(state); + }); +} + +const selectTheme = (state: ThemeProviderState) => state.theme; +const selectSetTheme = (state: ThemeProviderState) => state.setTheme; +const selectIsDark = (state: ThemeProviderState) => state.isDark; +const selectIsLight = (state: ThemeProviderState) => state.isLight; +const selectSystemTheme = (state: ThemeProviderState) => state.systemTheme; +const selectThemeState = (state: ThemeProviderState) => state; + +export const useTheme = () => useBusterTheme(selectTheme); +export const useSetTheme = () => useBusterTheme(selectSetTheme); +export const useIsDarkTheme = () => useBusterTheme(selectIsDark); +export const useIsLightTheme = () => useBusterTheme(selectIsLight); +export const useSystemTheme = () => useBusterTheme(selectSystemTheme); +export const useThemeState = () => useBusterTheme(selectThemeState); diff --git a/apps/web-tss/src/context/BusterStyles/index.ts b/apps/web-tss/src/context/BusterStyles/index.ts index ff18ee060..95aa3c0c4 100644 --- a/apps/web-tss/src/context/BusterStyles/index.ts +++ b/apps/web-tss/src/context/BusterStyles/index.ts @@ -1 +1,2 @@ export * from './BusterStyles'; +export * from './BusterThemeProvider'; diff --git a/apps/web-tss/src/hooks/useLocalStorageState.tsx b/apps/web-tss/src/hooks/useLocalStorageState.tsx index 4b92c95a1..7680f682c 100644 --- a/apps/web-tss/src/hooks/useLocalStorageState.tsx +++ b/apps/web-tss/src/hooks/useLocalStorageState.tsx @@ -3,7 +3,6 @@ import { isServer } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useMemoizedFn } from './useMemoizedFn'; -import { useMount } from './useMount'; type SetState = S | ((prevState?: S) => S); @@ -27,7 +26,7 @@ interface Options { export function useLocalStorageState( key: string, options?: Options -): [T | undefined, (value?: SetState) => void] { +): [T, (value?: SetState) => void] { const { defaultValue, serializer = JSON.stringify, @@ -92,7 +91,7 @@ export function useLocalStorageState( } }); - const [state, setState] = useState(getInitialValue); + const [state, setState] = useState(getInitialValue as T); // Initialize state from localStorage on mount // useMount(() => { @@ -126,7 +125,7 @@ export function useLocalStorageState( return newState; }); } else { - setState(value); + setState(value as T); } } catch (error) { onError?.(error);