diff --git a/src/internal/index.ts b/src/internal/index.ts index 1223f27..49d1ea9 100644 --- a/src/internal/index.ts +++ b/src/internal/index.ts @@ -14,6 +14,7 @@ export { useDensityMode, useReducedMotion, useRuntimeVisualRefresh, + initThemes, isThemeActive, Theme, } from './visual-mode/index.js'; diff --git a/src/internal/testing.ts b/src/internal/testing.ts index 12af494..6e67ee2 100644 --- a/src/internal/testing.ts +++ b/src/internal/testing.ts @@ -4,7 +4,7 @@ export { clearOneTimeMetricsCache } from './base-component/metrics/metrics.js'; export { clearMessageCache } from './logging.js'; export { setGlobalFlag } from './global-flags/index.js'; -export { clearVisualRefreshState } from './visual-mode/index.js'; +export { clearThemeState, clearVisualRefreshState } from './visual-mode/index.js'; export { TestSingleTabStopNavigationProvider, setTestSingleTabStopNavigationTarget, diff --git a/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.ssr.test.tsx b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.ssr.test.tsx index 4477048..8f049f6 100644 --- a/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.ssr.test.tsx +++ b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.ssr.test.tsx @@ -41,5 +41,5 @@ test('prints a warning when feature flag changes between renders', () => { renderToStaticMarkup(); globalWithFlags[awsuiVisualRefreshFlag] = () => false; renderToStaticMarkup(); - expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic visual refresh change detected/)); + expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic theme change detected/)); }); diff --git a/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx index e6aef54..3702430 100644 --- a/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx +++ b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx @@ -2,11 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { useRuntimeVisualRefresh, clearVisualRefreshState } from '../index'; +import { useRuntimeVisualRefresh, clearThemeState, initThemes } from '../index'; import { render, screen } from '@testing-library/react'; import { clearMessageCache } from '../../logging'; const awsuiVisualRefreshFlag = Symbol.for('awsui-visual-refresh-flag'); +const awsuiGlobalFlagsSymbol = Symbol.for('awsui-global-flags'); interface ExtendedWindow extends Window { [awsuiVisualRefreshFlag]?: () => boolean; } @@ -19,7 +20,7 @@ describe('useVisualRefresh', () => { } afterEach(() => { - clearVisualRefreshState(); + clearThemeState(); expect(document.querySelector('.awsui-visual-refresh')).toBeFalsy(); expect(document.querySelector('.awsui-one-theme')).toBeFalsy(); }); @@ -53,7 +54,7 @@ describe('useVisualRefresh', () => { document.body.classList.add('awsui-visual-refresh'); rerender(); - expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic visual refresh change detected/)); + expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic theme change detected/)); expect(screen.getByTestId('current-mode')).toHaveTextContent('false'); }); @@ -92,8 +93,6 @@ describe('useVisualRefresh', () => { }); describe('oneTheme global flag', () => { - const awsuiGlobalFlagsSymbol = Symbol.for('awsui-global-flags'); - afterEach(() => { delete (window as any)[awsuiGlobalFlagsSymbol]; }); @@ -103,5 +102,61 @@ describe('useVisualRefresh', () => { render(); expect(screen.getByTestId('current-mode')).toHaveTextContent('true'); }); + + test('should add the awsui-one-theme body class when oneTheme global flag is set', () => { + (window as any)[awsuiGlobalFlagsSymbol] = { oneTheme: true }; + render(); + expect(document.body).toHaveClass('awsui-one-theme'); + }); + + test('should not add the awsui-one-theme body class when oneTheme global flag is not set', () => { + window[awsuiVisualRefreshFlag] = () => true; + render(); + expect(document.body).toHaveClass('awsui-visual-refresh'); + expect(document.body).not.toHaveClass('awsui-one-theme'); + window[awsuiVisualRefreshFlag] = undefined; + }); + }); + + describe('initThemes', () => { + afterEach(() => { + delete (window as any)[awsuiGlobalFlagsSymbol]; + window[awsuiVisualRefreshFlag] = undefined; + }); + + test('adds the visual refresh body class when its flag is set', () => { + window[awsuiVisualRefreshFlag] = () => true; + initThemes(); + expect(document.body).toHaveClass('awsui-visual-refresh'); + expect(document.body).not.toHaveClass('awsui-one-theme'); + }); + + test('adds the one-theme body class when its flag is set', () => { + (window as any)[awsuiGlobalFlagsSymbol] = { oneTheme: true }; + initThemes(); + expect(document.body).toHaveClass('awsui-one-theme'); + }); + + test('adds no theme body class when no flag is set', () => { + initThemes(); + expect(document.body).not.toHaveClass('awsui-visual-refresh'); + expect(document.body).not.toHaveClass('awsui-one-theme'); + }); + + test('applies only the highest-priority theme when multiple flags are set', () => { + window[awsuiVisualRefreshFlag] = () => true; + (window as any)[awsuiGlobalFlagsSymbol] = { oneTheme: true }; + initThemes(); + expect(document.body).toHaveClass('awsui-one-theme'); + expect(document.body).not.toHaveClass('awsui-visual-refresh'); + }); + + test('warns when multiple theme flags are enabled', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + window[awsuiVisualRefreshFlag] = () => true; + (window as any)[awsuiGlobalFlagsSymbol] = { oneTheme: true }; + initThemes(); + expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Multiple theme flags are enabled/)); + }); }); }); diff --git a/src/internal/visual-mode/index.ts b/src/internal/visual-mode/index.ts index 745a85b..10cfa6f 100644 --- a/src/internal/visual-mode/index.ts +++ b/src/internal/visual-mode/index.ts @@ -92,63 +92,19 @@ function useMutationObserver(elementRef: React.RefObject, onChange: }, [handler]); } -// We expect VR is to be set only once and before the application is rendered. -let visualRefreshState: undefined | boolean = undefined; - -// for testing -export function clearVisualRefreshState() { - visualRefreshState = undefined; - if (typeof document !== 'undefined') { - document.body.classList.remove('awsui-visual-refresh'); - document.body.classList.remove('awsui-one-theme'); - } -} - -function detectVisualRefreshClassName() { - return typeof document !== 'undefined' && !!document.querySelector('.awsui-visual-refresh, .awsui-one-theme'); -} - -function detectVisualRefreshFlag() { - const global = getGlobal(); - return global?.[awsuiVisualRefreshFlag]?.() ?? !!getGlobalFlag('oneTheme'); -} - -export function useRuntimeVisualRefresh() { - if (visualRefreshState === undefined) { - visualRefreshState = detectVisualRefreshClassName(); - if (!visualRefreshState) { - if (detectVisualRefreshFlag()) { - visualRefreshState = true; - if (typeof document !== 'undefined') { - document.body.classList.add('awsui-visual-refresh'); - } - } - } - } - if (isDevelopment) { - const newVisualRefreshState = detectVisualRefreshClassName() || detectVisualRefreshFlag(); - if (newVisualRefreshState !== visualRefreshState) { - warnOnce( - 'Visual Refresh', - 'Dynamic visual refresh change detected. This is not supported. ' + - 'Make sure `awsui-visual-refresh` is attached to the `` element before initial React render' - ); - } - } - return visualRefreshState; -} - export enum Theme { VisualRefresh = 'visual-refresh', OneTheme = 'one-theme', } -interface ThemeConfig { +// When multiple theme flags are enabled, only the highest-priority (lower index) theme's body class is applied. +const themePrecedence: Array = [Theme.OneTheme, Theme.VisualRefresh]; +interface ThemeDefinition { className: string; isFlagActive: () => boolean; } -const THEMES: Record = { +const themeDefinitions: Record = { [Theme.VisualRefresh]: { className: 'awsui-visual-refresh', isFlagActive: () => !!getGlobal()?.[awsuiVisualRefreshFlag]?.(), @@ -159,10 +115,71 @@ const THEMES: Record = { }, }; +const allThemes = Object.values(Theme); + +function hasThemeClassName(theme: Theme) { + return typeof document !== 'undefined' && !!document.querySelector(`.${themeDefinitions[theme].className}`); +} + +// A theme is active if its body class or its global flag is set. export function isThemeActive(theme: Theme): boolean { - const config = THEMES[theme]; - if (typeof document !== 'undefined' && document.querySelector(`.${config.className}`)) { - return true; + return hasThemeClassName(theme) || themeDefinitions[theme].isFlagActive(); +} + +function applyThemeClassName(theme: Theme) { + if (typeof document !== 'undefined' && !hasThemeClassName(theme) && themeDefinitions[theme].isFlagActive()) { + document.body.classList.add(themeDefinitions[theme].className); + } +} + +export function initThemes() { + const flaggedThemes = themePrecedence.filter(theme => themeDefinitions[theme].isFlagActive()); + if (isDevelopment && flaggedThemes.length > 1) { + warnOnce( + 'Theme', + `Multiple theme flags are enabled (${flaggedThemes.join(', ')}). ` + + `Only the highest-priority theme (${flaggedThemes[0]}) is applied.` + ); + } + if (flaggedThemes.length > 0) { + applyThemeClassName(flaggedThemes[0]); + } +} + +let runtimeVisualRefresh: undefined | boolean = undefined; + +// Resets applied theme state: removes every theme's body class and clears the memoized state. +export function clearThemeState() { + runtimeVisualRefresh = undefined; + if (typeof document !== 'undefined') { + for (const theme of allThemes) { + document.body.classList.remove(themeDefinitions[theme].className); + } + } +} + +// @deprecated Use `clearThemeState` instead. +export function clearVisualRefreshState() { + clearThemeState(); +} + +export function useRuntimeVisualRefresh() { + if (runtimeVisualRefresh === undefined) { + initThemes(); + // One Theme needs to activate the same visual refresh behavior as the + // Visual Refresh theme, so both themes count as visual refresh here. + runtimeVisualRefresh = isThemeActive(Theme.VisualRefresh) || isThemeActive(Theme.OneTheme); + } + if (isDevelopment) { + const visualRefreshActive = isThemeActive(Theme.VisualRefresh) || isThemeActive(Theme.OneTheme); + if (visualRefreshActive !== runtimeVisualRefresh) { + warnOnce( + 'Visual Refresh', + 'Dynamic theme change detected. This is not supported. ' + + 'Make sure the theme class (e.g. `awsui-visual-refresh` or `awsui-one-theme`) is attached to ' + + 'the `` element before the initial React render.' + ); + } } - return config.isFlagActive(); + return runtimeVisualRefresh; }