import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ThemeContext } from '../context/theme-context.tsx';
/**
* Storage key for persisting theme preference in localStorage
*/
const THEME_STORAGE_KEY = 'ably-chat-ui-theme';
/**
* Supported theme types
*/
export type ThemeType = 'light' | 'dark';
/**
* Callback function type for theme change events
*/
export type ThemeChangeCallback = (theme: ThemeType, previousTheme: ThemeType) => void;
/**
* Configuration options for theme management
*/
export interface ThemeOptions {
/**
* Whether to persist theme preference to localStorage
* @default true
*/
persist?: boolean;
/**
* Whether to detect and use system theme preference
* @default true
*/
detectSystemTheme?: boolean;
/**
* Initial theme to use if no preference is found
* @default 'light'
*/
defaultTheme?: ThemeType;
}
/**
* Props for the ThemeProvider component
*/
export interface ThemeProviderProps {
/**
* Child components that will have access to the theme context
*/
children: ReactNode;
/**
* Configuration options for theme management
*/
options?: ThemeOptions;
/**
* Callback fired when the theme changes
*/
onThemeChange?: ThemeChangeCallback;
}
/**
* ThemeProvider manages theme state, persistence, and system integration across the application
*
* Features:
* - Light/dark theme management with type safety
* - Persistent theme preference in localStorage
* - System theme preference detection and integration
* - Change notifications for theme updates
* - Performance optimizations with memoization
* - Accessibility support with proper DOM updates
*
* TODO: Adding more themes for:
* - High contrast mode for accessibility
* - Custom user/brand themes
*
* @example
* // Basic usage
*
*
*
*
* @example
* // With custom configuration
* {
* console.log(`Theme changed from ${prev} to ${theme}`);
* }}
* >
*
*
*/
export const ThemeProvider = ({
children,
options = {},
onThemeChange: externalOnThemeChange,
}: ThemeProviderProps) => {
const { persist = true, detectSystemTheme = true, defaultTheme = 'light' } = options;
const [theme, setThemeState] = useState(defaultTheme);
const [changeCallbacks, setChangeCallbacks] = useState>(new Set());
const isInitialized = useRef(false);
/**
* Detects the system theme preference
*/
const supportsSystemTheme =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
globalThis.window !== undefined && typeof globalThis.window.matchMedia === 'function';
const getSystemTheme = useCallback<() => ThemeType | undefined>(() => {
if (!supportsSystemTheme) {
return; // SSR / old browser → not available
}
return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}, [supportsSystemTheme]);
/**
* Notifies all registered callbacks about theme changes
*/
const notifyThemeChange = useCallback(
(newTheme: ThemeType, previousTheme: ThemeType) => {
if (newTheme === previousTheme) return;
for (const callback of changeCallbacks) {
try {
callback(newTheme, previousTheme);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error in theme change callback:', error);
}
}
}
// Notify external callback if provided
if (externalOnThemeChange) {
try {
externalOnThemeChange(newTheme, previousTheme);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error in external theme change callback:', error);
}
}
}
},
[changeCallbacks, externalOnThemeChange]
);
/**
* Sets the theme with change notifications and persistence
*/
const setTheme = useCallback(
(newTheme: ThemeType) => {
setThemeState((prevTheme) => {
if (prevTheme !== newTheme) {
// Persist theme preference
if (persist) {
try {
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to persist theme preference:', error);
}
}
}
// Notify change
notifyThemeChange(newTheme, prevTheme);
}
return newTheme;
});
},
[persist, notifyThemeChange]
);
/**
* Toggles between light and dark themes
*/
const toggleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dark' : 'light');
}, [theme, setTheme]);
/**
* Resets theme to system preference or default
*/
const resetToSystemTheme = useCallback(() => {
const systemTheme = getSystemTheme();
setTheme(systemTheme || defaultTheme);
}, [getSystemTheme, setTheme, defaultTheme]);
/**
* Registers a callback for theme change events
*/
const onThemeChange = useCallback((callback: ThemeChangeCallback) => {
setChangeCallbacks((prev) => new Set(prev).add(callback));
return () => {
setChangeCallbacks((prev) => {
const newSet = new Set(prev);
newSet.delete(callback);
return newSet;
});
};
}, []);
// Initialize theme from localStorage or system preference
useEffect(() => {
if (isInitialized.current) return;
let initialTheme = defaultTheme;
// Try to get saved theme preference
if (persist) {
try {
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme === 'light' || savedTheme === 'dark') {
initialTheme = savedTheme;
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to load saved theme preference:', error);
}
}
}
// Fall back to system theme if no saved preference and detection is enabled
if ((detectSystemTheme && !persist) || (persist && !localStorage.getItem(THEME_STORAGE_KEY))) {
const systemTheme = getSystemTheme();
if (systemTheme) {
initialTheme = systemTheme;
}
}
setThemeState(initialTheme);
isInitialized.current = true;
}, [persist, detectSystemTheme, defaultTheme, getSystemTheme]);
// Listen for system theme changes
useEffect(() => {
if (!supportsSystemTheme) return;
const mql = globalThis.matchMedia('(prefers-color-scheme: dark)');
const handle = (e: MediaQueryListEvent) => {
setTheme(e.matches ? 'dark' : 'light');
};
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handle);
return () => {
mql.removeEventListener('change', handle);
};
}
// eslint-disable-next-line @typescript-eslint/no-deprecated
mql.addListener(handle);
return () => {
// eslint-disable-next-line @typescript-eslint/no-deprecated
mql.removeListener(handle);
};
}, [setTheme, supportsSystemTheme]);
// Apply theme to document element
useEffect(() => {
if (!isInitialized.current) return;
const root = document.documentElement;
// Update data attribute for CSS targeting
root.dataset.theme = theme;
// Update class for Tailwind dark mode
root.classList.toggle('dark', theme === 'dark');
// Update meta theme-color for mobile browsers
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', theme === 'dark' ? '#1f2937' : '#ffffff');
}
}, [theme]);
// Derived state
const isDark = useMemo(() => theme === 'dark', [theme]);
const isLight = useMemo(() => theme === 'light', [theme]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(
() => ({
theme,
toggleTheme,
setTheme,
isDark,
isLight,
supportsSystemTheme,
getSystemTheme,
resetToSystemTheme,
onThemeChange,
}),
[
theme,
toggleTheme,
setTheme,
isDark,
isLight,
supportsSystemTheme,
getSystemTheme,
resetToSystemTheme,
onThemeChange,
]
);
return {children};
};