@openedx/frontend-base 1.0.0-alpha.6 → 1.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/config/eslint/base.eslint.config.js +1 -1
  2. package/config/webpack/common-config/all/getStylesheetRule.js +1 -1
  3. package/config/webpack/webpack.config.build.js +0 -11
  4. package/config/webpack/webpack.config.dev.js +0 -11
  5. package/config/webpack/webpack.config.dev.shell.js +0 -11
  6. package/package.json +4 -3
  7. package/runtime/config/index.ts +2 -3
  8. package/runtime/react/SiteProvider.tsx +26 -3
  9. package/runtime/react/constants.ts +3 -0
  10. package/runtime/react/hooks/index.ts +8 -0
  11. package/runtime/react/hooks/theme/index.ts +2 -0
  12. package/runtime/react/hooks/theme/useTheme.test.ts +221 -0
  13. package/runtime/react/hooks/theme/useTheme.ts +179 -0
  14. package/runtime/react/hooks/theme/useThemeConfig.test.ts +107 -0
  15. package/runtime/react/hooks/theme/useThemeConfig.ts +34 -0
  16. package/runtime/react/hooks/theme/useThemeCore.test.ts +65 -0
  17. package/runtime/react/hooks/theme/useThemeCore.ts +52 -0
  18. package/runtime/react/hooks/theme/useThemeVariants.test.ts +97 -0
  19. package/runtime/react/hooks/theme/useThemeVariants.ts +116 -0
  20. package/runtime/react/hooks/theme/useTrackColorSchemeChoice.test.ts +54 -0
  21. package/runtime/react/hooks/theme/useTrackColorSchemeChoice.ts +30 -0
  22. package/runtime/react/hooks/theme/utils.ts +11 -0
  23. package/runtime/react/hooks/useActiveRoles.ts +15 -0
  24. package/runtime/react/hooks/useActiveRouteRoleWatcher.ts +31 -0
  25. package/runtime/react/hooks/useAppConfig.ts +9 -0
  26. package/runtime/react/hooks/useAuthenticatedUser.test.tsx +41 -0
  27. package/runtime/react/hooks/useAuthenticatedUser.ts +9 -0
  28. package/runtime/react/hooks/useSiteConfig.test.tsx +13 -0
  29. package/runtime/react/hooks/useSiteConfig.ts +9 -0
  30. package/runtime/react/hooks/useSiteEvent.ts +24 -0
  31. package/runtime/react/reducers.ts +40 -0
  32. package/runtime/setupTest.js +0 -35
  33. package/runtime/testing/initializeMockApp.ts +5 -0
  34. package/shell/app.scss +2 -1
  35. package/shell/setupTest.js +0 -35
  36. package/tools/dist/eslint/base.eslint.config.js +1 -1
  37. package/tools/dist/webpack/common-config/all/getStylesheetRule.js +1 -1
  38. package/tools/dist/webpack/webpack.config.build.js +0 -11
  39. package/tools/dist/webpack/webpack.config.dev.js +0 -11
  40. package/tools/dist/webpack/webpack.config.dev.shell.js +0 -11
  41. package/types.ts +20 -0
  42. package/config/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
  43. package/config/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
  44. package/config/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
  45. package/config/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
  46. package/config/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
  47. package/config/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
  48. package/config/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
  49. package/config/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
  50. package/config/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
  51. package/config/webpack/types.js +0 -2
  52. package/config/webpack/utils/paragonUtils.js +0 -138
  53. package/runtime/react/hooks.test.jsx +0 -104
  54. package/runtime/react/hooks.ts +0 -106
  55. package/tools/dist/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
  56. package/tools/dist/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
  57. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
  58. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
  59. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
  60. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
  61. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
  62. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
  63. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
  64. package/tools/dist/webpack/types.js +0 -2
  65. package/tools/dist/webpack/utils/paragonUtils.js +0 -138
@@ -31,7 +31,6 @@ module.exports = tseslint.config(eslint.configs.recommended, ...tseslint.configs
31
31
  ...globals.browser,
32
32
  ...globals.node,
33
33
  ...globals.jest,
34
- PARAGON_THEME: 'readonly',
35
34
  newrelic: 'readonly',
36
35
  },
37
36
  },
@@ -79,6 +78,7 @@ module.exports = tseslint.config(eslint.configs.recommended, ...tseslint.configs
79
78
  caughtErrors: 'none',
80
79
  }],
81
80
  '@typescript-eslint/no-empty-function': 'off',
