diff --git a/src/react/hooks.js b/src/react/hooks.js deleted file mode 100644 index 7328b5907..000000000 --- a/src/react/hooks.js +++ /dev/null @@ -1,652 +0,0 @@ -import { - useCallback, useEffect, useState, useReducer, useMemo, -} from 'react'; -import { subscribe, unsubscribe } from '../pubSub'; -import { sendTrackEvent } from '../analytics'; -import { paragonThemeReducer, paragonThemeActions } from './reducers'; -import { logError, logInfo } from '../logging'; -import { getConfig } from '../config'; -import { SELECTED_THEME_VARIANT_KEY } from './constants'; - -/** - * A React hook that allows functional components to subscribe to application events. This should - * be used sparingly - for the most part, Context should be used higher-up in the application to - * provide necessary data to a given component, rather than utilizing a non-React-like Pub/Sub - * mechanism. - * - * @memberof module:React - * @param {string} type - * @param {function} callback - */ -export const useAppEvent = (type, callback) => { - useEffect(() => { - const subscriptionToken = subscribe(type, callback); - - return function cleanup() { - unsubscribe(subscriptionToken); - }; - }, [callback, type]); -}; - -/** - * A React hook that tracks user's preferred color scheme (light or dark) and sends respective - * event to the tracking service. - * - * @memberof module:React - */ -export const useTrackColorSchemeChoice = () => { - useEffect(() => { - const trackColorSchemeChoice = ({ matches }) => { - const preferredColorScheme = matches ? 'dark' : 'light'; - sendTrackEvent('openedx.ui.frontend-platform.prefers-color-scheme.selected', { preferredColorScheme }); - }; - const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); - if (colorSchemeQuery) { - // send user's initial choice - trackColorSchemeChoice(colorSchemeQuery); - colorSchemeQuery.addEventListener('change', trackColorSchemeChoice); - } - return () => { - if (colorSchemeQuery) { - colorSchemeQuery.removeEventListener('change', trackColorSchemeChoice); - } - }; - }, []); -}; - -const removeExistingLinks = (existingLinks) => { - existingLinks.forEach((link) => { - link.remove(); - }); -}; - -/** - * Adds/updates a `` element in the HTML document to load the core application theme CSS. - * - * @memberof module:React - * @param {object} args - * @param {object} args.themeCore Object representing the core Paragon theme CSS. - * @param {string} args.onLoad A callback function called when the core theme CSS is loaded. - */ -export const useParagonThemeCore = ({ - themeCore, - onLoad, -}) => { - const [isParagonThemeCoreLoaded, setIsParagonThemeCoreLoaded] = useState(false); - const [isBrandThemeCoreLoaded, setIsBrandThemeCoreLoaded] = useState(false); - - useEffect(() => { - // Call `onLoad` once both the paragon and brand theme core are loaded. - if (isParagonThemeCoreLoaded && isBrandThemeCoreLoaded) { - onLoad(); - } - }, [isParagonThemeCoreLoaded, isBrandThemeCoreLoaded, onLoad]); - - useEffect(() => { - // If there is no config for the core theme url, do nothing. - if (!themeCore?.urls) { - setIsParagonThemeCoreLoaded(true); - setIsBrandThemeCoreLoaded(true); - return; - } - const getParagonThemeCoreLink = () => document.head.querySelector('link[data-paragon-theme-core="true"'); - const existingCoreThemeLink = document.head.querySelector(`link[href='${themeCore.urls.default}']`); - if (!existingCoreThemeLink) { - const getExistingCoreThemeLinks = (isBrandOverride) => { - const coreThemeLinkSelector = `link[data-${isBrandOverride ? 'brand' : 'paragon'}-theme-core="true"]`; - return document.head.querySelectorAll(coreThemeLinkSelector); - }; - const createCoreThemeLink = ( - url, - { - isFallbackThemeUrl = false, - isBrandOverride = false, - } = {}, - ) => { - let coreThemeLink = document.createElement('link'); - coreThemeLink.href = url; - coreThemeLink.rel = 'stylesheet'; - if (isBrandOverride) { - coreThemeLink.dataset.brandThemeCore = true; - } else { - coreThemeLink.dataset.paragonThemeCore = true; - } - coreThemeLink.onload = () => { - if (isBrandOverride) { - setIsBrandThemeCoreLoaded(true); - } else { - setIsParagonThemeCoreLoaded(true); - } - }; - coreThemeLink.onerror = () => { - logError(`Failed to load core theme CSS from ${url}`); - if (isFallbackThemeUrl) { - logError(`Could not load core theme CSS from ${url} or fallback URL. Aborting.`); - if (isBrandOverride) { - setIsBrandThemeCoreLoaded(true); - } else { - setIsParagonThemeCoreLoaded(true); - } - const otherExistingLinks = getExistingCoreThemeLinks(isBrandOverride); - removeExistingLinks(otherExistingLinks); - return; - } - const paragonThemeAccessor = isBrandOverride ? 'brand' : 'paragon'; - const themeUrls = PARAGON_THEME?.[paragonThemeAccessor]?.themeUrls ?? {}; - if (themeUrls.core) { - const coreThemeFallbackUrl = `${getConfig().BASE_URL}/${themeUrls.core.fileName}`; - logInfo(`Falling back to locally installed core theme CSS: ${coreThemeFallbackUrl}`); - coreThemeLink = createCoreThemeLink(coreThemeFallbackUrl, { isFallbackThemeUrl: true, isBrandOverride }); - const otherExistingLinks = getExistingCoreThemeLinks(isBrandOverride); - removeExistingLinks(otherExistingLinks); - const foundParagonThemCoreLink = getParagonThemeCoreLink(); - if (foundParagonThemCoreLink) { - foundParagonThemCoreLink.insertAdjacentElement( - 'afterend', - coreThemeLink, - ); - } else { - document.head.insertAdjacentElement( - 'afterbegin', - coreThemeLink, - ); - } - } else { - logError(`Failed to load core theme CSS from ${url} or fallback URL. Aborting.`); - } - }; - return coreThemeLink; - }; - - const paragonCoreThemeLink = createCoreThemeLink(themeCore.urls.default); - document.head.insertAdjacentElement( - 'afterbegin', - paragonCoreThemeLink, - ); - - if (themeCore.urls.brandOverride) { - const brandCoreThemeLink = createCoreThemeLink(themeCore.urls.brandOverride, { isBrandOverride: true }); - const foundParagonThemeCoreLink = getParagonThemeCoreLink(); - if (foundParagonThemeCoreLink) { - foundParagonThemeCoreLink.insertAdjacentElement( - 'afterend', - brandCoreThemeLink, - ); - } else { - document.head.insertAdjacentElement( - 'afterbegin', - brandCoreThemeLink, - ); - } - } else { - setIsBrandThemeCoreLoaded(true); - } - } - }, [themeCore?.urls, onLoad]); -}; - -/** - * Adds/updates a `` element in the HTML document to load each theme variant's CSS, setting the - * non-current theme variants as "alternate" stylesheets. That is, the browser will still download - * the CSS for the non-current theme variants, but at a lower priority than the current theme - * variant's CSS. This ensures that if the theme variant is changed at runtime, the CSS for the new - * theme variant will already be loaded. - * - * @memberof module:React - * @param {object} args - * @param {object} [args.themeVariants] An object containing the URLs for each supported theme variant, e.g.: `{ light: { url: 'https://path/to/light.css' } }`. - * @param {string} [args.currentThemeVariant] The currently applied theme variant, e.g.: `light`. - * @param {string} args.onLoad A callback function called when the theme variant(s) CSS is loaded. - */ -const useParagonThemeVariants = ({ - themeVariants, - currentThemeVariant, - onLoad, - onDarkModeSystemPreferenceChange, -}) => { - const [isParagonThemeVariantLoaded, setIsParagonThemeVariantLoaded] = useState(false); - const [isBrandThemeVariantLoaded, setIsBrandThemeVariantLoaded] = useState(false); - - useEffect(() => { - const someFn = (colorSchemeQuery) => { - onDarkModeSystemPreferenceChange(colorSchemeQuery.matches); - }; - const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); - if (colorSchemeQuery) { - colorSchemeQuery.addEventListener('change', someFn); - } - return () => { - if (colorSchemeQuery) { - colorSchemeQuery.removeEventListener('change', someFn); - } - }; - }, [onDarkModeSystemPreferenceChange]); - - useEffect(() => { - if (currentThemeVariant && themeVariants?.[currentThemeVariant]) { - const htmlDataThemeVariantAttr = 'data-paragon-theme-variant'; - document.querySelector('html').setAttribute(htmlDataThemeVariantAttr, currentThemeVariant); - return () => { - document.querySelector('html').removeAttribute(htmlDataThemeVariantAttr); - }; - } - return () => {}; // no-op - }, [themeVariants, currentThemeVariant]); - - useEffect(() => { - // Call `onLoad` once both the paragon and brand theme variant are loaded. - if (isParagonThemeVariantLoaded && isBrandThemeVariantLoaded) { - onLoad(); - } - }, [isParagonThemeVariantLoaded, isBrandThemeVariantLoaded, onLoad]); - - useEffect(() => { - if (!themeVariants) { - return; - } - - /** - * Determines the value for the `rel` attribute for a given theme variant based - * on if its the currently applied variant. - */ - const generateStylesheetRelAttr = (themeVariant) => (currentThemeVariant === themeVariant ? 'stylesheet' : 'alternate stylesheet'); - - // Iterate over each theme variant URL and inject it into the HTML document, if it doesn't already exist. - Object.entries(themeVariants).forEach(([themeVariant, value]) => { - // If there is no config for the theme variant URL, set the theme variant to loaded and continue. - if (!value.urls) { - setIsParagonThemeVariantLoaded(true); - setIsBrandThemeVariantLoaded(true); - return; - } - const getParagonThemeVariantLink = () => document.head.querySelector(`link[data-paragon-theme-variant='${themeVariant}']`); - const existingThemeVariantLink = document.head.querySelector(`link[href='${value.urls.default}']`); - const existingThemeVariantBrandLink = document.head.querySelector(`link[href='${value.urls.brandOverride}']`); - - const getExistingThemeVariantLinks = (isBrandOverride) => { - const themeVariantLinkSelector = `link[data-${isBrandOverride ? 'brand' : 'paragon'}-theme-variant='${themeVariant}']`; - return document.head.querySelectorAll(themeVariantLinkSelector); - }; - - const createThemeVariantLink = ( - url, - { - isFallbackThemeUrl = false, - isBrandOverride = false, - } = {}, - ) => { - let themeVariantLink = document.createElement('link'); - themeVariantLink.href = url; - themeVariantLink.rel = generateStylesheetRelAttr(themeVariant); - if (isBrandOverride) { - themeVariantLink.dataset.brandThemeVariant = themeVariant; - } else { - themeVariantLink.dataset.paragonThemeVariant = themeVariant; - } - - themeVariantLink.onload = () => { - if (themeVariant === currentThemeVariant) { - if (isBrandOverride) { - setIsBrandThemeVariantLoaded(true); - } else { - setIsParagonThemeVariantLoaded(true); - } - } - }; - - themeVariantLink.onerror = () => { - logError(`Failed to load theme variant (${themeVariant}) CSS from ${value.urls.default}`); - if (isFallbackThemeUrl) { - logError(`Could not load theme variant (${themeVariant}) CSS from fallback URL. Aborting.`); - if (isBrandOverride) { - setIsBrandThemeVariantLoaded(true); - } else { - setIsParagonThemeVariantLoaded(true); - } - const otherExistingLinks = getExistingThemeVariantLinks(isBrandOverride); - removeExistingLinks(otherExistingLinks); - return; - } - const paragonThemeAccessor = isBrandOverride ? 'brand' : 'paragon'; - const themeUrls = PARAGON_THEME?.[paragonThemeAccessor]?.themeUrls ?? {}; - if (themeUrls.variants && themeUrls.variants[themeVariant]) { - const themeVariantFallbackUrl = `${getConfig().BASE_URL}/${themeUrls.variants[themeVariant].fileName}`; - logInfo(`Falling back to locally installed theme variant (${themeVariant}) CSS: ${themeVariantFallbackUrl}`); - themeVariantLink = createThemeVariantLink(themeVariantFallbackUrl, { - isFallbackThemeUrl: true, - isBrandOverride, - }); - const otherExistingLinks = getExistingThemeVariantLinks(isBrandOverride); - removeExistingLinks(otherExistingLinks); - const foundParagonThemeVariantLink = getParagonThemeVariantLink(); - if (foundParagonThemeVariantLink) { - foundParagonThemeVariantLink.insertAdjacentElement( - 'afterend', - themeVariantLink, - ); - } else { - document.head.insertAdjacentElement( - 'afterbegin', - themeVariantLink, - ); - } - } else { - logError(`Failed to load theme variant (${themeVariant}) CSS from ${url} and locally installed fallback URL is not available. Aborting.`); - if (isBrandOverride) { - setIsBrandThemeVariantLoaded(true); - } else { - setIsParagonThemeVariantLoaded(true); - } - } - }; - return themeVariantLink; - }; - - if (!existingThemeVariantLink) { - const paragonThemeVariantLink = createThemeVariantLink(value.urls.default); - document.head.insertAdjacentElement( - 'afterbegin', - paragonThemeVariantLink, - ); - - if (value.urls.brandOverride) { - const brandThemeVariantLink = createThemeVariantLink(value.urls.brandOverride, { isBrandOverride: true }); - const foundParagonThemeVariantLink = getParagonThemeVariantLink(); - if (foundParagonThemeVariantLink) { - foundParagonThemeVariantLink.insertAdjacentElement( - 'afterend', - brandThemeVariantLink, - ); - } else { - document.head.insertAdjacentElement( - 'afterbegin', - brandThemeVariantLink, - ); - } - } else { - setIsBrandThemeVariantLoaded(true); - } - } else { - const updatedStylesheetRel = generateStylesheetRelAttr(themeVariant); - existingThemeVariantLink.rel = updatedStylesheetRel; - existingThemeVariantBrandLink.rel = updatedStylesheetRel; - } - }); - }, [themeVariants, currentThemeVariant, onLoad]); -}; - -const handleVersionSubstitution = ({ url, wildcardKeyword, localVersion }) => { - if (!url || !url.includes(wildcardKeyword) || !localVersion) { - return url; - } - return url.replace(wildcardKeyword, localVersion); -}; - -/** - * @typedef {Object} ParagonThemeUrl - * @property {string} default The default URL for Paragon. - * @property {string} brandOverride The URL for a brand override. - */ - -/** - * @typedef {Object} ParagonThemeCore - * @property {ParagonThemeUrl|string} url - */ - -/** - * @typedef {Object} ParagonThemeVariant - * @property {ParagonThemeUrl|string} url - * @property {boolean} default Whether this is the default theme variant. - * @property {boolean} dark Whether this is the dark theme variant to use for `prefers-color-scheme: "dark"`. - */ - -/** - * @typedef {Object} ParagonThemeUrls - * @property {ParagonThemeCore} core - * @property {Object.} defaults - * @property {Object.} variants - */ - -/** - * Returns an object containing the URLs for the theme's core CSS and any theme variants. - * - * @param {*} config - * @returns {ParagonThemeUrls|undefined} An object containing the URLs for the theme's core CSS and any theme variants. - */ -const useParagonThemeUrls = (config) => useMemo(() => { - if (!config.PARAGON_THEME_URLS) { - return undefined; - } - /** @type {ParagonThemeUrls} */ - const paragonThemeUrls = config.PARAGON_THEME_URLS; - const paragonCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.default : paragonThemeUrls.core.url; - const brandCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.brandOverride : undefined; - const defaultThemeVariants = paragonThemeUrls.defaults; - - // Local versions of @edx/paragon and @edx/brand - const localParagonVersion = PARAGON_THEME?.paragon?.version; - const localBrandVersion = PARAGON_THEME?.brand?.version; - - const coreCss = { - default: handleVersionSubstitution({ url: paragonCoreCssUrl, wildcardKeyword: '$paragonVersion', localVersion: localParagonVersion }), - brandOverride: handleVersionSubstitution({ url: brandCoreCssUrl, wildcardKeyword: '$brandVersion', localVersion: localBrandVersion }), - }; - - const themeVariantsCss = {}; - const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {}); - themeVariantsEntries.forEach(([themeVariant, { url, urls }]) => { - const themeVariantMetadata = { urls: null }; - if (url) { - themeVariantMetadata.urls = { - default: handleVersionSubstitution({ - url, - wildcardKeyword: '$paragonVersion', - localVersion: localParagonVersion, - }), - }; - } else { - themeVariantMetadata.urls = { - default: handleVersionSubstitution({ - url: urls.default, - wildcardKeyword: '$paragonVersion', - localVersion: localParagonVersion, - }), - brandOverride: handleVersionSubstitution({ url: urls.brandOverride, wildcardKeyword: '$brandVersion', localVersion: localBrandVersion }), - }; - } - themeVariantsCss[themeVariant] = themeVariantMetadata; - }); - - const hasMissingCssUrls = !coreCss.default || Object.keys(themeVariantsCss).length === 0; - if (hasMissingCssUrls) { - if (!PARAGON_THEME) { - return undefined; - } - const themeVariants = {}; - const baseUrl = config.BASE_URL || window.location?.origin; - const prependBaseUrl = (url) => `${baseUrl}/${url}`; - themeVariantsEntries.forEach(([themeVariant, { fileName, ...rest }]) => { - themeVariants[themeVariant] = { - url: prependBaseUrl(fileName), - ...rest, - }; - }); - return { - core: { urls: coreCss }, - defaults: defaultThemeVariants, - variants: themeVariants, - }; - } - - return { - core: { urls: coreCss }, - defaults: defaultThemeVariants, - variants: themeVariantsCss, - }; -}, [config.BASE_URL, config.PARAGON_THEME_URLS]); - -/** - * Finds the default theme variant from the given theme variants object. If no default theme exists, the first theme - * variant is returned as a fallback. - * @param {Object.|undefined} themeVariants - * - * @returns {ParagonThemeVariant|undefined} The default theme variant. - */ -const getDefaultThemeVariant = ({ themeVariants, themeVariantDefaults = {} }) => { - if (!themeVariants) { - return undefined; - } - - const themeVariantKeys = Object.keys(themeVariants); - - // Return early if there are no theme variants configured. - if (themeVariantKeys.length === 0) { - return undefined; - } - // 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], - }; - } - // There's more than one theme variant configured; figured out which one to display based on - // the following preference rules: - // 1. Get theme preference from localStorage. - // 2. Detect user system settings. - // 3. Use the default theme variant as configured. - - // 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, - }; -}; - -/** - * Given the inputs of URLs to the CSS for the core application theme and the theme variants (e.g., light), this hook - * will inject the CSS as `` elements into the HTML document, loading each theme variant's CSS with an appropriate - * priority based on whether its the currently active theme variant. This is done using "alternate" stylesheets. That - * is,the browser will still download the CSS for the non-current theme variants, but at a lower priority than the - * current theme variant's CSS. This ensures that if the theme variant is changed at runtime, the CSS for the new theme - * variant will already be loaded. - * - * @memberof module:React - * @param {object} config An object containing the URLs for the theme's core CSS and any theme (i.e., `getConfig()`) - * - * @returns An array containing 2 elements: 1) an object containing the app - * theme state, and 2) a dispatch function to mutate the app theme state. - */ -export const useParagonTheme = (config) => { - const paragonThemeUrls = useParagonThemeUrls(config); - 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, - onLoad: 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, - onLoad: onLoadThemeVariants, - currentThemeVariant: themeState.themeVariant, - onDarkModeSystemPreferenceChange: handleDarkModeSystemPreferenceChange, - }); - - useEffect(() => { - // theme is already loaded, do nothing - if (themeState.isThemeLoaded) { - return; - } - - const hasThemeConfig = (themeCore?.urls && Object.keys(themeVariants).length > 0); - 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]; -}; diff --git a/src/react/hooks/index.js b/src/react/hooks/index.js new file mode 100644 index 000000000..059a04a4c --- /dev/null +++ b/src/react/hooks/index.js @@ -0,0 +1,3 @@ +export { default as useAppEvent } from './useAppEvent'; + +export * from './paragon'; diff --git a/src/react/hooks/paragon/index.js b/src/react/hooks/paragon/index.js new file mode 100644 index 000000000..bda7abb66 --- /dev/null +++ b/src/react/hooks/paragon/index.js @@ -0,0 +1,2 @@ +export { default as useTrackColorSchemeChoice } from './useTrackColorSchemeChoice'; +export { default as useParagonTheme } from './useParagonTheme'; diff --git a/src/react/hooks/paragon/useParagonTheme.js b/src/react/hooks/paragon/useParagonTheme.js new file mode 100644 index 000000000..5fd8c49cd --- /dev/null +++ b/src/react/hooks/paragon/useParagonTheme.js @@ -0,0 +1,109 @@ +import { + useCallback, useEffect, useReducer, useState, +} from 'react'; +import useParagonThemeUrls from './useParagonThemeUrls'; +import { getDefaultThemeVariant } from './utils'; +import { paragonThemeActions, paragonThemeReducer } from '../../reducers'; +import useParagonThemeCore from './useParagonThemeCore'; +import { SELECTED_THEME_VARIANT_KEY } from '../../constants'; +import { logError } from '../../../logging'; +import useParagonThemeVariants from './useParagonThemeVariants'; + +/** + * Given the inputs of URLs to the CSS for the core application theme and the theme variants (e.g., light), this hook + * will inject the CSS as `` elements into the HTML document, loading each theme variant's CSS with an appropriate + * priority based on whether its the currently active theme variant. This is done using "alternate" stylesheets. That + * is,the browser will still download the CSS for the non-current theme variants, but at a lower priority than the + * current theme variant's CSS. This ensures that if the theme variant is changed at runtime, the CSS for the new theme + * variant will already be loaded. + * + * @memberof module:React + * @param {object} config An object containing the URLs for the theme's core CSS and any theme (i.e., `getConfig()`) + * + * @returns An array containing 2 elements: 1) an object containing the app + * theme state, and 2) a dispatch function to mutate the app theme state. + */ +const useParagonTheme = (config) => { + const paragonThemeUrls = useParagonThemeUrls(config); + 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, + onLoad: 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, + onLoad: onLoadThemeVariants, + currentThemeVariant: themeState.themeVariant, + onDarkModeSystemPreferenceChange: handleDarkModeSystemPreferenceChange, + }); + + useEffect(() => { + // theme is already loaded, do nothing + if (themeState.isThemeLoaded) { + return; + } + + const hasThemeConfig = (themeCore?.urls && Object.keys(themeVariants).length > 0); + 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; diff --git a/src/react/hooks/paragon/useParagonThemeCore.js b/src/react/hooks/paragon/useParagonThemeCore.js new file mode 100644 index 000000000..f1090d52e --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeCore.js @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; + +import { logError, logInfo } from '../../../logging'; +import { removeExistingLinks } from './utils'; +import { getConfig } from '../../../config'; + +/** + * Adds/updates a `` element in the HTML document to load the core application theme CSS. + * + * @memberof module:React + * + * @param {object} args + * @param {object} args.themeCore Object representing the core Paragon theme CSS. + * @param {string} args.onLoad A callback function called when the core theme CSS is loaded. + */ +const useParagonThemeCore = ({ + themeCore, + onLoad, +}) => { + const [isParagonThemeCoreLoaded, setIsParagonThemeCoreLoaded] = useState(false); + const [isBrandThemeCoreLoaded, setIsBrandThemeCoreLoaded] = useState(false); + + useEffect(() => { + // Call `onLoad` once both the paragon and brand theme core are loaded. + if (isParagonThemeCoreLoaded && isBrandThemeCoreLoaded) { + onLoad(); + } + }, [isParagonThemeCoreLoaded, isBrandThemeCoreLoaded, onLoad]); + + useEffect(() => { + // If there is no config for the core theme url, do nothing. + if (!themeCore?.urls) { + setIsParagonThemeCoreLoaded(true); + setIsBrandThemeCoreLoaded(true); + return; + } + const getParagonThemeCoreLink = () => document.head.querySelector('link[data-paragon-theme-core="true"'); + const existingCoreThemeLink = document.head.querySelector(`link[href='${themeCore.urls.default}']`); + if (!existingCoreThemeLink) { + const getExistingCoreThemeLinks = (isBrandOverride) => { + const coreThemeLinkSelector = `link[data-${isBrandOverride ? 'brand' : 'paragon'}-theme-core="true"]`; + return document.head.querySelectorAll(coreThemeLinkSelector); + }; + const createCoreThemeLink = ( + url, + { + isFallbackThemeUrl = false, + isBrandOverride = false, + } = {}, + ) => { + let coreThemeLink = document.createElement('link'); + coreThemeLink.href = url; + coreThemeLink.rel = 'stylesheet'; + if (isBrandOverride) { + coreThemeLink.dataset.brandThemeCore = true; + } else { + coreThemeLink.dataset.paragonThemeCore = true; + } + coreThemeLink.onload = () => { + if (isBrandOverride) { + setIsBrandThemeCoreLoaded(true); + } else { + setIsParagonThemeCoreLoaded(true); + } + }; + coreThemeLink.onerror = () => { + logError(`Failed to load core theme CSS from ${url}`); + if (isFallbackThemeUrl) { + logError(`Could not load core theme CSS from ${url} or fallback URL. Aborting.`); + if (isBrandOverride) { + setIsBrandThemeCoreLoaded(true); + } else { + setIsParagonThemeCoreLoaded(true); + } + const otherExistingLinks = getExistingCoreThemeLinks(isBrandOverride); + removeExistingLinks(otherExistingLinks); + return; + } + const paragonThemeAccessor = isBrandOverride ? 'brand' : 'paragon'; + const themeUrls = PARAGON_THEME?.[paragonThemeAccessor]?.themeUrls ?? {}; + if (themeUrls.core) { + const coreThemeFallbackUrl = `${getConfig().BASE_URL}/${themeUrls.core.fileName}`; + logInfo(`Falling back to locally installed core theme CSS: ${coreThemeFallbackUrl}`); + coreThemeLink = createCoreThemeLink(coreThemeFallbackUrl, { isFallbackThemeUrl: true, isBrandOverride }); + const otherExistingLinks = getExistingCoreThemeLinks(isBrandOverride); + removeExistingLinks(otherExistingLinks); + const foundParagonThemCoreLink = getParagonThemeCoreLink(); + if (foundParagonThemCoreLink) { + foundParagonThemCoreLink.insertAdjacentElement( + 'afterend', + coreThemeLink, + ); + } else { + document.head.insertAdjacentElement( + 'afterbegin', + coreThemeLink, + ); + } + } else { + logError(`Failed to load core theme CSS from ${url} or fallback URL. Aborting.`); + } + }; + return coreThemeLink; + }; + + const paragonCoreThemeLink = createCoreThemeLink(themeCore.urls.default); + document.head.insertAdjacentElement( + 'afterbegin', + paragonCoreThemeLink, + ); + + if (themeCore.urls.brandOverride) { + const brandCoreThemeLink = createCoreThemeLink(themeCore.urls.brandOverride, { isBrandOverride: true }); + const foundParagonThemeCoreLink = getParagonThemeCoreLink(); + if (foundParagonThemeCoreLink) { + foundParagonThemeCoreLink.insertAdjacentElement( + 'afterend', + brandCoreThemeLink, + ); + } else { + document.head.insertAdjacentElement( + 'afterbegin', + brandCoreThemeLink, + ); + } + } else { + setIsBrandThemeCoreLoaded(true); + } + } + }, [themeCore?.urls, onLoad]); +}; + +export default useParagonThemeCore; diff --git a/src/react/hooks/paragon/useParagonThemeUrls.js b/src/react/hooks/paragon/useParagonThemeUrls.js new file mode 100644 index 000000000..b1721e74a --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeUrls.js @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import { handleVersionSubstitution } from './utils'; + +/** + * Returns an object containing the URLs for the theme's core CSS and any theme variants. + * + * @param {*} config + * @returns {ParagonThemeUrls|undefined} An object containing the URLs for the theme's core CSS and any theme variants. + */ +const useParagonThemeUrls = (config) => useMemo(() => { + if (!config?.PARAGON_THEME_URLS) { + return undefined; + } + const paragonThemeUrls = config.PARAGON_THEME_URLS; + const paragonCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.default : paragonThemeUrls.core.url; + const brandCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.brandOverride : undefined; + const defaultThemeVariants = paragonThemeUrls.defaults; + + // Local versions of @edx/paragon and @edx/brand + const localParagonVersion = PARAGON_THEME?.paragon?.version; + const localBrandVersion = PARAGON_THEME?.brand?.version; + + const coreCss = { + default: handleVersionSubstitution({ url: paragonCoreCssUrl, wildcardKeyword: '$paragonVersion', localVersion: localParagonVersion }), + brandOverride: handleVersionSubstitution({ url: brandCoreCssUrl, wildcardKeyword: '$brandVersion', localVersion: localBrandVersion }), + }; + + const themeVariantsCss = {}; + const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {}); + themeVariantsEntries.forEach(([themeVariant, { url, urls }]) => { + const themeVariantMetadata = { urls: null }; + if (url) { + themeVariantMetadata.urls = { + default: handleVersionSubstitution({ + url, + wildcardKeyword: '$paragonVersion', + localVersion: localParagonVersion, + }), + }; + } else { + themeVariantMetadata.urls = { + default: handleVersionSubstitution({ + url: urls.default, + wildcardKeyword: '$paragonVersion', + localVersion: localParagonVersion, + }), + brandOverride: handleVersionSubstitution({ + url: urls.brandOverride, + wildcardKeyword: '$brandVersion', + localVersion: localBrandVersion, + }), + }; + } + themeVariantsCss[themeVariant] = themeVariantMetadata; + }); + + const hasMissingCssUrls = !coreCss.default || Object.keys(themeVariantsCss).length === 0; + if (hasMissingCssUrls) { + if (!PARAGON_THEME) { + return undefined; + } + const themeVariants = {}; + const baseUrl = config.BASE_URL || window.location?.origin; + const prependBaseUrl = (url) => `${baseUrl}/${url}`; + themeVariantsEntries.forEach(([themeVariant, { fileName, ...rest }]) => { + themeVariants[themeVariant] = { + url: prependBaseUrl(fileName), + ...rest, + }; + }); + return { + core: { urls: coreCss }, + defaults: defaultThemeVariants, + variants: themeVariants, + }; + } + + return { + core: { urls: coreCss }, + defaults: defaultThemeVariants, + variants: themeVariantsCss, + }; +}, [config?.BASE_URL, config?.PARAGON_THEME_URLS]); + +export default useParagonThemeUrls; diff --git a/src/react/hooks/paragon/useParagonThemeUrls.test.js b/src/react/hooks/paragon/useParagonThemeUrls.test.js new file mode 100644 index 000000000..ff87c9a03 --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeUrls.test.js @@ -0,0 +1,101 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import useParagonThemeUrls from './useParagonThemeUrls'; + +describe('useParagonThemeUrls', () => { + it.each([ + undefined, + {}, + ])('handles when `config.PARAGON_THEME_URLS` is not present', (config) => { + const { result } = renderHook(() => useParagonThemeUrls(config)); + expect(result.current).toEqual(undefined); + }); + + describe('when `config.PARAGON_THEME_URLS` is present', () => { + it('returns expected object when configuration is valid (only Paragon)', () => { + const config = { + PARAGON_THEME_URLS: { + core: { + url: 'core.css', + }, + defaults: { + light: 'light', + }, + variants: { + light: { + url: 'light.css', + }, + }, + }, + }; + const { result } = renderHook(() => useParagonThemeUrls(config)); + expect(result.current).toEqual( + expect.objectContaining({ + core: { + urls: { + default: 'core.css', + brandOverride: undefined, + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'light.css', + brandOverride: undefined, + }, + }, + }, + }), + ); + }); + + it('returns expected object when configuration is valid (both Paragon + brand)', () => { + const config = { + PARAGON_THEME_URLS: { + core: { + urls: { + default: 'core.css', + brandOverride: 'brand-core.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'light.css', + brandOverride: 'brand-light.css', + }, + }, + }, + }, + }; + const { result } = renderHook(() => useParagonThemeUrls(config)); + expect(result.current).toEqual( + expect.objectContaining({ + core: { + urls: { + default: 'core.css', + brandOverride: 'brand-core.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'light.css', + brandOverride: 'brand-light.css', + }, + }, + }, + }), + ); + }); + }); +}); diff --git a/src/react/hooks/paragon/useParagonThemeVariants.js b/src/react/hooks/paragon/useParagonThemeVariants.js new file mode 100644 index 000000000..efb8ef4c5 --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeVariants.js @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react'; +import { logError, logInfo } from '../../../logging'; +import { removeExistingLinks } from './utils'; +import { getConfig } from '../../../config'; + +/** + * Adds/updates a `` element in the HTML document to load each theme variant's CSS, setting the + * non-current theme variants as "alternate" stylesheets. That is, the browser will still download + * the CSS for the non-current theme variants, but at a lower priority than the current theme + * variant's CSS. This ensures that if the theme variant is changed at runtime, the CSS for the new + * theme variant will already be loaded. + * + * @memberof module:React + * @param {object} args + * @param {object} [args.themeVariants] An object containing the URLs for each supported theme variant, e.g.: `{ light: { url: 'https://path/to/light.css' } }`. + * @param {string} [args.currentThemeVariant] The currently applied theme variant, e.g.: `light`. + * @param {string} args.onLoad A callback function called when the theme variant(s) CSS is loaded. + */ +const useParagonThemeVariants = ({ + themeVariants, + currentThemeVariant, + onLoad, + onDarkModeSystemPreferenceChange, +}) => { + const [isParagonThemeVariantLoaded, setIsParagonThemeVariantLoaded] = useState(false); + const [isBrandThemeVariantLoaded, setIsBrandThemeVariantLoaded] = useState(false); + + useEffect(() => { + const someFn = (colorSchemeQuery) => { + onDarkModeSystemPreferenceChange(colorSchemeQuery.matches); + }; + const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); + if (colorSchemeQuery) { + colorSchemeQuery.addEventListener('change', someFn); + } + return () => { + if (colorSchemeQuery) { + colorSchemeQuery.removeEventListener('change', someFn); + } + }; + }, [onDarkModeSystemPreferenceChange]); + + useEffect(() => { + if (currentThemeVariant && themeVariants?.[currentThemeVariant]) { + const htmlDataThemeVariantAttr = 'data-paragon-theme-variant'; + document.querySelector('html').setAttribute(htmlDataThemeVariantAttr, currentThemeVariant); + return () => { + document.querySelector('html').removeAttribute(htmlDataThemeVariantAttr); + }; + } + return () => {}; // no-op + }, [themeVariants, currentThemeVariant]); + + useEffect(() => { + // Call `onLoad` once both the paragon and brand theme variant are loaded. + if (isParagonThemeVariantLoaded && isBrandThemeVariantLoaded) { + onLoad(); + } + }, [isParagonThemeVariantLoaded, isBrandThemeVariantLoaded, onLoad]); + + useEffect(() => { + if (!themeVariants) { + return; + } + + /** + * Determines the value for the `rel` attribute for a given theme variant based + * on if its the currently applied variant. + */ + const generateStylesheetRelAttr = (themeVariant) => (currentThemeVariant === themeVariant ? 'stylesheet' : 'alternate stylesheet'); + + // Iterate over each theme variant URL and inject it into the HTML document, if it doesn't already exist. + Object.entries(themeVariants).forEach(([themeVariant, value]) => { + // If there is no config for the theme variant URL, set the theme variant to loaded and continue. + if (!value.urls) { + setIsParagonThemeVariantLoaded(true); + setIsBrandThemeVariantLoaded(true); + return; + } + const getParagonThemeVariantLink = () => document.head.querySelector(`link[data-paragon-theme-variant='${themeVariant}']`); + const existingThemeVariantLink = document.head.querySelector(`link[href='${value.urls.default}']`); + const existingThemeVariantBrandLink = document.head.querySelector(`link[href='${value.urls.brandOverride}']`); + + const getExistingThemeVariantLinks = (isBrandOverride) => { + const themeVariantLinkSelector = `link[data-${isBrandOverride ? 'brand' : 'paragon'}-theme-variant='${themeVariant}']`; + return document.head.querySelectorAll(themeVariantLinkSelector); + }; + + const createThemeVariantLink = ( + url, + { + isFallbackThemeUrl = false, + isBrandOverride = false, + } = {}, + ) => { + let themeVariantLink = document.createElement('link'); + themeVariantLink.href = url; + themeVariantLink.rel = generateStylesheetRelAttr(themeVariant); + if (isBrandOverride) { + themeVariantLink.dataset.brandThemeVariant = themeVariant; + } else { + themeVariantLink.dataset.paragonThemeVariant = themeVariant; + } + + themeVariantLink.onload = () => { + if (themeVariant === currentThemeVariant) { + if (isBrandOverride) { + setIsBrandThemeVariantLoaded(true); + } else { + setIsParagonThemeVariantLoaded(true); + } + } + }; + + themeVariantLink.onerror = () => { + logError(`Failed to load theme variant (${themeVariant}) CSS from ${value.urls.default}`); + if (isFallbackThemeUrl) { + logError(`Could not load theme variant (${themeVariant}) CSS from fallback URL. Aborting.`); + if (isBrandOverride) { + setIsBrandThemeVariantLoaded(true); + } else { + setIsParagonThemeVariantLoaded(true); + } + const otherExistingLinks = getExistingThemeVariantLinks(isBrandOverride); + removeExistingLinks(otherExistingLinks); + return; + } + const paragonThemeAccessor = isBrandOverride ? 'brand' : 'paragon'; + const themeUrls = PARAGON_THEME?.[paragonThemeAccessor]?.themeUrls ?? {}; + if (themeUrls.variants && themeUrls.variants[themeVariant]) { + const themeVariantFallbackUrl = `${getConfig().BASE_URL}/${themeUrls.variants[themeVariant].fileName}`; + logInfo(`Falling back to locally installed theme variant (${themeVariant}) CSS: ${themeVariantFallbackUrl}`); + themeVariantLink = createThemeVariantLink(themeVariantFallbackUrl, { + isFallbackThemeUrl: true, + isBrandOverride, + }); + const otherExistingLinks = getExistingThemeVariantLinks(isBrandOverride); + removeExistingLinks(otherExistingLinks); + const foundParagonThemeVariantLink = getParagonThemeVariantLink(); + if (foundParagonThemeVariantLink) { + foundParagonThemeVariantLink.insertAdjacentElement( + 'afterend', + themeVariantLink, + ); + } else { + document.head.insertAdjacentElement( + 'afterbegin', + themeVariantLink, + ); + } + } else { + logError(`Failed to load theme variant (${themeVariant}) CSS from ${url} and locally installed fallback URL is not available. Aborting.`); + if (isBrandOverride) { + setIsBrandThemeVariantLoaded(true); + } else { + setIsParagonThemeVariantLoaded(true); + } + } + }; + return themeVariantLink; + }; + + if (!existingThemeVariantLink) { + const paragonThemeVariantLink = createThemeVariantLink(value.urls.default); + document.head.insertAdjacentElement( + 'afterbegin', + paragonThemeVariantLink, + ); + + if (value.urls.brandOverride) { + const brandThemeVariantLink = createThemeVariantLink(value.urls.brandOverride, { isBrandOverride: true }); + const foundParagonThemeVariantLink = getParagonThemeVariantLink(); + if (foundParagonThemeVariantLink) { + foundParagonThemeVariantLink.insertAdjacentElement( + 'afterend', + brandThemeVariantLink, + ); + } else { + document.head.insertAdjacentElement( + 'afterbegin', + brandThemeVariantLink, + ); + } + } else { + setIsBrandThemeVariantLoaded(true); + } + } else { + const updatedStylesheetRel = generateStylesheetRelAttr(themeVariant); + existingThemeVariantLink.rel = updatedStylesheetRel; + existingThemeVariantBrandLink.rel = updatedStylesheetRel; + } + }); + }, [themeVariants, currentThemeVariant, onLoad]); +}; + +export default useParagonThemeVariants; diff --git a/src/react/hooks/paragon/useTrackColorSchemeChoice.js b/src/react/hooks/paragon/useTrackColorSchemeChoice.js new file mode 100644 index 000000000..6f3abfecd --- /dev/null +++ b/src/react/hooks/paragon/useTrackColorSchemeChoice.js @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; + +import { sendTrackEvent } from '../../../analytics'; + +/** + * A React hook that tracks user's preferred color scheme (light or dark) and sends respective + * event to the tracking service. + * + * @memberof module:React + */ +const useTrackColorSchemeChoice = () => { + useEffect(() => { + const trackColorSchemeChoice = ({ matches }) => { + const preferredColorScheme = matches ? 'dark' : 'light'; + sendTrackEvent('openedx.ui.frontend-platform.prefers-color-scheme.selected', { preferredColorScheme }); + }; + const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); + if (colorSchemeQuery) { + // send user's initial choice + trackColorSchemeChoice(colorSchemeQuery); + colorSchemeQuery.addEventListener('change', trackColorSchemeChoice); + } + return () => { + if (colorSchemeQuery) { + colorSchemeQuery.removeEventListener('change', trackColorSchemeChoice); + } + }; + }, []); +}; + +export default useTrackColorSchemeChoice; diff --git a/src/react/hooks/paragon/useTrackColorSchemeChoice.test.js b/src/react/hooks/paragon/useTrackColorSchemeChoice.test.js new file mode 100644 index 000000000..1c8bf5e4f --- /dev/null +++ b/src/react/hooks/paragon/useTrackColorSchemeChoice.test.js @@ -0,0 +1,59 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { sendTrackEvent } from '../../../analytics'; + +import useTrackColorSchemeChoice from './useTrackColorSchemeChoice'; + +jest.mock('../../../analytics', () => ({ + ...jest.requireActual('../../../analytics'), + sendTrackEvent: jest.fn(), +})); + +const mockAddEventListener = jest.fn(); +const mockRemoveEventListener = jest.fn(); +let matchesMock; + +Object.defineProperty(window, 'matchMedia', { + value: jest.fn(() => ({ + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + matches: matchesMock, + })), +}); + +describe('useTrackColorSchemeChoice', () => { + afterEach(() => { + mockAddEventListener.mockClear(); + mockRemoveEventListener.mockClear(); + sendTrackEvent.mockClear(); + }); + + it('sends dark preferred color schema event if query matches', async () => { + matchesMock = true; + renderHook(() => useTrackColorSchemeChoice()); + + expect(sendTrackEvent).toHaveBeenCalledTimes(1); + expect(sendTrackEvent).toHaveBeenCalledWith( + 'openedx.ui.frontend-platform.prefers-color-scheme.selected', + { preferredColorScheme: 'dark' }, + ); + }); + + it('sends light preferred color schema event if query does not match', async () => { + matchesMock = false; + renderHook(() => useTrackColorSchemeChoice()); + + expect(sendTrackEvent).toHaveBeenCalledTimes(1); + expect(sendTrackEvent).toHaveBeenCalledWith( + 'openedx.ui.frontend-platform.prefers-color-scheme.selected', + { preferredColorScheme: 'light' }, + ); + }); + + it('adds change event listener to matchMedia query', async () => { + renderHook(() => useTrackColorSchemeChoice()); + + expect(mockAddEventListener).toHaveBeenCalledTimes(1); + expect(mockAddEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); +}); diff --git a/src/react/hooks/paragon/utils.js b/src/react/hooks/paragon/utils.js new file mode 100644 index 000000000..8dee00646 --- /dev/null +++ b/src/react/hooks/paragon/utils.js @@ -0,0 +1,87 @@ +import { SELECTED_THEME_VARIANT_KEY } from '../../constants'; + +/** + * Iterates through each given `` element and removes it from the DOM. + * @param {HTMLLinkElement[]} existingLinks + */ +export const removeExistingLinks = (existingLinks) => { + existingLinks.forEach((link) => { + link.remove(); + }); +}; + +/** +* Finds the default theme variant from the given theme variants object. If no default theme exists, the first theme +* variant is returned as a fallback. +* @param {Object.|undefined} themeVariants +* +* @returns {ParagonThemeVariant|undefined} The default theme variant. +*/ +export const getDefaultThemeVariant = ({ themeVariants, themeVariantDefaults = {} }) => { + if (!themeVariants) { + return undefined; + } + + const themeVariantKeys = Object.keys(themeVariants); + + // Return early if there are no theme variants configured. + if (themeVariantKeys.length === 0) { + return undefined; + } + // 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], + }; + } + // There's more than one theme variant configured; figured out which one to display based on + // the following preference rules: + // 1. Get theme preference from localStorage. + // 2. Detect user system settings. + // 3. Use the default theme variant as configured. + + // 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, + }; +}; + +export const handleVersionSubstitution = ({ url, wildcardKeyword, localVersion }) => { + if (!url || !url.includes(wildcardKeyword) || !localVersion) { + return url; + } + return url.replace(wildcardKeyword, localVersion); +}; diff --git a/src/react/hooks/useAppEvent.js b/src/react/hooks/useAppEvent.js new file mode 100644 index 000000000..ed06c3b7e --- /dev/null +++ b/src/react/hooks/useAppEvent.js @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +import { subscribe, unsubscribe } from '../../pubSub'; + +/** + * A React hook that allows functional components to subscribe to application events. This should + * be used sparingly - for the most part, Context should be used higher-up in the application to + * provide necessary data to a given component, rather than utilizing a non-React-like Pub/Sub + * mechanism. + * + * @memberof module:React + * + * @param {string} type + * @param {function} callback + */ +const useAppEvent = (type, callback) => { + useEffect(() => { + const subscriptionToken = subscribe(type, callback); + + return function cleanup() { + unsubscribe(subscriptionToken); + }; + }, [callback, type]); +}; + +export default useAppEvent;