diff --git a/src/services/theme/__snapshots__/provider.test.tsx.snap b/src/services/theme/__snapshots__/provider.test.tsx.snap index 720b4738012..47336d01d76 100644 --- a/src/services/theme/__snapshots__/provider.test.tsx.snap +++ b/src/services/theme/__snapshots__/provider.test.tsx.snap @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`EuiThemeProvider CSS variables allows child components to set non-global theme CSS variables 1`] = ` + +`; + exports[`EuiThemeProvider nested EuiThemeProviders allows avoiding the extra span wrapper with \`wrapperProps.cloneElement\` 1`] = `
Top-level provider diff --git a/src/services/theme/context.ts b/src/services/theme/context.ts index fe50cda7ea1..d366a2ffba8 100644 --- a/src/services/theme/context.ts +++ b/src/services/theme/context.ts @@ -34,4 +34,6 @@ export const EuiNestedThemeContext = createContext({ hasDifferentColorFromGlobalTheme: false, bodyColor: '', colorClassName: '', + setGlobalCSSVariables: () => {}, + setNearestThemeCSSVariables: () => {}, }); diff --git a/src/services/theme/hooks.test.tsx b/src/services/theme/hooks.test.tsx index a931e9c5280..d3c5812ebd0 100644 --- a/src/services/theme/hooks.test.tsx +++ b/src/services/theme/hooks.test.tsx @@ -7,15 +7,18 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, act } from '@testing-library/react'; import { renderHook } from '../../test/rtl'; +import { EuiProvider } from '../../components'; + import { setEuiDevProviderWarning } from './warning'; import { useEuiTheme, UseEuiTheme, withEuiTheme, RenderWithEuiTheme, + useEuiThemeCSSVariables, } from './hooks'; describe('useEuiTheme', () => { @@ -82,3 +85,21 @@ describe('RenderWithEuiTheme', () => { ); }); }); + +describe('useEuiThemeCSSVariables', () => { + it('returns CSS variable related state setters/getters', () => { + const { result } = renderHook(useEuiThemeCSSVariables, { + wrapper: EuiProvider, + }); + expect(result.current.globalCSSVariables).toBeUndefined(); + expect(result.current.themeCSSVariables).toBeUndefined(); + + act(() => { + result.current.setNearestThemeCSSVariables({ '--hello': 'world' }); + }); + + // In this case, the nearest theme is the global one, so it should set both + expect(result.current.globalCSSVariables).toEqual({ '--hello': 'world' }); + expect(result.current.themeCSSVariables).toEqual({ '--hello': 'world' }); + }); +}); diff --git a/src/services/theme/hooks.tsx b/src/services/theme/hooks.tsx index 132d3a35f4f..bf87146612e 100644 --- a/src/services/theme/hooks.tsx +++ b/src/services/theme/hooks.tsx @@ -13,6 +13,7 @@ import { EuiModificationsContext, EuiColorModeContext, defaultComputedTheme, + EuiNestedThemeContext, } from './context'; import { emitEuiProviderWarning } from './warning'; import { @@ -95,3 +96,23 @@ export const RenderWithEuiTheme = ({ const theme = useEuiTheme(); return children(theme); }; + +/** + * Minor syntactical sugar hook for theme CSS variables. + * Primarily meant for internal EUI usage. + */ +export const useEuiThemeCSSVariables = () => { + const { + setGlobalCSSVariables, + globalCSSVariables, + setNearestThemeCSSVariables, + themeCSSVariables, + } = useContext(EuiNestedThemeContext); + + return { + setGlobalCSSVariables, + globalCSSVariables, + setNearestThemeCSSVariables, + themeCSSVariables, + }; +}; diff --git a/src/services/theme/index.ts b/src/services/theme/index.ts index 7c46c4f81ff..d290b7e0a46 100644 --- a/src/services/theme/index.ts +++ b/src/services/theme/index.ts @@ -14,7 +14,12 @@ export { EuiColorModeContext, } from './context'; export type { UseEuiTheme, WithEuiThemeProps } from './hooks'; -export { useEuiTheme, withEuiTheme, RenderWithEuiTheme } from './hooks'; +export { + useEuiTheme, + withEuiTheme, + RenderWithEuiTheme, + useEuiThemeCSSVariables, +} from './hooks'; export type { EuiThemeProviderProps } from './provider'; export { EuiThemeProvider } from './provider'; export { getEuiDevProviderWarning, setEuiDevProviderWarning } from './warning'; diff --git a/src/services/theme/provider.stories.tsx b/src/services/theme/provider.stories.tsx new file mode 100644 index 00000000000..2b2fbe33d7b --- /dev/null +++ b/src/services/theme/provider.stories.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FunctionComponent, useEffect } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { useEuiThemeCSSVariables } from './hooks'; +import { EuiThemeProvider, EuiThemeProviderProps } from './provider'; + +const meta: Meta> = { + title: 'EuiThemeProvider', + component: EuiThemeProvider, +}; + +export default meta; +type Story = StoryObj>; + +export const WrapperCloneElement: Story = { + render: () => ( + <> + +
+ This example should only have 1 main wrapper rendered. +
+
+ + ), +}; + +export const CSSVariablesNearest: Story = { + render: () => ( + <> + + This component sets the nearest theme provider (the global theme) with a + red CSS variable color. Inspect the `:root` styles to see the variable + set. + + + + This component sets the nearest local theme provider with a blue CSS + variable color. Inspect the parent theme wrapper to see the variable + set. + + + + ), +}; + +export const CSSVariablesGlobal: Story = { + render: () => ( + <> + + This component sets the nearest theme provider (the global theme) with a + red CSS variable color. However, it should be overridden by the next + component. + + + + This component sets the global theme with a blue CSS variable color. + It should override the previous component. Inspect the `:root` styles + to see this behavior + + + + ), +}; + +/** + * Component for QA/testing purposes that mocks an EUI component + * that sets global or theme-level CSS variables + */ +const MockComponent: FunctionComponent<{ + global?: boolean; + color: string; + children: any; +}> = ({ global, color, children }) => { + const { setGlobalCSSVariables, setNearestThemeCSSVariables } = + useEuiThemeCSSVariables(); + + useEffect(() => { + if (global) { + setGlobalCSSVariables({ '--testColor': color }); + } else { + setNearestThemeCSSVariables({ '--testColor': color }); + } + }, [global, color, setGlobalCSSVariables, setNearestThemeCSSVariables]); + + return ( +

+ {children} +

+ ); +}; diff --git a/src/services/theme/provider.test.tsx b/src/services/theme/provider.test.tsx index 519cd645f87..3b0368723c5 100644 --- a/src/services/theme/provider.test.tsx +++ b/src/services/theme/provider.test.tsx @@ -6,11 +6,12 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { FunctionComponent, useContext, useEffect } from 'react'; import { render } from '@testing-library/react'; // Note - don't use the EUI custom RTL `render`, as it auto-wraps an `EuiProvider` import { css } from '@emotion/react'; import { EuiProvider } from '../../components/provider'; +import { EuiNestedThemeContext } from './context'; import { EuiThemeProvider } from './provider'; describe('EuiThemeProvider', () => { @@ -136,4 +137,82 @@ describe('EuiThemeProvider', () => { expect(container.querySelector('.hello.world')).toBeTruthy(); }); }); + + describe('CSS variables', () => { + const MockEuiComponent: FunctionComponent<{ global?: boolean }> = ({ + global, + }) => { + const { + globalCSSVariables, + setGlobalCSSVariables, + setNearestThemeCSSVariables, + } = useContext(EuiNestedThemeContext); + + useEffect(() => { + if (global) { + setGlobalCSSVariables({ '--hello': 'global-world' }); + } else { + setNearestThemeCSSVariables({ '--hello': 'world' }); + } + }, [global, setGlobalCSSVariables, setNearestThemeCSSVariables]); + + // Our current version of jsdom doesn't yet support :root (currently on v11, + // need to be on at least v20), so we'll mock something to assert on in the interim + return <>{JSON.stringify(globalCSSVariables)}; + }; + + const getThemeProvider = (container: HTMLElement) => + container.querySelector('.euiThemeProvider')!; + const getThemeClassName = (container: HTMLElement) => + getThemeProvider(container).className; + + it('allows child components to set non-global theme CSS variables', () => { + const { container } = render( + + + + + + ); + expect(getThemeClassName(container)).toContain('euiCSSVariables'); + expect(container.firstChild).toHaveStyleRule('--hello', 'world'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('sets global CSS variables when the nearest theme provider is the top-level one', () => { + const { container } = render( + + + + ); + expect(container.textContent).toContain('{"--hello":"world"}'); + }); + + it('allows child components to set global CSS variables from any nested theme provider', () => { + const { container } = render( + + + + + + ); + expect(getThemeClassName(container)).not.toContain('euiCSSVariables'); + expect(container.textContent).toContain('{"--hello":"global-world"}'); + }); + + it('can set both global and nearest theme variables without conflicting', () => { + const { container } = render( + + + + + + + + ); + expect(getThemeClassName(container)).toContain('euiCSSVariables'); + expect(getThemeProvider(container)).toHaveStyleRule('--hello', 'world'); + expect(container.textContent).toContain('{"--hello":"global-world"}'); + }); + }); }); diff --git a/src/services/theme/provider.tsx b/src/services/theme/provider.tsx index 13003b54d2e..0005f356d9f 100644 --- a/src/services/theme/provider.tsx +++ b/src/services/theme/provider.tsx @@ -12,14 +12,17 @@ import React, { useRef, useMemo, useState, + useCallback, PropsWithChildren, HTMLAttributes, } from 'react'; import classNames from 'classnames'; import { css } from '@emotion/css'; +import { Global, type CSSObject } from '@emotion/react'; import isEqual from 'lodash/isEqual'; import type { CommonProps } from '../../components/common'; +import { cloneElementWithCss } from '../emotion'; import { EuiSystemContext, @@ -63,7 +66,12 @@ export const EuiThemeProvider = ({ children, wrapperProps, }: EuiThemeProviderProps) => { - const { isGlobalTheme, bodyColor } = useContext(EuiNestedThemeContext); + const { + isGlobalTheme, + bodyColor, + globalCSSVariables, + setGlobalCSSVariables, + } = useContext(EuiNestedThemeContext); const parentSystem = useContext(EuiSystemContext); const parentModifications = useContext(EuiModificationsContext); const parentColorMode = useContext(EuiColorModeContext); @@ -137,6 +145,13 @@ export const EuiThemeProvider = ({ } }, [colorMode, system, modifications]); + const [themeCSSVariables, _setThemeCSSVariables] = useState(); + const setThemeCSSVariables = useCallback( + (variables: CSSObject) => + _setThemeCSSVariables((previous) => ({ ...previous, ...variables })), + [] + ); + const nestedThemeContext = useMemo(() => { return { isGlobalTheme: false, // The theme that determines the global body styles @@ -148,8 +163,25 @@ export const EuiThemeProvider = ({ label: euiColorMode-${_colorMode}; color: ${theme.colors.text}; `, + setGlobalCSSVariables: isGlobalTheme + ? setThemeCSSVariables + : setGlobalCSSVariables, + globalCSSVariables: isGlobalTheme + ? themeCSSVariables + : globalCSSVariables, + setNearestThemeCSSVariables: setThemeCSSVariables, + themeCSSVariables: themeCSSVariables, }; - }, [theme, isGlobalTheme, bodyColor, _colorMode]); + }, [ + theme, + isGlobalTheme, + bodyColor, + _colorMode, + setGlobalCSSVariables, + globalCSSVariables, + setThemeCSSVariables, + themeCSSVariables, + ]); const renderedChildren = useMemo(() => { if (isGlobalTheme) { @@ -161,9 +193,14 @@ export const EuiThemeProvider = ({ ...rest, className: classNames(className, nestedThemeContext.colorClassName), }; + // Condition avoids rendering an empty Emotion selector if no + // theme-specific CSS variables have been set by child components + if (themeCSSVariables) { + props.css = { label: 'euiCSSVariables', ...themeCSSVariables }; + } if (cloneElement) { - return React.cloneElement(children, { + return cloneElementWithCss(children, { ...props, className: classNames(children.props.className, props.className), }); @@ -177,21 +214,32 @@ export const EuiThemeProvider = ({ ); } - }, [isGlobalTheme, nestedThemeContext, wrapperProps, children]); + }, [ + isGlobalTheme, + themeCSSVariables, + nestedThemeContext, + wrapperProps, + children, + ]); return ( - - - - - - - {renderedChildren} - - - - - - + <> + {isGlobalTheme && themeCSSVariables && ( + + )} + + + + + + + {renderedChildren} + + + + + + + ); }; diff --git a/src/services/theme/types.ts b/src/services/theme/types.ts index b719e135889..04b93b40f20 100644 --- a/src/services/theme/types.ts +++ b/src/services/theme/types.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { CSSObject } from '@emotion/react'; + import { RecursivePartial, ValueOf } from '../../components/common'; import { _EuiThemeAnimation } from '../../global_styling/variables/animations'; import { _EuiThemeBreakpoints } from '../../global_styling/variables/breakpoint'; @@ -99,4 +101,8 @@ export type EuiThemeNested = { hasDifferentColorFromGlobalTheme: boolean; bodyColor: string; colorClassName: string; + setGlobalCSSVariables: Function; + globalCSSVariables?: CSSObject; + setNearestThemeCSSVariables: Function; + themeCSSVariables?: CSSObject; };