81
+ '@typescript-eslint/prefer-nullish-coalescing': 'off',
82
82
  '@stylistic/semi': ['error', 'always', { omitLastInOneLineBlock: true, omitLastInOneLineClassBody: true }],
83
83
  '@stylistic/quotes': ['error', 'single', {
84
84
  avoidEscape: true,
@@ -87,7 +87,7 @@ function getStyleUseConfig(mode) {
87
87
  ],
88
88
  // Silences compiler deprecation warnings. They mostly come from bootstrap and/or paragon.
89
89
  quietDeps: true,
90
- silenceDeprecations: ['abs-percent', 'color-functions', 'import', 'mixed-decls', 'global-builtin', 'legacy-js-api'],
90
+ silenceDeprecations: ['abs-percent', 'color-functions', 'import', 'global-builtin', 'legacy-js-api'],
91
91
  },
92
92
  },
93
93
  },
@@ -9,13 +9,9 @@ const path_1 = __importDefault(require("path"));
9
9
  const webpack_bundle_analyzer_1 = require("webpack-bundle-analyzer");
10
10
  const webpack_remove_empty_scripts_1 = __importDefault(require("webpack-remove-empty-scripts"));
11
11
  const common_config_1 = require("./common-config");
12
- const ParagonWebpackPlugin_1 = __importDefault(require("./plugins/paragon-webpack-plugin/ParagonWebpackPlugin"));
13
12
  const getLocalAliases_1 = __importDefault(require("./utils/getLocalAliases"));
14
13
  const getPublicPath_1 = __importDefault(require("./utils/getPublicPath"));
15
14
  const getResolvedSiteConfigPath_1 = __importDefault(require("./utils/getResolvedSiteConfigPath"));
16
- const paragonUtils_1 = require("./utils/paragonUtils");
17
- const paragonThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd());
18
- const brandThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd(), { isBrandOverride: true });
19
15
  const aliases = (0, getLocalAliases_1.default)();
20
16
  const resolvedSiteConfigPath = (0, getResolvedSiteConfigPath_1.default)('site.config.build.tsx');
