import React, { createContext, useContext, useEffect, useState, useMemo, FunctionComponent, useCallback, useRef, } from 'react' import { LocalStorage } from '@aviato/utils' import { themes } from '../theme/theme' import { ThemeProps } from '../theme/types' const MEDIA = '(prefers-color-scheme: dark)' const THEME_STORAGE_KEY = 'activeTheme' const defaultTheme = 'light' let baseActiveTheme = defaultTheme export const ThemeContext = createContext({ activeTheme: defaultTheme, setActiveTheme: () => {}, getNextTheme: () => defaultTheme, toggleTheme: () => {}, }) export const useTheme = () => useContext(ThemeContext) function storeThemeAndUpdateDOM(targetValue: string): void { Object.keys(themes).forEach((themeName) => { document.documentElement.classList.remove(themeName) }) document.documentElement.classList.add(themes[targetValue]) document.documentElement.setAttribute('data-theme', targetValue) setStoredTheme(targetValue) } export const ThemeProvider: FunctionComponent = ({ children }) => { const [storedTheme, setStoredTheme] = useState(baseActiveTheme) /** * Set active theme, load in storage, and update DOM */ const setActiveTheme = useCallback((targetTheme: string) => { baseActiveTheme = targetTheme setStoredTheme(baseActiveTheme) storeThemeAndUpdateDOM(targetTheme) }, []) /** * Get next available theme * * @returns string - next available theme */ function getNextTheme() { const availableThemes = Object.keys(themes) const currentIndex = availableThemes.indexOf(baseActiveTheme) const nextIndex = (currentIndex + 1) % availableThemes.length const nextAvailableTheme = availableThemes[nextIndex] return nextAvailableTheme } /** * Toggle to next available theme */ function toggleTheme() { const nextAvailableTheme = getNextTheme() setActiveTheme(nextAvailableTheme) } /** * Set theme value on initial load */ useEffect(() => { const initialTheme = getStoredTheme() const fallbackTheme = getSystemTheme() ?? defaultTheme setActiveTheme(initialTheme ?? fallbackTheme) }) /** * Check if local-storage is cleared */ useEffect(() => { const handleStorage = (event: StorageEvent) => { if (event.key !== THEME_STORAGE_KEY) { return } const themeName = event?.newValue const themeExists = themeName in themes if (themeExists) { setActiveTheme(themeName) } else { removeStoredTheme() } } window.addEventListener('storage', handleStorage) return () => { window.removeEventListener('storage', handleStorage) } }, [setActiveTheme]) /** * If missing local theme or system update, set theme to system */ const handleMediaQuery = useCallback( ({ isSystemUpdate = false }: any, event?) => { const systemTheme = getSystemTheme(event) const localTheme = getStoredTheme() if (isSystemUpdate || !localTheme) { setActiveTheme(systemTheme) } }, [storedTheme] ) // Ref hack to avoid adding handleMediaQuery as a dep const mediaListener = useRef(handleMediaQuery) mediaListener.current = handleMediaQuery useEffect(() => { // @ts-ignore const handler = (...args: any) => mediaListener.current({ isSystemUpdate: true }, ...args) // Listen to System preference const media = window.matchMedia(MEDIA) // Intentionally use deprecated listener methods to support iOS & old browsers media.addListener(handler) return () => media.removeListener(handler) }, []) const ProviderValue = useMemo( () => ({ activeTheme: storedTheme, setActiveTheme, getNextTheme, toggleTheme, }), [storedTheme] ) return ( {children} ) } const getStoredTheme: () => string | null = () => { return LocalStorage.getItem(THEME_STORAGE_KEY) } const setStoredTheme: (theme: string) => void = (theme: string) => { return LocalStorage.setItem(THEME_STORAGE_KEY, theme) } const removeStoredTheme = () => { return LocalStorage.removeItem(THEME_STORAGE_KEY) } const getSystemTheme = (event?: MediaQueryList) => { if (!event) { event = window.matchMedia(MEDIA) } const isDark = event.matches const systemTheme = isDark ? 'dark' : 'light' return systemTheme } ThemeProvider.displayName = 'ThemeProvider'