Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/services/theme/__snapshots__/provider.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -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`] = `
<span
class="euiThemeProvider emotion-euiCSSVariables-euiColorMode--colorClassName"
/>
`;

exports[`EuiThemeProvider nested EuiThemeProviders allows avoiding the extra span wrapper with \`wrapperProps.cloneElement\` 1`] = `
<div>
Top-level provider
Expand Down
2 changes: 2 additions & 0 deletions src/services/theme/context.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[attaching to a semi-random file for threading]

I'm debating making this a hook, e.g. useEuiCSSVariables(). Right now an EUI component will have to do this:

import React, { useContext } from 'react';
import { EuiNestedThemeContext } from '../services/theme';

const EuiComponent = () => {
  const { setGlobalCSSVariables } = useContext(EuiNestedThemeContext);
}

which feels like it could be cleaner/more elegant 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also debating adding a section about this in our Emotion wiki. I'm not totally sure what to write just yet and that may become clearer once I actually have the EuiHeader work in front of me, so I may hold off on that wiki doc until the last PR that actually merges in the feature branch into main.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a hook after all in 6f4f6b2, but will continue to hold off on a wiki document for a bit

Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ export const EuiNestedThemeContext = createContext<EuiThemeNested>({
hasDifferentColorFromGlobalTheme: false,
bodyColor: '',
colorClassName: '',
setGlobalCSSVariables: () => {},
setNearestThemeCSSVariables: () => {},
});
99 changes: 99 additions & 0 deletions src/services/theme/provider.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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, useContext, useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react';

import { EuiNestedThemeContext } from './context';
import { EuiThemeProvider, EuiThemeProviderProps } from './provider';

const meta: Meta<EuiThemeProviderProps<{}>> = {
title: 'EuiThemeProvider',
component: EuiThemeProvider,
};

export default meta;
type Story = StoryObj<EuiThemeProviderProps<{}>>;

export const WrapperCloneElement: Story = {
render: () => (
<>
<EuiThemeProvider wrapperProps={{ cloneElement: true }}>
<main className="clonedExample">
This example should only have 1 main wrapper rendered.
</main>
</EuiThemeProvider>
</>
),
};
Comment on lines +23 to +33

@cee-chen cee-chen Aug 28, 2023

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This story isn't related to this PR, I just thought I'd add it as a tech debt item while here (and also as a comparison to the existing stories)


export const CSSVariablesNearest: Story = {
render: () => (
<>
<MockComponent color="red">
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.
</MockComponent>
<EuiThemeProvider>
<MockComponent color="blue">
This component sets the nearest local theme provider with a blue CSS
variable color. Inspect the parent theme wrapper to see the variable
set.
</MockComponent>
</EuiThemeProvider>
</>
),
};

export const CSSVariablesGlobal: Story = {
render: () => (
<>
<MockComponent color="red">
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.
</MockComponent>
<EuiThemeProvider>
<MockComponent color="blue" global={true}>
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
</MockComponent>
</EuiThemeProvider>
</>
),
};

/**
* 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 } = useContext(
EuiNestedThemeContext
);

useEffect(() => {
if (global) {
setGlobalCSSVariables({ '--testColor': color });
} else {
setNearestThemeCSSVariables({ '--testColor': color });
}
}, [global, color, setGlobalCSSVariables, setNearestThemeCSSVariables]);

return (
<p style={{ color: 'var(--testColor)', marginBlockEnd: '1em' }}>
{children}
</p>
);
};
81 changes: 80 additions & 1 deletion src/services/theme/provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
Comment on lines +159 to +160

@cee-chen cee-chen Aug 28, 2023

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reference: https://github.com/jsdom/jsdom/releases/tag/20.0.0

We'll need to update Jest to latest to get a more recent version of jsdom (#6813)

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(
<EuiProvider>
<EuiThemeProvider>
<MockEuiComponent />
</EuiThemeProvider>
</EuiProvider>
);
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(
<EuiProvider>
<MockEuiComponent />
</EuiProvider>
);
expect(container.textContent).toContain('{"--hello":"world"}');
});

it('allows child components to set global CSS variables from any nested theme provider', () => {
const { container } = render(
<EuiProvider>
<EuiThemeProvider>
<MockEuiComponent global={true} />
</EuiThemeProvider>
</EuiProvider>
);
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(
<EuiProvider>
<MockEuiComponent />
<EuiThemeProvider>
<MockEuiComponent />
<MockEuiComponent global={true} />
</EuiThemeProvider>
</EuiProvider>
);
expect(getThemeClassName(container)).toContain('euiCSSVariables');
expect(getThemeProvider(container)).toHaveStyleRule('--hello', 'world');
expect(container.textContent).toContain('{"--hello":"global-world"}');
});
});
});
82 changes: 65 additions & 17 deletions src/services/theme/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,7 +66,12 @@ export const EuiThemeProvider = <T extends {} = {}>({
children,
wrapperProps,
}: EuiThemeProviderProps<T>) => {
const { isGlobalTheme, bodyColor } = useContext(EuiNestedThemeContext);
const {
isGlobalTheme,
bodyColor,
globalCSSVariables,
setGlobalCSSVariables,
} = useContext(EuiNestedThemeContext);
const parentSystem = useContext(EuiSystemContext);
const parentModifications = useContext(EuiModificationsContext);
const parentColorMode = useContext(EuiColorModeContext);
Expand Down Expand Up @@ -137,6 +145,13 @@ export const EuiThemeProvider = <T extends {} = {}>({
}
}, [colorMode, system, modifications]);

const [themeCSSVariables, _setThemeCSSVariables] = useState<CSSObject>();
const setThemeCSSVariables = useCallback(
(variables: CSSObject) =>
_setThemeCSSVariables((previous) => ({ ...previous, ...variables })),
[]
);

const nestedThemeContext = useMemo(() => {
return {
isGlobalTheme: false, // The theme that determines the global body styles
Expand All @@ -148,8 +163,25 @@ export const EuiThemeProvider = <T extends {} = {}>({
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) {
Expand All @@ -161,9 +193,14 @@ export const EuiThemeProvider = <T extends {} = {}>({
...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),
});
Expand All @@ -177,21 +214,32 @@ export const EuiThemeProvider = <T extends {} = {}>({
</span>
);
}
}, [isGlobalTheme, nestedThemeContext, wrapperProps, children]);
}, [
isGlobalTheme,
themeCSSVariables,
nestedThemeContext,
wrapperProps,
children,
]);

return (
<EuiColorModeContext.Provider value={colorMode}>
<EuiSystemContext.Provider value={system}>
<EuiModificationsContext.Provider value={modifications}>
<EuiThemeContext.Provider value={theme}>
<EuiNestedThemeContext.Provider value={nestedThemeContext}>
<EuiEmotionThemeProvider>
{renderedChildren}
</EuiEmotionThemeProvider>
</EuiNestedThemeContext.Provider>
</EuiThemeContext.Provider>
</EuiModificationsContext.Provider>
</EuiSystemContext.Provider>
</EuiColorModeContext.Provider>
<>
{isGlobalTheme && themeCSSVariables && (
<Global styles={{ ':root': themeCSSVariables }} />
)}
<EuiColorModeContext.Provider value={colorMode}>
<EuiSystemContext.Provider value={system}>
<EuiModificationsContext.Provider value={modifications}>
<EuiThemeContext.Provider value={theme}>
<EuiNestedThemeContext.Provider value={nestedThemeContext}>
<EuiEmotionThemeProvider>
{renderedChildren}
</EuiEmotionThemeProvider>
</EuiNestedThemeContext.Provider>
</EuiThemeContext.Provider>
</EuiModificationsContext.Provider>
</EuiSystemContext.Provider>
</EuiColorModeContext.Provider>
</>
);
};
6 changes: 6 additions & 0 deletions src/services/theme/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -99,4 +101,8 @@ export type EuiThemeNested = {
hasDifferentColorFromGlobalTheme: boolean;
bodyColor: string;
colorClassName: string;
setGlobalCSSVariables: Function;
globalCSSVariables?: CSSObject;
setNearestThemeCSSVariables: Function;
themeCSSVariables?: CSSObject;
};