diff --git a/docs/how_tos/theming.md b/docs/how_tos/theming.md index 52646a7d6..c7ff08084 100644 --- a/docs/how_tos/theming.md +++ b/docs/how_tos/theming.md @@ -1,15 +1,47 @@ -# Theming support with Paragon - -This document serves as a guide to using `@edx/frontend-platform` to support MFE theming with Paragon using theme CSS loaded externally (e.g., from a CDN). By serving CSS loaded externally, consuming applications of Paragon no longer need to be responsible for compiling the theme SCSS to CSS themselves and instead use a pre-compiled CSS file. In doing so, this allows making changes to the Paragon theme without needing to necessarily re-build and re-deploy all consuming applications. We would also get a meaningful gain in performance as loading the compiled theme CSS from an external CDN means micro-frontends (MFEs) can include cached styles instead of needing to load essentially duplicate theme styles as users navigate across different MFEs. +# Theming support with `@edx/paragon` and `@edx/brand` ## Overview +This document serves as a guide to using `@edx/frontend-platform` to support MFE theming with Paragon using theme CSS loaded externally (e.g., from a CDN). + +To do this, configured URLs pointing to relevant CSS files from `@edx/paragon` and (optionally) `@edx/brand` are loaded and injected to the HTML document at runtime. This differs than the consuming application importing the styles from `@edx/paragon` and `@edx/brand` directly, which includes these styles in the application's production assets. + +By serving CSS loaded externally, consuming applications of Paragon no longer need to be responsible for compiling the theme SCSS to CSS themselves and instead use a pre-compiled CSS file. In doing so, this allows making changes to the Paragon theme without needing to necessarily re-build and re-deploy all consuming applications. + +### Dark mode and theme variant preferences + +`@edx/frontend-platform` supports both `light` (required) and `dark` (optional) theme variants. The choice of which theme variant should be applied on page load is based on the following preference cascade: + +1. **Get theme preference from localStorage.** Supports persisting and loading the user's preference for their selected theme variant, until cleared. +1. **Detect user system settings.** Rely on the `prefers-color-scheme` media query to detect if the user's system indicates a preference for dark mode. If so, use the default dark theme variant, if one is configured. +1. **Use default theme variant as configured (see below).** Otherwise, load the default theme variant as configured by the `defaults` option described below. + +Whenever the current theme variant changes, an attrivbute `data-paragon-theme-variant="*"` is updated on the `` element. This attribute enables applications' both JS and CSS to have knowledge of the currently applied theme variant. + +### Supporting custom theme variants beyond `light` and `dark` + +If your use case necessitates additional variants beyond the default supported `light` and `dark` theme variants, you may pass any number of custom theme variants. Custom theme variants will work the user's persisted localStorage setting (i.e., if a user switches to a custom theme variant, the MFE will continue to load the custom theme variant by default). By supporting custom theme variants, it also supports having multiple or alternative `light` and/or `dark` theme variants. + +### Performance implications + +There is also a meaningful improvement in performance as loading the compiled theme CSS from an external CDN means micro-frontends (MFEs) can include cached styles instead of needing to load essentially duplicate theme styles included in each individual MFE as users navigate across the platform. + +However, as the styles from `@edx/paragon` and `@edx/brand` get loaded at runtime by `@edx/frontend-platform`, the associated CSS files do not get processed through the consuming application's Webpack build process (e.g., if the MFE used PurgeCSS or any custom PostCSS plugins specifically for Paragon). + +### Falling back to styles installed in consuming application + +If any of the configured external `PARAGON_THEME_URLS` fail to load for whatever reason (e.g., CDN is down, URL is incorrectly configured), `@edx/paragon` will attempt to fallback to the relevant files installed in `node_modules` from the consuming application. + +## Technical architecture + ![overview of paragon theme loader](./assets/paragon-theme-loader.png "Paragon theme loader") -## Basic theme URL configuration +## Development + +### Basic theme URL configuration Paragon supports 2 mechanisms for configuring the Paragon theme urls: -* JavaScript-based configuration via `env.config.js`. +* JavaScript-based configuration via `env.config.js` * MFE runtime configuration API via `edx-platform` Using either configuration mechanism, a `PARAGON_THEME_URLS` configuration setting must be created to point to the externally hosted Paragon theme CSS files, e.g.: @@ -19,16 +51,45 @@ Using either configuration mechanism, a `PARAGON_THEME_URLS` configuration setti "core": { "url": "https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css" }, + "defaults": { + "light": "light", + }, "variants": { "light": { "url": "https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css", - "default": true, - "dark": false, } } } ``` +### Configuration options + +The `PARAGON_THEME_URLS` configuration object supports using only the default styles from `@edx/paragon` or, optionally, extended/overridden styles via `@edx/brand`. To utilize `@edx/brand` overrides, see the `core.urls` and `variants.*.urls` options below. + +The `dark` theme variant options are optional. + +| Property | Data Type | Description | +| -------- | ----------- | ----------- | +| `core` | Object | Metadata about the core styles from `@edx/paragon` and `@edx/brand`. | +| `core.url` | String | URL for the `core.css` file from `@edx/paragon`. | +| `core.urls` | Object | URL(s) for the `core.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. | +| `core.urls.default` | String | URL for the `core.css` file from `@edx/paragon`. | +| `core.urls.brandOverride` | Object | URL for the `core.css` file from `@edx/brand`. | +| `defaults` | Object | Mapping of theme variants to Paragon's default supported light and dark theme variants. | +| `defaults.light` | String | Default `light` theme variant from the theme variants in the `variants` object. | +| `defaults.dark` | String | Default `dark` theme variant from the theme variants in the `variants` object. | +| `variants` | Object | Metadata about each supported theme variant. | +| `variants.light` | Object | Metadata about the light theme variant styles from `@edx/paragon` and (optionally)`@edx/brand`. | +| `variants.light.url` | String | URL for the `light.css` file from `@edx/paragon`. | +| `variants.light.urls` | Object | URL(s) for the `light.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. | +| `variants.light.urls.default` | String | URL for the `light.css` file from `@edx/paragon`. | +| `variants.light.urls.brandOverride` | String | URL for the `light.css` file from `@edx/brand`. | +| `variants.dark` | Object | Metadata about the dark theme variant styles from `@edx/paragon` and (optionally)`@edx/brand`. | +| `variants.dark.url` | String | URL for the `dark.css` file from `@edx/paragon`. | +| `variants.dark.urls` | Object | URL(s) for the `dark.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. | +| `variants.dark.urls.default` | String | URL for the `dark.css` file from `@edx/paragon`. | +| `variants.dark.urls.brandOverride` | String | URL for the `dark.css` file from `@edx/brand`. | + ### JavaScript-based configuration One approach to configuring the `PARAGON_THEME_URLS` is to create a `env.config.js` file in the root of the repository. The configuration is defined as a JavaScript file, which affords consumers to use more complex data types, amongst other benefits. @@ -41,11 +102,12 @@ const config = { core: { url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css', }, + defaults: { + light: 'light', + }, variants: { light: { url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css', - default: true, - dark: false, }, }, }, @@ -70,11 +132,12 @@ MFE_CONFIG_OVERRIDES = { 'core': { 'url': 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css', }, + 'defaults': { + 'light': 'light', + }, 'variants': { 'light': { 'url': 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css', - 'default': True, - 'dark': False, }, }, }, @@ -112,14 +175,15 @@ const config = { brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@#brandVersion/dist/core.min.css', }, }, + defaults: { + light: 'light', + }, variants: { light: { urls: { default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css', brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@$brandVersion/dist/light.min.css', }, - default: true, - dark: false, }, }, }, diff --git a/src/react/AppProvider.jsx b/src/react/AppProvider.jsx index 3d975e2cf..646d661c4 100644 --- a/src/react/AppProvider.jsx +++ b/src/react/AppProvider.jsx @@ -22,6 +22,7 @@ import { LOCALE_CHANGED, } from '../i18n'; import { basename } from '../initialize'; +import { SELECTED_THEME_VARIANT_KEY } from './constants'; /** * A wrapper component for React-based micro-frontends to initialize a number of common data/ @@ -66,6 +67,7 @@ export default function AppProvider({ store, children, wrapWithRouter }) { setLocale(getLocale()); }); + useTrackColorSchemeChoice(); const [paragonThemeState, paragonThemeDispatch] = useParagonTheme(config); const appContextValue = useMemo(() => ({ @@ -76,6 +78,9 @@ export default function AppProvider({ store, children, wrapWithRouter }) { state: paragonThemeState, setThemeVariant: (themeVariant) => { paragonThemeDispatch(paragonThemeActions.setParagonThemeVariant(themeVariant)); + + // Persist selected theme variant to localStorage. + window.localStorage.setItem(SELECTED_THEME_VARIANT_KEY, themeVariant); }, }, }), [authenticatedUser, config, locale, paragonThemeState, paragonThemeDispatch]); diff --git a/src/react/AppProvider.test.jsx b/src/react/AppProvider.test.jsx index af0a7625e..71f076392 100644 --- a/src/react/AppProvider.test.jsx +++ b/src/react/AppProvider.test.jsx @@ -1,30 +1,65 @@ import React from 'react'; import { createStore } from 'redux'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; + import AppProvider from './AppProvider'; import { initialize } from '../initialize'; +import { useAppEvent, useTrackColorSchemeChoice, useParagonTheme } from './hooks'; +import { AUTHENTICATED_USER_CHANGED, getAuthenticatedUser } from '../auth'; +import { CONFIG_CHANGED } from '../constants'; +import { getConfig } from '../config'; +import { getLocale, LOCALE_CHANGED } from '../i18n'; +import AppContext from './AppContext'; +import { SELECTED_THEME_VARIANT_KEY, SET_THEME_VARIANT } from './constants'; jest.mock('../auth', () => ({ - configure: () => {}, - getAuthenticatedUser: () => null, - fetchAuthenticatedUser: () => null, - getAuthenticatedHttpClient: () => ({}), + ...jest.requireActual('../auth'), + getAuthenticatedUser: jest.fn(), + fetchAuthenticatedUser: jest.fn(), + getAuthenticatedHttpClient: jest.fn().mockReturnValue({}), AUTHENTICATED_USER_CHANGED: 'user_changed', })); +jest.mock('../config', () => ({ + ...jest.requireActual('../config'), + getConfig: jest.fn().mockReturnValue({ + BASE_URL: 'localhost:8080', + LMS_BASE_URL: 'localhost:18000', + LOGIN_URL: 'localhost:18000/login', + LOGOUT_URL: 'localhost:18000/logout', + REFRESH_ACCESS_TOKEN_ENDPOINT: 'localhost:18000/oauth2/access_token', + ACCESS_TOKEN_COOKIE_NAME: 'access_token', + CSRF_TOKEN_API_PATH: 'localhost:18000/csrf', + }), +})); + +jest.mock('../i18n', () => ({ + ...jest.requireActual('../i18n'), + getLocale: jest.fn().mockReturnValue('en'), +})); + jest.mock('../analytics', () => ({ - configure: () => {}, + configure: () => { }, identifyAnonymousUser: jest.fn(), identifyAuthenticatedUser: jest.fn(), })); jest.mock('./hooks', () => ({ ...jest.requireActual('./hooks'), + useAppEvent: jest.fn(), useTrackColorSchemeChoice: jest.fn(), + useParagonTheme: jest.fn().mockImplementation(() => [ + { isThemeLoaded: true, themeVariant: 'light' }, + jest.fn(), + ]), })); describe('AppProvider', () => { beforeEach(async () => { + jest.clearAllMocks(); + await initialize({ loggingService: jest.fn(() => ({ logError: jest.fn(), @@ -104,4 +139,147 @@ describe('AppProvider', () => { const reduxProvider = wrapper.queryByTestId('redux-provider'); expect(reduxProvider).not.toBeInTheDocument(); }); + + describe('paragon theme and brand', () => { + it('calls trackColorSchemeChoice', () => { + const Component = ( + +
Child One
+
Child Two
+
+ ); + render(Component); + expect(useTrackColorSchemeChoice).toHaveBeenCalled(); + }); + + it('calls useParagonTheme', () => { + const Component = ( + +
Child One
+
Child Two
+
+ ); + render(Component); + expect(useParagonTheme).toHaveBeenCalled(); + expect(useParagonTheme).toHaveBeenCalledWith( + expect.objectContaining({ + BASE_URL: 'localhost:8080', + LMS_BASE_URL: 'localhost:18000', + LOGIN_URL: 'localhost:18000/login', + LOGOUT_URL: 'localhost:18000/logout', + REFRESH_ACCESS_TOKEN_ENDPOINT: 'localhost:18000/oauth2/access_token', + ACCESS_TOKEN_COOKIE_NAME: 'access_token', + CSRF_TOKEN_API_PATH: 'localhost:18000/csrf', + }), + ); + }); + + it('blocks rendering until paragon theme is loaded', () => { + useParagonTheme.mockImplementationOnce(() => [ + { isThemeLoaded: false }, + jest.fn(), + ]); + const Component = ( + +
Child One
+
Child Two
+
+ ); + const { container } = render(Component); + expect(container).toBeEmptyDOMElement(); + }); + + it('returns correct `paragonTheme` in context value', async () => { + const mockUseParagonThemeDispatch = jest.fn(); + useParagonTheme.mockImplementationOnce(() => [ + { isThemeLoaded: true, themeVariant: 'light' }, + mockUseParagonThemeDispatch, + ]); + const Component = ( + + + {({ paragonTheme }) => ( +
+

Is theme loaded: {paragonTheme.state.isThemeLoaded ? 'yes' : 'no'}

+

Current theme variant: {paragonTheme.state.themeVariant}

+ +
+ )} +
+
+ ); + render(Component); + expect(screen.getByText('Is theme loaded: yes')).toBeInTheDocument(); + expect(screen.getByText('Current theme variant: light')).toBeInTheDocument(); + + const setThemeVariantBtn = screen.getByRole('button', { name: 'Set theme variant' }); + expect(setThemeVariantBtn).toBeInTheDocument(); + await userEvent.click(setThemeVariantBtn); + + expect(mockUseParagonThemeDispatch).toHaveBeenCalledTimes(1); + expect(mockUseParagonThemeDispatch).toHaveBeenCalledWith({ + payload: 'dark', + type: SET_THEME_VARIANT, + }); + expect(localStorage.setItem).toHaveBeenLastCalledWith(SELECTED_THEME_VARIANT_KEY, 'dark'); + }); + }); + + describe('useAppEvent', () => { + it('subscribes to `AUTHENTICATED_USER_CHANGED`', async () => { + const Component = ( + +
Child
+
+ ); + render(Component); + expect(useAppEvent).toHaveBeenCalledWith(AUTHENTICATED_USER_CHANGED, expect.any(Function)); + const useAppEventMockCalls = useAppEvent.mock.calls; + const authUserChangedFn = useAppEventMockCalls.find(([event]) => event === AUTHENTICATED_USER_CHANGED)[1]; + expect(authUserChangedFn).toBeDefined(); + const getAuthUserCallCount = getAuthenticatedUser.mock.calls.length; + authUserChangedFn(); + expect(getAuthUserCallCount + 1).toEqual(getAuthenticatedUser.mock.calls.length); + }); + + it('subscribes to `CONFIG_CHANGED`', async () => { + const Component = ( + +
Child
+
+ ); + render(Component); + expect(useAppEvent).toHaveBeenCalledWith(CONFIG_CHANGED, expect.any(Function)); + const useAppEventMockCalls = useAppEvent.mock.calls; + const configChangedFn = useAppEventMockCalls.find(([event]) => event === CONFIG_CHANGED)[1]; + expect(configChangedFn).toBeDefined(); + const getConfigCallCount = getConfig.mock.calls.length; + configChangedFn(); + expect(getConfig.mock.calls.length).toEqual(getConfigCallCount + 1); + }); + + it('subscribes to `LOCALE_CHANGED`', async () => { + const Component = ( + +
Child
+
+ ); + render(Component); + expect(useAppEvent).toHaveBeenCalledWith(LOCALE_CHANGED, expect.any(Function)); + const useAppEventMockCalls = useAppEvent.mock.calls; + const localeChangedFn = useAppEventMockCalls.find(([event]) => event === LOCALE_CHANGED)[1]; + expect(localeChangedFn).toBeDefined(); + const getLocaleCallCount = getLocale.mock.calls.length; + localeChangedFn(); + expect(getLocale.mock.calls.length).toEqual(getLocaleCallCount + 1); + }); + }); }); diff --git a/src/react/constants.js b/src/react/constants.js index 08de95fc0..89f640bd4 100644 --- a/src/react/constants.js +++ b/src/react/constants.js @@ -1,2 +1,3 @@ export const SET_THEME_VARIANT = 'SET_THEME_VARIANT'; export const SET_IS_THEME_LOADED = 'SET_IS_THEME_LOADED'; +export const SELECTED_THEME_VARIANT_KEY = 'selected-paragon-theme-variant'; diff --git a/src/react/hooks.js b/src/react/hooks.js deleted file mode 100644 index b31ae90a4..000000000 --- a/src/react/hooks.js +++ /dev/null @@ -1,553 +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'; - -/** - * 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, -}) => { - const [isParagonThemeVariantLoaded, setIsParagonThemeVariantLoaded] = useState(false); - const [isBrandThemeVariantLoaded, setIsBrandThemeVariantLoaded] = useState(false); - - 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 stylesheetRelForVariant = generateStylesheetRelAttr(themeVariant); - if (!existingThemeVariantLink) { - 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 = 'stylesheet'; - if (isBrandOverride) { - themeVariantLink.dataset.brandThemeVariant = themeVariant; - } else { - themeVariantLink.dataset.paragonThemeVariant = themeVariant; - } - themeVariantLink.onload = () => { - if (themeVariant === currentThemeVariant) { - if (isBrandOverride) { - setIsBrandThemeVariantLoaded(true); - } else { - setIsParagonThemeVariantLoaded(true); - } - } else { - const existingLinks = getExistingThemeVariantLinks(isBrandOverride); - removeExistingLinks(existingLinks); - } - }; - 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) { - 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} or fallback URL. Aborting.`); - } - }; - return themeVariantLink; - }; - - 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 if (existingThemeVariantLink.rel !== stylesheetRelForVariant) { - existingThemeVariantLink.rel = stylesheetRelForVariant; - } - }); - }, [themeVariants, currentThemeVariant, onLoad]); -}; - -const handleParagonVersionSubstitution = (url, { isBrandOverride = false } = {}) => { - const localVersion = isBrandOverride ? PARAGON_THEME?.brand?.version : PARAGON_THEME?.paragon?.version; - const wildcardKeyword = isBrandOverride ? '$brandVersion' : '$paragonVersion'; - 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.} 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 coreCss = { - default: handleParagonVersionSubstitution(paragonCoreCssUrl), - brandOverride: handleParagonVersionSubstitution(brandCoreCssUrl, { isBrandOverride: true }), - }; - - const themeVariantsCss = {}; - const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {}); - themeVariantsEntries.forEach(([themeVariant, { - url, urls, default: isDefaultThemeVariant, dark, - }]) => { - const themeVariantMetadata = { - default: isDefaultThemeVariant, - dark, - }; - if (url) { - themeVariantMetadata.urls = { - default: handleParagonVersionSubstitution(url), - }; - } else { - themeVariantMetadata.urls = { - default: handleParagonVersionSubstitution(urls.default), - brandOverride: handleParagonVersionSubstitution(urls.brandOverride, { isBrandOverride: true }), - }; - } - themeVariantsCss[themeVariant] = themeVariantMetadata; - }); - - const hasMissingCssUrls = !coreCss.default || Object.keys(themeVariantsCss).length === 0; - if (hasMissingCssUrls) { - if (!PARAGON_THEME) { - return undefined; - } - const themeVariants = {}; - const prependBaseUrl = (url) => `${config.BASE_URL}/${url}`; - themeVariantsEntries.forEach(([themeVariant, { fileName, ...rest }]) => { - themeVariants[themeVariant] = { - url: prependBaseUrl(fileName), - ...rest, - }; - }); - return { - core: { urls: coreCss }, - variants: themeVariants, - }; - } - - return { - core: { urls: coreCss }, - 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) => { - if (!themeVariants) { - return undefined; - } - const themeVariantKeys = Object.keys(themeVariants); - if (themeVariantKeys.length === 0) { - return undefined; - } - if (themeVariantKeys.length === 1) { - return { - name: themeVariantKeys[0], - metadata: themeVariants[themeVariantKeys[0]], - }; - } - const foundDefaultThemeVariant = Object.values(themeVariants) - .find(({ default: isDefault }) => isDefault === true); - - if (!foundDefaultThemeVariant) { - return undefined; - } - return { - name: foundDefaultThemeVariant[0], - metadata: foundDefaultThemeVariant[1], - }; -}; - -/** - * 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, - variants: themeVariants, - } = paragonThemeUrls || {}; - const initialParagonThemeState = { - isThemeLoaded: false, - themeVariant: getDefaultThemeVariant(themeVariants)?.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, - }); - - // load the theme variant(s) CSS - useParagonThemeVariants({ - themeVariants, - onLoad: onLoadThemeVariants, - currentThemeVariant: themeState.themeVariant, - }); - - 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)); - } - - const isDefaultThemeLoaded = (isCoreThemeLoaded && hasLoadedThemeVariants); - if (!isDefaultThemeLoaded) { - 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/useParagonThemeCore.test.js b/src/react/hooks/paragon/useParagonThemeCore.test.js new file mode 100644 index 000000000..1eecf876c --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeCore.test.js @@ -0,0 +1,101 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { getConfig } from '../../../config'; +import { logError } from '../../../logging'; +import useParagonThemeCore from './useParagonThemeCore'; + +jest.mock('../../../logging'); + +describe('useParagonThemeCore', () => { + const themeOnLoad = jest.fn(); + + afterEach(() => { + document.head.innerHTML = ''; + jest.clearAllMocks(); + }); + + it('should load the core url and change the loading state to true', () => { + const coreConfig = { + themeCore: { + urls: { default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/core.min.css' }, + }, + onLoad: themeOnLoad, + }; + + renderHook(() => useParagonThemeCore(coreConfig)); + const createdLinkTag = document.head.querySelector('link'); + act(() => createdLinkTag.onload()); + expect(createdLinkTag.href).toBe(coreConfig.themeCore.urls.default); + expect(themeOnLoad).toHaveBeenCalledTimes(1); + }); + + it('should load the core default and brand url and change the loading state to true', () => { + const coreConfig = { + themeCore: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/core.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0Version/dist/core.min.css', + }, + }, + onLoad: themeOnLoad, + }; + + renderHook(() => useParagonThemeCore(coreConfig)); + const createdLinkTag = document.head.querySelector('link[data-paragon-theme-core="true"]'); + const createdBrandLinkTag = document.head.querySelector('link[data-brand-theme-core="true"]'); + + act(() => { createdLinkTag.onload(); createdBrandLinkTag.onload(); }); + expect(createdLinkTag.href).toBe(coreConfig.themeCore.urls.default); + expect(createdBrandLinkTag.href).toBe(coreConfig.themeCore.urls.brandOverride); + expect(themeOnLoad).toHaveBeenCalledTimes(1); + }); + + it('should dispatch a log error and fallback to PARAGON_THEME if can not load the core theme link', () => { + global.PARAGON_THEME = { + paragon: { + version: '1.0.0', + themeUrls: { + core: { + fileName: 'core.min.css', + }, + defaults: { + light: 'light', + }, + variants: { + light: { + fileName: 'light.min.css', + }, + }, + }, + }, + }; + const coreConfig = { + themeCore: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/core.min.css', + }, + }, + onLoad: themeOnLoad, + }; + + renderHook(() => useParagonThemeCore(coreConfig)); + const createdLinkTag = document.head.querySelector('link[data-paragon-theme-core="true"]'); + + act(() => { createdLinkTag.onerror(); }); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith(`Failed to load core theme CSS from ${coreConfig.themeCore.urls.default}`); + expect(document.querySelector('link').href).toBe(`${getConfig().BASE_URL}/${PARAGON_THEME.paragon.themeUrls.core.fileName}`); + }); + + it('should not create any core link if can not find themeCore urls definition', () => { + const coreConfig = { + themeCore: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/core.min.css', + }, + onLoad: themeOnLoad, + }; + + renderHook(() => useParagonThemeCore(coreConfig)); + expect(document.head.querySelectorAll('link').length).toBe(0); + expect(themeOnLoad).toHaveBeenCalledTimes(1); + }); +}); 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/useParagonThemeVariants.test.js b/src/react/hooks/paragon/useParagonThemeVariants.test.js new file mode 100644 index 000000000..d43dd5588 --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeVariants.test.js @@ -0,0 +1,140 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { getConfig } from '../../../config'; +import { logError } from '../../../logging'; +import useParagonThemeVariants from './useParagonThemeVariants'; + +jest.mock('../../../logging'); + +const mockAddEventListener = jest.fn(); +const mockRemoveEventListener = jest.fn(); +const mockOnChange = jest.fn(); + +Object.defineProperty(window, 'matchMedia', { + value: jest.fn(() => ({ + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + onchange: mockOnChange, + })), +}); + +describe('useParagonThemeVariants', () => { + const themeOnLoad = jest.fn(); + + afterEach(() => { + document.head.innerHTML = ''; + jest.clearAllMocks(); + }); + + it('should create the links tags for each theme variant and change the state to true when all variants are loaded', () => { + const themeVariants = { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/light.min.css', + }, + }, + dark: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/dark.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/dark.min.css', + }, + }, + }; + const currentThemeVariant = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentThemeVariant, onLoad: themeOnLoad })); + const themeLinks = document.head.querySelectorAll('link'); + act(() => { themeLinks.forEach((link) => link.onload()); }); + + expect(themeLinks.length).toBe(4); + }); + + it('should dispatch a log error and fallback to PARAGON_THEME if can not load the variant theme link', () => { + global.PARAGON_THEME = { + paragon: { + version: '1.0.0', + themeUrls: { + core: { + fileName: 'core.min.css', + }, + defaults: { + light: 'light', + }, + variants: { + light: { + fileName: 'light.min.css', + }, + }, + }, + }, + }; + const themeVariants = { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + }, + }, + }; + const currentThemeVariant = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentThemeVariant, onLoad: themeOnLoad })); + const createdLinkTag = document.head.querySelector('link'); + act(() => { createdLinkTag.onerror(); }); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith(`Failed to load theme variant (${currentThemeVariant}) CSS from ${themeVariants.light.urls.default}`); + expect(document.querySelector('link').href).toBe(`${getConfig().BASE_URL}/${PARAGON_THEME.paragon.themeUrls.variants.light.fileName}`); + }); + + it('should configure theme variants according with system preference and add the change event listener', () => { + window.matchMedia['prefers-color-scheme'] = 'dark'; + + const themeVariants = { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/light.min.css', + }, + }, + dark: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/dark.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/dark.min.css', + }, + }, + }; + + const currentThemeVariant = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentThemeVariant, onLoad: themeOnLoad })); + + const themeLinks = document.head.querySelectorAll('link'); + act(() => { themeLinks.forEach((link) => link.onload()); }); + + expect(mockAddEventListener).toHaveBeenCalledTimes(1); + }); + + it('should do nothing if themeVariants is not configured', () => { + const themeVariants = null; + const currentTheme = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentTheme, onLoad: themeOnLoad })); + expect(document.head.querySelectorAll('link').length).toBe(0); + }); + + it('should not create any core link if can not find themeVariant urls definition', () => { + const themeVariants = { + light: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + }, + dark: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/dark.min.css', + }, + }; + + const currentTheme = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentTheme, onLoad: themeOnLoad })); + + expect(document.head.querySelectorAll('link').length).toBe(0); + }); +}); 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.test.jsx b/src/react/hooks/paragon/useTrackColorSchemeChoice.test.js similarity index 84% rename from src/react/hooks.test.jsx rename to src/react/hooks/paragon/useTrackColorSchemeChoice.test.js index 48623a3b3..1c8bf5e4f 100644 --- a/src/react/hooks.test.jsx +++ b/src/react/hooks/paragon/useTrackColorSchemeChoice.test.js @@ -1,8 +1,13 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useTrackColorSchemeChoice } from './hooks'; -import { sendTrackEvent } from '../analytics'; -jest.mock('../analytics'); +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(); @@ -16,7 +21,7 @@ Object.defineProperty(window, 'matchMedia', { })), }); -describe('useTrackColorSchemeChoice hook', () => { +describe('useTrackColorSchemeChoice', () => { afterEach(() => { mockAddEventListener.mockClear(); mockRemoveEventListener.mockClear(); 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;