21
17
  const config = {
@@ -23,8 +19,6 @@ const config = {
23
19
  devtool: 'source-map',
24
20
  entry: {
25
21
  app: path_1.default.resolve(process.cwd(), 'node_modules/@openedx/frontend-base/shell/site'),
26
- ...(0, paragonUtils_1.getParagonEntryPoints)(paragonThemeCss),
27
- ...(0, paragonUtils_1.getParagonEntryPoints)(brandThemeCss),
28
22
  },
29
23
  output: {
30
24
  filename: '[name].[chunkhash].js',
@@ -51,10 +45,6 @@ const config = {
51
45
  runtimeChunk: 'single',
52
46
  splitChunks: {
53
47
  chunks: 'all',
54
- cacheGroups: {
55
- ...(0, paragonUtils_1.getParagonCacheGroups)(paragonThemeCss),
56
- ...(0, paragonUtils_1.getParagonCacheGroups)(brandThemeCss),
57
- },
58
48
  },
59
49
  minimizer: (0, common_config_1.getImageMinimizer)(),
60
50
  },
@@ -65,7 +55,6 @@ const config = {
65
55
  // See: https://www.npmjs.com/package/webpack-remove-empty-scripts#usage-with-mini-css-extract-plugin
66
56
  new webpack_remove_empty_scripts_1.default(),
67
57
  // Writes the extracted CSS from each entry to a file in the output directory.
68
- new ParagonWebpackPlugin_1.default(),
69
58
  new mini_css_extract_plugin_1.default({
70
59
  filename: '[name].[chunkhash].css',
71
60
  }),
@@ -9,20 +9,14 @@ const mini_css_extract_plugin_1 = __importDefault(require("mini-css-extract-plug
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const webpack_remove_empty_scripts_1 = __importDefault(require("webpack-remove-empty-scripts"));
11
11
  const common_config_1 = require("./common-config");
12
- const ParagonWebpackPlugin_1 = __importDefault(require("./plugins/paragon-webpack-plugin/ParagonWebpackPlugin"));
13
12
  const getLocalAliases_1 = __importDefault(require("./utils/getLocalAliases"));
14
13
  const getPublicPath_1 = __importDefault(require("./utils/getPublicPath"));
15
14
  const getResolvedSiteConfigPath_1 = __importDefault(require("./utils/getResolvedSiteConfigPath"));
16
- const paragonUtils_1 = require("./utils/paragonUtils");
17
- const paragonThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd());
18
- const brandThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd(), { isBrandOverride: true });
19
15
  const aliases = (0, getLocalAliases_1.default)();
20
16
  const resolvedSiteConfigPath = (0, getResolvedSiteConfigPath_1.default)('site.config.dev.tsx');
21
17
  const config = {
22
18
  entry: {
23
19
  app: path_1.default.resolve(process.cwd(), 'node_modules/@openedx/frontend-base/shell/site'),
24
- ...(0, paragonUtils_1.getParagonEntryPoints)(paragonThemeCss),
25
- ...(0, paragonUtils_1.getParagonEntryPoints)(brandThemeCss),
26
20
  },
27
21
  output: {
28
22
  path: path_1.default.resolve(process.cwd(), './dist'),
@@ -47,10 +41,6 @@ const config = {
47
41
  optimization: {
48
42
  splitChunks: {
49
43
  chunks: 'all',
50
- cacheGroups: {
51
- ...(0, paragonUtils_1.getParagonCacheGroups)(paragonThemeCss),
52
- ...(0, paragonUtils_1.getParagonCacheGroups)(brandThemeCss),
53
- },
54
44
  },
55
45
  minimizer: (0, common_config_1.getImageMinimizer)(),
56
46
  },
@@ -60,7 +50,6 @@ const config = {
60
50
  // This helps to clean up the final bundle application
61
51
  // See: https://www.npmjs.com/package/webpack-remove-empty-scripts#usage-with-mini-css-extract-plugin
62
52
  new webpack_remove_empty_scripts_1.default(),
63
- new ParagonWebpackPlugin_1.default(),
64
53
  // Writes the extracted CSS from each entry to a file in the output directory.
65
54
  new mini_css_extract_plugin_1.default({
66
55
  filename: '[name].css',
@@ -11,21 +11,15 @@ const path_1 = __importDefault(require("path"));
11
11
  const react_refresh_typescript_1 = __importDefault(require("react-refresh-typescript"));
12
12
  const webpack_remove_empty_scripts_1 = __importDefault(require("webpack-remove-empty-scripts"));
13
13
  const common_config_1 = require("./common-config");
14
- const ParagonWebpackPlugin_1 = __importDefault(require("./plugins/paragon-webpack-plugin/ParagonWebpackPlugin"));
15
14
  const html_webpack_plugin_1 = __importDefault(require("html-webpack-plugin"));
16
15
  const getLocalAliases_1 = __importDefault(require("./utils/getLocalAliases"));
17
16
  const getPublicPath_1 = __importDefault(require("./utils/getPublicPath"));
18
17
  const getResolvedSiteConfigPath_1 = __importDefault(require("./utils/getResolvedSiteConfigPath"));
19
- const paragonUtils_1 = require("./utils/paragonUtils");
20
- const paragonThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd());
21
- const brandThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd(), { isBrandOverride: true });
22
18
  const aliases = (0, getLocalAliases_1.default)();
23
19
  const resolvedSiteConfigPath = (0, getResolvedSiteConfigPath_1.default)('shell/site.config.dev.tsx');
24
20
  const config = {
25
21
  entry: {
26
22
  app: path_1.default.resolve(process.cwd(), 'shell/site'),
27
- ...(0, paragonUtils_1.getParagonEntryPoints)(paragonThemeCss),
28
- ...(0, paragonUtils_1.getParagonEntryPoints)(brandThemeCss),
29
23
  },
30
24
  output: {
31
25
  path: path_1.default.resolve(process.cwd(), './dist'),
@@ -77,10 +71,6 @@ const config = {
77
71
  optimization: {
78
72
  splitChunks: {
79
73
  chunks: 'all',
80
- cacheGroups: {
81
- ...(0, paragonUtils_1.getParagonCacheGroups)(paragonThemeCss),
82
- ...(0, paragonUtils_1.getParagonCacheGroups)(brandThemeCss),
83
- },
84
74
  },
85
75
  minimizer: (0, common_config_1.getImageMinimizer)(),
86
76
  },
@@ -90,7 +80,6 @@ const config = {
90
80
  // This helps to clean up the final bundle application
91
81
  // See: https://www.npmjs.com/package/webpack-remove-empty-scripts#usage-with-mini-css-extract-plugin
92
82
  new webpack_remove_empty_scripts_1.default(),
93
- new ParagonWebpackPlugin_1.default(),
94
83
  // Writes the extracted CSS from each entry to a file in the output directory.
95
84
  new mini_css_extract_plugin_1.default({
96
85
  filename: '[name].css',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openedx/frontend-base",
3
- "version": "1.0.0-alpha.6",
3
+ "version": "1.0.0-alpha.8",
4
4
  "description": "Build tools, setup and config for frontend apps",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -86,6 +86,7 @@
86
86
  "image-minimizer-webpack-plugin": "3.8.3",
87
87
  "jest": "^29.7.0",
88
88
  "jest-environment-jsdom": "^29.7.0",
89
+ "jest-localstorage-mock": "^2.4.26",
89
90
  "jwt-decode": "^3.1.2",
90
91
  "localforage": "^1.10.0",
91
92
  "localforage-memoryStorageDriver": "^0.9.2",
@@ -101,7 +102,7 @@
101
102
  "postcss-rtlcss": "^5.5.0",
102
103
  "prop-types": "^15.8.1",
103
104
  "react-dev-utils": "12.0.1",
104
- "react-focus-on": "^3.9.4",
105
+ "react-focus-on": "<3.10.0",
105
106
  "react-intl": "^6.6.6",
106
107
  "react-refresh": "0.16.0",
107
108
  "react-refresh-typescript": "^2.0.9",
@@ -146,7 +147,7 @@
146
147
  "nodemon": "^3.1.4"
147
148
  },
148
149
  "peerDependencies": {
149
- "@openedx/paragon": "^22.20.2",
150
+ "@openedx/paragon": "^23.4.5",
150
151
  "@tanstack/react-query": "^5.81.2",
151
152
  "react": "^18.3.1",
152
153
  "react-dom": "^18.3.1",
@@ -124,6 +124,7 @@ let siteConfig: SiteConfig = {
124
124
  externalRoutes: [],
125
125
  externalLinkUrlOverrides: [],
126
126
  mfeConfigApiUrl: null,
127
+ theme: {},
127
128
  accessTokenCookieName: 'edx-jwt-cookie-header-payload',
128
129
  csrfTokenApiPath: '/csrf/api/v1/token',
129
130
  ignoredErrorRegex: null,
@@ -238,9 +239,7 @@ export function getActiveRouteRoles() {
238
239
  const activeWidgetRoles: Record<string, number> = {};
239
240
 
240
241
  export function addActiveWidgetRole(role: string) {
241
- if (activeWidgetRoles[role] === undefined) {
242
- activeWidgetRoles[role] = 0;
243
- }
242
+ activeWidgetRoles[role] ??= 0;
244
243
  activeWidgetRoles[role] += 1;
245
244
  publish(ACTIVE_ROLES_CHANGED);
246
245
  }
@@ -13,7 +13,13 @@ import {
13
13
  import CombinedAppProvider from './CombinedAppProvider';
14
14
  import ErrorBoundary from './ErrorBoundary';
15
15
  import SiteContext from './SiteContext';
16
- import { useSiteEvent } from './hooks';
16
+ import { SELECTED_THEME_VARIANT_KEY } from './constants';
17
+ import {
18
+ useTheme,
19
+ useSiteEvent,
20
+ useTrackColorSchemeChoice
21
+ } from './hooks';
22
+ import { themeActions } from './reducers';
17
23
 
18
24
  interface SiteProviderProps {
19
25
  children: ReactNode,
@@ -37,6 +43,7 @@ interface SiteProviderProps {
37
43
  * - An error boundary as described above.
38
44
  * - An `SiteContext` provider for React context data.
39
45
  * - IntlProvider for @edx/frontend-i18n internationalization
46
+ * - A theme manager for Paragon.
40
47
  *
41
48
  * @param {Object} props
42
49
  * @memberof module:React
@@ -58,11 +65,27 @@ export default function SiteProvider({ children }: SiteProviderProps) {
58
65
  setLocale(getLocale());
59
66
  });
60
67
 
68
+ useTrackColorSchemeChoice();
69
+ const [themeState, themeDispatch] = useTheme();
70
+
61
71
  const siteContextValue = useMemo(() => ({
62
72
  authenticatedUser,
63
73
  siteConfig,
64
- locale
65
- }), [authenticatedUser, siteConfig, locale]);
74
+ locale,
75
+ theme: {
76
+ state: themeState,
77
+ setThemeVariant: (themeVariant: string) => {
78
+ themeDispatch(themeActions.setThemeVariant(themeVariant));
79
+
80
+ // Persist selected theme variant to localStorage.
81
+ window.localStorage.setItem(SELECTED_THEME_VARIANT_KEY, themeVariant);
82
+ },
83
+ },
84
+ }), [authenticatedUser, siteConfig, locale, themeState, themeDispatch]);
85
+
86
+ if (!themeState?.isThemeLoaded) {
87
+ return null;
88
+ }
66
89
 
67
90
  return (
68
91
  <IntlProvider locale={locale} messages={getMessages()}>
@@ -0,0 +1,3 @@
1
+ export const SET_THEME_VARIANT = 'SET_THEME_VARIANT';
2
+ export const SET_IS_THEME_LOADED = 'SET_IS_THEME_LOADED';
3
+ export const SELECTED_THEME_VARIANT_KEY = 'selected-theme-variant';
@@ -0,0 +1,8 @@
1
+ export { default as useAppConfig } from './useAppConfig';
2
+ export { default as useAuthenticatedUser } from './useAuthenticatedUser';
3
+ export { default as useActiveRouteRoleWatcher } from './useActiveRouteRoleWatcher';
4
+ export { default as useActiveRoles } from './useActiveRoles';
5
+ export { default as useSiteConfig } from './useSiteConfig';
6
+ export { default as useSiteEvent } from './useSiteEvent';
7
+
8
+ export * from './theme';
@@ -0,0 +1,2 @@
1
+ export { default as useTrackColorSchemeChoice } from './useTrackColorSchemeChoice';
2
+ export { default as useTheme } from './useTheme';
@@ -0,0 +1,221 @@
1
+ import { act, fireEvent, renderHook } from '@testing-library/react';
2
+
3
+ import useTheme from './useTheme';
4
+ import * as config from '../../../config';
5
+ import { logError } from '../../../logging';
6
+
7
+ jest.mock('../../../logging');
8
+
9
+ const baseSiteConfig = config.getSiteConfig();
10
+
11
+ const theme = {
12
+ core: {
13
+ url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/core.min.css',
14
+ },
15
+ defaults: {
16
+ light: 'light',
17
+ dark: 'dark',
18
+ },
19
+ variants: {
20
+ light: {
21
+ url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/light.min.css',
22
+ },
23
+ dark: {
24
+ url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/dark.min.css',
25
+ },
26
+ },
27
+ };
28
+
29
+ let mockMediaQueryListEvent;
30
+ const mockAddEventListener = jest.fn((_, fn) => fn(mockMediaQueryListEvent));
31
+ const mockRemoveEventListener = jest.fn();
32
+
33
+ Object.defineProperty(window, 'matchMedia', {
34
+ value: jest.fn(() => ({
35
+ addEventListener: mockAddEventListener,
36
+ removeEventListener: mockRemoveEventListener,
37
+ matches: mockMediaQueryListEvent.matches,
38
+ })),
39
+ });
40
+
41
+ Object.defineProperty(window, 'localStorage', {
42
+ value: {
43
+ getItem: jest.fn(),
44
+ },
45
+ });
46
+
47
+ describe('useTheme', () => {
48
+ beforeEach(() => {
49
+ document.head.innerHTML = '';
50
+ mockMediaQueryListEvent = { matches: true };
51
+ mockAddEventListener.mockClear();
52
+ mockRemoveEventListener.mockClear();
53
+ jest.mocked(window.localStorage.getItem).mockClear();
54
+ });
55
+
56
+ afterEach(() => {
57
+ jest.spyOn(config, 'getSiteConfig').mockRestore();
58
+ });
59
+
60
+ it.each([
61
+ ['dark', 'stylesheet', 'alternate stylesheet', true], // preference is dark
62
+ ['light', 'alternate stylesheet', 'stylesheet', false], // preference is light
63
+ ])(
64
+ 'should configure theme variant for system preference %s and handle theme change events',
65
+ (initialPreference, expectedDarkRel, expectedLightRel, isDarkMediaMatch) => {
66
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
67
+ ...baseSiteConfig,
68
+ theme
69
+ });
70
+ // Mock the matchMedia behavior to simulate system preference
71
+ mockMediaQueryListEvent = { matches: isDarkMediaMatch };
72
+ // Set up the hook and initial theme configuration
73
+ const { result, unmount } = renderHook(() => useTheme());
74
+ const themeLinks = document.head.querySelectorAll('link');
75
+
76
+ const checkThemeLinks = () => {
77
+ const darkLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="dark"]');
78
+ const lightLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="light"]');
79
+ expect(darkLink?.rel).toBe(expectedDarkRel);
80
+ expect(lightLink?.rel).toBe(expectedLightRel);
81
+ };
82
+
83
+ // Simulate initial theme configuration based on system preference
84
+ act(() => {
85
+ themeLinks.forEach((link) => fireEvent.load(link));
86
+ });
87
+
88
+ // Ensure matchMedia was called with the correct system preference
89
+ expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
90
+ expect(mockAddEventListener).toHaveBeenCalled();
91
+
92
+ // Check initial theme setup
93
+ checkThemeLinks();
94
+ expect(result.current[0]).toEqual({
95
+ isThemeLoaded: true,
96
+ themeVariant: initialPreference,
97
+ });
98
+
99
+ unmount();
100
+ expect(mockRemoveEventListener).toHaveBeenCalled();
101
+ },
102
+ );
103
+
104
+ it('should configure theme variants according with user preference if is defined (localStorage)', () => {
105
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
106
+ ...baseSiteConfig,
107
+ theme
108
+ });
109
+ jest.mocked(window.localStorage.getItem).mockReturnValue('light');
110
+ const { result, unmount } = renderHook(() => useTheme());
111
+ const themeLinks = document.head.querySelectorAll('link');
112
+ const darkLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="dark"]');
113
+ const lightLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="light"]');
114
+
115
+ act(() => {
116
+ themeLinks.forEach((link) => fireEvent.load(link));
117
+ });
118
+
119
+ expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
120
+ expect(mockAddEventListener).toHaveBeenCalled();
121
+
122
+ expect(darkLink?.rel).toBe('alternate stylesheet');
123
+ expect(lightLink?.rel).toBe('stylesheet');
124
+ expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'light' });
125
+
126
+ unmount();
127
+ expect(mockRemoveEventListener).toHaveBeenCalled();
128
+ });
129
+
130
+ it('should define the theme variant as default if only 1 is configured', () => {
131
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
132
+ ...baseSiteConfig,
133
+ theme: { ...theme, variants: { light: theme.variants.light } }
134
+ });
135
+ jest.mocked(window.localStorage.getItem).mockReturnValue('light');
136
+ const { result, unmount } = renderHook(() => useTheme());
137
+ const themeLinks = document.head.querySelectorAll('link');
138
+ const themeVariantLinks: NodeListOf<HTMLAnchorElement> | null = document.head.querySelectorAll('link[data-theme-variant]');
139
+
140
+ act(() => {
141
+ themeLinks.forEach((link) => fireEvent.load(link));
142
+ });
143
+
144
+ expect(themeVariantLinks.length).toBe(1);
145
+ expect((themeVariantLinks[0]).rel).toBe('stylesheet');
146
+ expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'light' });
147
+
148
+ unmount();
149
+ expect(mockRemoveEventListener).toHaveBeenCalled();
150
+ });
151
+
152
+ it('should not configure any theme if theme is undefined', () => {
153
+ const { result, unmount } = renderHook(() => useTheme());
154
+ const themeLinks = document.head.querySelectorAll('link');
155
+
156
+ expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: undefined });
157
+ expect(themeLinks.length).toBe(0);
158
+ unmount();
159
+ });
160
+
161
+ it('should return themeVariant undefined if a default variant cannot be configured', () => {
162
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
163
+ ...baseSiteConfig,
164
+ theme: {
165
+ ...theme,
166
+ defaults: {
167
+ light: 'red'
168
+ },
169
+ variants: {
170
+ light: theme.variants.light,
171
+ green: { url: 'green-url' }
172
+ }
173
+ }
174
+ });
175
+ jest.mocked(window.localStorage.getItem).mockReturnValue(null);
176
+
177
+ const { result, unmount } = renderHook(() => useTheme());
178
+ const themeLinks = document.head.querySelectorAll('link');
179
+ const themeVariantLinks = document.head.querySelectorAll('link[data-theme-variant]');
180
+ act(() => {
181
+ themeLinks.forEach((link) => fireEvent.load(link));
182
+ });
183
+
184
+ expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: undefined });
185
+ expect(themeLinks.length).toBe(3);
186
+ themeVariantLinks.forEach((link: HTMLAnchorElement) => expect(link.rel).toBe('alternate stylesheet'));
187
+ unmount();
188
+ });
189
+
190
+ it('should log an error if the preferred theme variant cannot be set', async () => {
191
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
192
+ ...baseSiteConfig,
193
+ theme: {
194
+ ...theme,
195
+ defaults: {
196
+ light: 'light',
197
+ dark: 'dark'
198
+ },
199
+ variants: {
200
+ light: theme.variants.light,
201
+ green: { url: 'green-url' }
202
+ }
203
+ }
204
+ });
205
+ jest.mocked(window.localStorage.getItem).mockReturnValue(null);
206
+
207
+ const { result, unmount } = renderHook(() => useTheme());
208
+ const themeLinks = document.head.querySelectorAll('link');
209
+ const themeVariantLinks = document.head.querySelectorAll('link[data-theme-variant]');
210
+
211
+ act(() => {
212
+ themeLinks.forEach((link) => fireEvent.load(link));
213
+ });
214
+
215
+ expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'dark' });
216
+ expect(logError).toHaveBeenCalled();
217
+ expect(themeVariantLinks.length).toBe(2);
218
+ themeVariantLinks.forEach((link: HTMLAnchorElement) => expect(link.rel).toBe('alternate stylesheet'));
219
+ unmount();
220
+ });
221
+ });
@@ -0,0 +1,179 @@
1
+ import {
2
+ useCallback, useEffect, useReducer, useState,
3
+ } from 'react';
4
+
5
+ import { SELECTED_THEME_VARIANT_KEY } from '../../constants';
6
+ import { logError } from '../../../logging';
7
+ import { themeActions, themeReducer } from '../../reducers';
8
+ import { ThemeVariants, ThemeDefaults } from '../../../../types';
9
+ import { isEmptyObject } from './utils';
10
+
11
+ import useThemeCore from './useThemeCore';
12
+ import useThemeConfig from './useThemeConfig';
13
+ import useThemeVariants from './useThemeVariants';
14
+
15
+ /**
16
+ * Finds the default theme variant from the given theme variants object. If no default theme exists, the light theme
17
+ * variant is returned as a fallback.
18
+ *
19
+ * It prioritizes:
20
+ * 1. A persisted theme variant from localStorage.
21
+ * 2. A system preference (`prefers-color-scheme`).
22
+ * 3. The configured default theme variant.
23
+ *
24
+ * Returns the default theme variant, or `undefined` if no valid theme variant is found.
25
+ */
26
+ export const getDefaultThemeVariant = (themeVariants: ThemeVariants | undefined, themeDefaults: ThemeDefaults = {}) => {
27
+ if (!themeVariants) {
28
+ return undefined;
29
+ }
30
+
31
+ const themeVariantKeys = Object.keys(themeVariants);
32
+
33
+ // If there is only one theme variant, return it since it's the only one that may be used.
34
+ if (themeVariantKeys.length === 1) {
35
+ const themeVariantKey = themeVariantKeys[0];
36
+ return {
37
+ name: themeVariantKey,
38
+ metadata: themeVariants[themeVariantKey],
39
+ };
40
+ }
41
+
42
+ // Prioritize persisted localStorage theme variant preference.
43
+ const persistedSelectedThemeVariant = localStorage.getItem(SELECTED_THEME_VARIANT_KEY);
44
+ if (persistedSelectedThemeVariant && themeVariants[persistedSelectedThemeVariant]) {
45
+ return {
46
+ name: persistedSelectedThemeVariant,
47
+ metadata: themeVariants[persistedSelectedThemeVariant],
48
+ };
49
+ }
50
+
51
+ // Then, detect system preference via `prefers-color-scheme` media query and use
52
+ // the default dark theme variant, if one exists.
53
+ const hasDarkSystemPreference = !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches;
54
+ const defaultDarkThemeVariant = themeDefaults.dark ?? 'dark';
55
+ const darkThemeVariantMetadata = themeVariants[defaultDarkThemeVariant];
56
+
57
+ if (hasDarkSystemPreference && defaultDarkThemeVariant && darkThemeVariantMetadata) {
58
+ return {
59
+ name: defaultDarkThemeVariant,
60
+ metadata: darkThemeVariantMetadata,
61
+ };
62
+ }
63
+
64
+ const defaultLightThemeVariant = themeDefaults.light ?? 'light';
65
+ const lightThemeVariantMetadata = themeVariants[defaultLightThemeVariant];
66
+
67
+ // Handle edge case where the default light theme variant is not configured or provided.
68
+ if (!lightThemeVariantMetadata) {
69
+ return undefined;
70
+ }
71
+
72
+ // Otherwise, fallback to using the default light theme variant as configured.
73
+ return {
74
+ name: defaultLightThemeVariant,
75
+ metadata: lightThemeVariantMetadata,
76
+ };
77
+ };
78
+
79
+ /**
80
+ * A custom React hook that manages the application's theme state and injects the appropriate CSS for the theme core
81
+ * and theme variants (e.g., light and dark modes) into the HTML document. It handles dynamically loading the theme
82
+ * CSS based on the current theme variant, and ensures that the theme variant's CSS is preloaded for runtime theme
83
+ * switching. This is done using "alternate" stylesheets. That is, the browser will download the CSS for the
84
+ * non-current theme variants with a lower priority than the current one.
85
+ *
86
+ * The hook also responds to system theme preference changes (e.g., via the `prefers-color-scheme` media query),
87
+ * and can automatically switch the theme based on the system's dark mode or light mode preference.
88
+ *
89
+ * * @example
90
+ * const [themeState, dispatch] = useTheme();
91
+ * console.log(themeState.isThemeLoaded); // true when the theme has been successfully loaded.
92
+ *
93
+ * // Dispatch an action to change the theme variant
94
+ * dispatch(themeActions.setThemeVariant('dark'));
95
+ */
96
+ const useTheme = () => {
97
+ const themeConfig = useThemeConfig();
98
+ const {
99
+ core: themeCore,
100
+ defaults: themeVariantDefaults,
101
+ variants: themeVariants,
102
+ } = themeConfig;
103
+ const initialThemeState = {
104
+ isThemeLoaded: false,
105
+ themeVariant: getDefaultThemeVariant(themeVariants, themeVariantDefaults)?.name,
106
+ };
107
+ const [themeState, dispatch] = useReducer(themeReducer, initialThemeState);
108
+
109
+ const [isThemeCoreLoaded, setIsThemeCoreLoaded] = useState(false);
110
+ const onLoadThemeCore = useCallback(() => {
111
+ setIsThemeCoreLoaded(true);
112
+ }, []);
113
+
114
+ const [hasLoadedThemeVariants, setHasLoadedThemeVariants] = useState(false);
115
+ const onLoadThemeVariants = useCallback(() => {
116
+ setHasLoadedThemeVariants(true);
117
+ }, []);
118
+
119
+ // load the theme's core CSS
120
+ useThemeCore({
121
+ themeCore,
122
+ onComplete: onLoadThemeCore,
123
+ });
124
+
125
+ // respond to system preference changes with regard to `prefers-color-scheme: dark`.
126
+ const handleDarkModeSystemPreferenceChange = useCallback((prefersDarkMode: boolean) => {
127
+ // Ignore system preference change if the theme variant is already set in localStorage.
128
+ if (localStorage.getItem(SELECTED_THEME_VARIANT_KEY)) {
129
+ return;
130
+ }
131
+
132
+ if (prefersDarkMode && themeVariantDefaults?.dark) {
133
+ dispatch(themeActions.setThemeVariant(themeVariantDefaults.dark));
134
+ } else if (!prefersDarkMode && themeVariantDefaults?.light) {
135
+ dispatch(themeActions.setThemeVariant(themeVariantDefaults.light));
136
+ } else {
137
+ logError(`Could not set theme variant based on system preference (prefers dark mode: ${prefersDarkMode})`);
138
+ }
139
+ }, [themeVariantDefaults]);
140
+
141
+ // load the theme variant(s) CSS
142
+ useThemeVariants({
143
+ themeVariants,
144
+ onComplete: onLoadThemeVariants,
145
+ currentThemeVariant: themeState.themeVariant,
146
+ onDarkModeSystemPreferenceChange: handleDarkModeSystemPreferenceChange,
147
+ });
148
+
149
+ useEffect(() => {
150
+ // theme is already loaded, do nothing
151
+ if (themeState.isThemeLoaded) {
152
+ return;
153
+ }
154
+
155
+ const hasThemeConfig = (themeCore?.url && !isEmptyObject(themeVariants));
156
+ if (!hasThemeConfig) {
157
+ // no theme URLs to load, set loading to false.
158
+ dispatch(themeActions.setThemeLoaded(true));
159
+ }
160
+
161
+ // Return early if neither the core theme CSS nor any theme variant CSS is loaded.
162
+ if (!isThemeCoreLoaded || !hasLoadedThemeVariants) {
163
+ return;
164
+ }
165
+
166
+ // All application theme URLs are loaded
167
+ dispatch(themeActions.setThemeLoaded(true));
168
+ }, [
169
+ themeState.isThemeLoaded,
170
+ isThemeCoreLoaded,
171
+ hasLoadedThemeVariants,
172
+ themeCore?.url,
173
+ themeVariants,
174
+ ]);
175
+
176
+ return [themeState, dispatch];
177
+ };
178
+
179
+ export default useTheme;