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;
};