diff --git a/packages/components/src/theme/ThemeModel.ts b/packages/components/src/theme/ThemeModel.ts index 588ef7d255..97c99d2bdf 100644 --- a/packages/components/src/theme/ThemeModel.ts +++ b/packages/components/src/theme/ThemeModel.ts @@ -43,6 +43,7 @@ export type ThemeIconsRequiringManualColorChanges = export const DEFAULT_DARK_THEME_KEY = 'default-dark' satisfies BaseThemeKey; export const DEFAULT_LIGHT_THEME_KEY = 'default-light' satisfies BaseThemeKey; +export const THEME_KEY_OVERRIDE_QUERY_PARAM = 'theme'; // Hex versions of some of the default dark theme color palette needed for // preload defaults. diff --git a/packages/components/src/theme/ThemeProvider.test.tsx b/packages/components/src/theme/ThemeProvider.test.tsx index 8fb489461a..fdc4438d9e 100644 --- a/packages/components/src/theme/ThemeProvider.test.tsx +++ b/packages/components/src/theme/ThemeProvider.test.tsx @@ -2,17 +2,12 @@ import React from 'react'; import { act, render } from '@testing-library/react'; import { assertNotNull, TestUtils } from '@deephaven/utils'; import { ThemeContextValue, ThemeProvider } from './ThemeProvider'; -import { - DEFAULT_DARK_THEME_KEY, - DEFAULT_LIGHT_THEME_KEY, - ThemeData, - ThemePreloadData, -} from './ThemeModel'; +import { DEFAULT_LIGHT_THEME_KEY, ThemeData } from './ThemeModel'; import { calculatePreloadStyleContent, getActiveThemes, getDefaultBaseThemes, - getThemePreloadData, + getDefaultSelectedThemeKey, setThemePreloadData, } from './ThemeUtils'; import { useTheme } from './useTheme'; @@ -24,13 +19,17 @@ jest.mock('./ThemeUtils', () => { return { ...actual, calculatePreloadStyleContent: jest.fn(), - getThemePreloadData: jest.fn(actual.getThemePreloadData), + getDefaultSelectedThemeKey: jest.fn(), + getThemeKeyOverride: jest.fn(), setThemePreloadData: jest.fn(), }; }); -const customThemes = [{ themeKey: 'themeA' }] as [ThemeData]; -const preloadA: ThemePreloadData = { themeKey: 'themeA' }; +const customThemes = [ + { themeKey: 'themeA' }, + { themeKey: 'mockDefaultSelectedThemeKey' }, +] as ThemeData[]; +const defaultSelectedThemeKey = 'mockDefaultSelectedThemeKey'; beforeEach(() => { jest.clearAllMocks(); @@ -41,7 +40,10 @@ beforeEach(() => { .mockName('calculatePreloadStyleContent') .mockReturnValue(':root{mock-preload-content}'); - asMock(getThemePreloadData).mockName('getThemePreloadData'); + asMock(getDefaultSelectedThemeKey) + .mockName('getDefaultSelectedThemeKey') + .mockReturnValue(defaultSelectedThemeKey); + asMock(setThemePreloadData).mockName('setThemePreloadData'); }); @@ -57,16 +59,9 @@ describe('ThemeProvider', () => { themeContextValueRef.current = null; }); - it.each([ - [null, null], - [null, preloadA], - [customThemes, null], - [customThemes, preloadA], - ] as const)( - 'should load themes based on preload data or default: %s, %s', - (themes, preloadData) => { - asMock(getThemePreloadData).mockReturnValue(preloadData); - + it.each([null, customThemes])( + 'should load themes based on default selected theme key. customThemes: %o', + themes => { const component = render( @@ -79,14 +74,14 @@ describe('ThemeProvider', () => { expect(themeContextValueRef.current.activeThemes).toBeNull(); } else { expect(themeContextValueRef.current.activeThemes).toEqual( - getActiveThemes(preloadData?.themeKey ?? DEFAULT_DARK_THEME_KEY, { + getActiveThemes(defaultSelectedThemeKey, { base: getDefaultBaseThemes(), custom: themes, }) ); expect(themeContextValueRef.current.selectedThemeKey).toEqual( - preloadData?.themeKey ?? DEFAULT_DARK_THEME_KEY + defaultSelectedThemeKey ); } @@ -94,16 +89,9 @@ describe('ThemeProvider', () => { } ); - it.each([ - [null, null], - [null, preloadA], - [customThemes, null], - [customThemes, preloadA], - ] as const)( - 'should set preload data when active themes change: %s, %s', - (themes, preloadData) => { - asMock(getThemePreloadData).mockReturnValue(preloadData); - + it.each([null, customThemes] as const)( + 'should set preload data when active themes change: %o', + themes => { render( @@ -114,14 +102,14 @@ describe('ThemeProvider', () => { expect(setThemePreloadData).not.toHaveBeenCalled(); } else { expect(setThemePreloadData).toHaveBeenCalledWith({ - themeKey: preloadData?.themeKey ?? DEFAULT_DARK_THEME_KEY, - preloadStyleContent: calculatePreloadStyleContent(), + themeKey: defaultSelectedThemeKey, + preloadStyleContent: calculatePreloadStyleContent({}), }); } } ); - describe.each([null, customThemes])('setSelectedThemeKey: %s', themes => { + describe.each([null, customThemes])('setSelectedThemeKey: %o', themes => { it.each([DEFAULT_LIGHT_THEME_KEY, customThemes[0].themeKey])( 'should change selected theme: %s', themeKey => { diff --git a/packages/components/src/theme/ThemeProvider.tsx b/packages/components/src/theme/ThemeProvider.tsx index 19ad76a7d1..2f687fb78d 100644 --- a/packages/components/src/theme/ThemeProvider.tsx +++ b/packages/components/src/theme/ThemeProvider.tsx @@ -1,17 +1,13 @@ import { createContext, ReactNode, useEffect, useMemo, useState } from 'react'; import Log from '@deephaven/log'; -import { - DEFAULT_DARK_THEME_KEY, - DEFAULT_PRELOAD_DATA_VARIABLES, - ThemeData, -} from './ThemeModel'; +import { DEFAULT_PRELOAD_DATA_VARIABLES, ThemeData } from './ThemeModel'; import { calculatePreloadStyleContent, getActiveThemes, getDefaultBaseThemes, - getThemePreloadData, setThemePreloadData, overrideSVGFillColors, + getDefaultSelectedThemeKey, } from './ThemeUtils'; import { SpectrumThemeProvider } from './SpectrumThemeProvider'; import './theme-svg.scss'; @@ -48,7 +44,7 @@ export function ThemeProvider({ const [value, setValue] = useState(null); const [selectedThemeKey, setSelectedThemeKey] = useState( - () => getThemePreloadData()?.themeKey ?? DEFAULT_DARK_THEME_KEY + getDefaultSelectedThemeKey ); // Calculate active themes once a non-null themes array is provided. diff --git a/packages/components/src/theme/ThemeUtils.test.ts b/packages/components/src/theme/ThemeUtils.test.ts index 3f88908032..42b17c2dc3 100644 --- a/packages/components/src/theme/ThemeUtils.test.ts +++ b/packages/components/src/theme/ThemeUtils.test.ts @@ -10,6 +10,7 @@ import { ThemePreloadColorVariable, ThemeRegistrationData, THEME_CACHE_LOCAL_STORAGE_KEY, + THEME_KEY_OVERRIDE_QUERY_PARAM, } from './ThemeModel'; import { calculatePreloadStyleContent, @@ -17,6 +18,7 @@ import { extractDistinctCssVariableExpressions, getActiveThemes, getDefaultBaseThemes, + getDefaultSelectedThemeKey, getExpressionRanges, getThemeKey, getThemePreloadData, @@ -239,6 +241,44 @@ describe('getActiveThemes', () => { }); }); +describe('getDefaultSelectedThemeKey', () => { + const origLocation = window.location; + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete window.location; + window.location = { + search: '', + } as unknown as Location; + }); + + afterEach(() => { + window.location = origLocation; + }); + + it.each([ + ['overrideKey', 'preloadKey', 'overrideKey'], + [undefined, 'preloadKey', 'preloadKey'], + [undefined, undefined, DEFAULT_DARK_THEME_KEY], + ])( + 'should coalesce overide key -> preload key -> default key: %s, %s, %s', + (overrideKey, preloadKey, expected) => { + if (overrideKey != null) { + window.location.search = `?${THEME_KEY_OVERRIDE_QUERY_PARAM}=${overrideKey}`; + } + + localStorage.setItem( + THEME_CACHE_LOCAL_STORAGE_KEY, + JSON.stringify({ themeKey: preloadKey }) + ); + + const actual = getDefaultSelectedThemeKey(); + expect(actual).toEqual(expected); + } + ); +}); + describe('getExpressionRanges', () => { const testCases = [ ['Single expression', '#ffffff', [[0, 6]]], diff --git a/packages/components/src/theme/ThemeUtils.ts b/packages/components/src/theme/ThemeUtils.ts index 22cd1ba35c..659fbf05eb 100644 --- a/packages/components/src/theme/ThemeUtils.ts +++ b/packages/components/src/theme/ThemeUtils.ts @@ -15,6 +15,7 @@ import { SVG_ICON_MANUAL_COLOR_MAP, ThemeCssVariableName, ThemeIconsRequiringManualColorChanges, + THEME_KEY_OVERRIDE_QUERY_PARAM, } from './ThemeModel'; const log = Log.module('ThemeUtils'); @@ -178,6 +179,31 @@ export function getDefaultBaseThemes(): ThemeData[] { ]; } +/** + * Get the default selected theme key. Precedence is: + * 1. Theme key override query parameter + * 2. Theme key from preload data + * 3. Default dark theme key + * @returns The default selected theme key + */ +export function getDefaultSelectedThemeKey(): string { + return ( + getThemeKeyOverride() ?? + getThemePreloadData()?.themeKey ?? + DEFAULT_DARK_THEME_KEY + ); +} + +/** + * A theme key override can be set via a query parameter to force a specific + * theme selection. Useful for embedded widget scenarios that don't expose the + * theme selector. + */ +export function getThemeKeyOverride(): string | null { + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get(THEME_KEY_OVERRIDE_QUERY_PARAM); +} + /** * Get the preload data from local storage or null if it does not exist or is * invalid diff --git a/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap b/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap index 080e6d0faa..24d29adc27 100644 --- a/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap +++ b/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap @@ -1,6 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ThemeProvider setSelectedThemeKey: [ [Object] ] should change selected theme: default-light 1`] = ` +exports[`ThemeProvider setSelectedThemeKey: [ + { themeKey: 'themeA' }, + { themeKey: 'mockDefaultSelectedThemeKey' }, + [length]: 2 +] should change selected theme: default-light 1`] = `
-
-
- Child -
-
-
- -`; - -exports[`ThemeProvider should load themes based on preload data or default: null, { themeKey: 'themeA' } 1`] = ` - -
-
-
- Child -
-
-
- -`; - -exports[`ThemeProvider should load themes based on preload data or default: null, null 1`] = ` +exports[`ThemeProvider should load themes based on default selected theme key. customThemes: null 1`] = `