import {
useCallback, useEffect, useReducer, useState,
} from 'react';
import { SELECTED_THEME_VARIANT_KEY } from '../../constants';
import { logError } from '../../../logging';
import { paragonThemeActions, paragonThemeReducer } from '../../reducers';
import { isEmptyObject } from './utils';
import useParagonThemeCore from './useParagonThemeCore';
import useParagonThemeUrls from './useParagonThemeUrls';
import useParagonThemeVariants from './useParagonThemeVariants';
/**
* Finds the default theme variant from the given theme variants object. If no default theme exists, the light theme
* variant is returned as a fallback.
*
* It prioritizes:
* 1. A persisted theme variant from localStorage.
* 2. A system preference (`prefers-color-scheme`).
* 3. The configured default theme variant.
*
* @param {Object.<string, ParagonThemeVariant>|undefined} themeVariants - An object where the keys are theme variant
* names (e.g., "light", "dark") and the values are objects containing URLs for theme CSS files.
* @param {Object} [options.themeVariantDefaults={}] - An object containing default theme variant preferences.
*
* @returns {Object|undefined} The default theme variant, or `undefined` if no valid theme variant is found.
*
*/
export const getDefaultThemeVariant = ({ themeVariants, themeVariantDefaults = {} }) => {
if (!themeVariants) {
return undefined;
}
const themeVariantKeys = Object.keys(themeVariants);
// If there is only one theme variant, return it since it's the only one that may be used.
if (themeVariantKeys.length === 1) {
const themeVariantKey = themeVariantKeys[0];
return {
name: themeVariantKey,
metadata: themeVariants[themeVariantKey],
};
}
// Prioritize persisted localStorage theme variant preference.
const persistedSelectedParagonThemeVariant = localStorage.getItem(SELECTED_THEME_VARIANT_KEY);
if (persistedSelectedParagonThemeVariant && themeVariants[persistedSelectedParagonThemeVariant]) {
return {
name: persistedSelectedParagonThemeVariant,
metadata: themeVariants[persistedSelectedParagonThemeVariant],
};
}
// Then, detect system preference via `prefers-color-scheme` media query and use
// the default dark theme variant, if one exists.
const hasDarkSystemPreference = !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches;
const defaultDarkThemeVariant = themeVariantDefaults.dark;
const darkThemeVariantMetadata = themeVariants[defaultDarkThemeVariant];
if (hasDarkSystemPreference && defaultDarkThemeVariant && darkThemeVariantMetadata) {
return {
name: defaultDarkThemeVariant,
metadata: darkThemeVariantMetadata,
};
}
const defaultLightThemeVariant = themeVariantDefaults.light;
const lightThemeVariantMetadata = themeVariants[defaultLightThemeVariant];
// Handle edge case where the default light theme variant is not configured or provided.
if (!defaultLightThemeVariant || !lightThemeVariantMetadata) {
return undefined;
}
// Otherwise, fallback to using the default light theme variant as configured.
return {
name: defaultLightThemeVariant,
metadata: lightThemeVariantMetadata,
};
};
/**
* A custom React hook that manages the application's theme state and injects the appropriate CSS for the theme core
* and theme variants (e.g., light and dark modes) into the HTML document. It handles dynamically loading the theme
* CSS based on the current theme variant, and ensures that the theme variant's CSS is preloaded for runtime theme
* switching.This is done using "alternate" stylesheets. That is, the browser will download the CSS for the
* non-current theme variants with a lower priority than the current one.
*
* The hook also responds to system theme preference changes (e.g., via the `prefers-color-scheme` media query),
* and can automatically switch the theme based on the system's dark mode or light mode preference.
*
* @memberof module:React
*
* @returns {Array} - An array containing:
* 1. An object representing the current theme state.
* 2. A dispatch function to mutate the app theme state (e.g., change the theme variant).
*
* * @example
* const [themeState, dispatch] = useParagonTheme();
* console.log(themeState.isThemeLoaded); // true when the theme has been successfully loaded.
*
* // Dispatch an action to change the theme variant
* dispatch(paragonThemeActions.setParagonThemeVariant('dark'));
*/
const useParagonTheme = () => {
const paragonThemeUrls = useParagonThemeUrls();
const {
core: themeCore,
defaults: themeVariantDefaults,
variants: themeVariants,
} = paragonThemeUrls || {};
const initialParagonThemeState = {
isThemeLoaded: false,
themeVariant: getDefaultThemeVariant({ themeVariants, themeVariantDefaults })?.name,
};
const [themeState, dispatch] = useReducer(paragonThemeReducer, initialParagonThemeState);
const [isCoreThemeLoaded, setIsCoreThemeLoaded] = useState(false);
const onLoadThemeCore = useCallback(() => {
setIsCoreThemeLoaded(true);
}, []);
const [hasLoadedThemeVariants, setHasLoadedThemeVariants] = useState(false);
const onLoadThemeVariants = useCallback(() => {
setHasLoadedThemeVariants(true);
}, []);
// load the core theme CSS
useParagonThemeCore({
themeCore,
onComplete: onLoadThemeCore,
});
// respond to system preference changes with regard to `prefers-color-scheme: dark`.
const handleDarkModeSystemPreferenceChange = useCallback((prefersDarkMode) => {
// Ignore system preference change if the theme variant is already set in localStorage.
if (localStorage.getItem(SELECTED_THEME_VARIANT_KEY)) {
return;
}
if (prefersDarkMode && themeVariantDefaults?.dark) {
dispatch(paragonThemeActions.setParagonThemeVariant(themeVariantDefaults.dark));
} else if (!prefersDarkMode && themeVariantDefaults?.light) {
dispatch(paragonThemeActions.setParagonThemeVariant(themeVariantDefaults.light));
} else {
logError(`Could not set theme variant based on system preference (prefers dark mode: ${prefersDarkMode})`, themeVariantDefaults, themeVariants);
}
}, [themeVariantDefaults, themeVariants]);
// load the theme variant(s) CSS
useParagonThemeVariants({
themeVariants,
onComplete: onLoadThemeVariants,
currentThemeVariant: themeState.themeVariant,
onDarkModeSystemPreferenceChange: handleDarkModeSystemPreferenceChange,
});
useEffect(() => {
// theme is already loaded, do nothing
if (themeState.isThemeLoaded) {
return;
}
const hasThemeConfig = (themeCore?.urls && !isEmptyObject(themeVariants));
if (!hasThemeConfig) {
// no theme URLs to load, set loading to false.
dispatch(paragonThemeActions.setParagonThemeLoaded(true));
}
// Return early if neither the core theme CSS nor any theme variant CSS is loaded.
if (!isCoreThemeLoaded || !hasLoadedThemeVariants) {
return;
}
// All application theme URLs are loaded
dispatch(paragonThemeActions.setParagonThemeLoaded(true));
}, [
themeState.isThemeLoaded,
isCoreThemeLoaded,
hasLoadedThemeVariants,
themeCore?.urls,
themeVariants,
]);
return [themeState, dispatch];
};
export default useParagonTheme;