Add a stable theme provider

This commit is contained in:
Nate Kelley 2025-08-13 09:14:07 -06:00
parent 4dcf3ac677
commit 088b20bfdb
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
4 changed files with 134 additions and 5 deletions

View File

@ -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<PropsWithChildren> = ({ children }) => {
return <BusterNotificationsProvider>{children}</BusterNotificationsProvider>;
return (
<BusterThemeProvider>
<BusterNotificationsProvider>{children}</BusterNotificationsProvider>
</BusterThemeProvider>
);
};

View File

@ -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<ThemeProviderState | null>(null);
// Theme provider component
export function BusterThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'buster-theme',
}: ThemeProviderProps) {
const [theme, setThemeState] = useLocalStorageState<Theme>(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 <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>;
}
// Selector hook with type safety
export function useBusterTheme<T>(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);

View File

@ -1 +1,2 @@
export * from './BusterStyles';
export * from './BusterThemeProvider';

View File

@ -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> = S | ((prevState?: S) => S);
@ -27,7 +26,7 @@ interface Options<T> {
export function useLocalStorageState<T>(
key: string,
options?: Options<T>
): [T | undefined, (value?: SetState<T>) => void] {
): [T, (value?: SetState<T>) => void] {
const {
defaultValue,
serializer = JSON.stringify,
@ -92,7 +91,7 @@ export function useLocalStorageState<T>(
}
});
const [state, setState] = useState<T | undefined>(getInitialValue);
const [state, setState] = useState<T>(getInitialValue as T);
// Initialize state from localStorage on mount
// useMount(() => {
@ -126,7 +125,7 @@ export function useLocalStorageState<T>(
return newState;
});
} else {
setState(value);
setState(value as T);
}
} catch (error) {
onError?.(error);