diff --git a/packages/eui/.storybook/decorator.tsx b/packages/eui/.storybook/decorator.tsx index 10e265a13de..6b78a2e2353 100644 --- a/packages/eui/.storybook/decorator.tsx +++ b/packages/eui/.storybook/decorator.tsx @@ -103,7 +103,9 @@ const storybookToolbarWritingModes: Array< export const euiProviderDecoratorGlobals: Preview['globalTypes'] = { colorMode: { description: 'Color mode for EuiProvider theme', - defaultValue: 'light', + defaultValue: window?.matchMedia?.('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light', toolbar: { title: 'Color mode', items: storybookToolbarColorModes, diff --git a/packages/eui/changelogs/upcoming/8026.md b/packages/eui/changelogs/upcoming/8026.md new file mode 100644 index 00000000000..78c9c0712c0 --- /dev/null +++ b/packages/eui/changelogs/upcoming/8026.md @@ -0,0 +1 @@ +- Updated `EuiProvider` to inherit from the user's OS/system light/dark mode setting if a `colorMode` prop has not been passed diff --git a/packages/eui/src-docs/src/components/guide_theme_selector/guide_theme_selector.tsx b/packages/eui/src-docs/src/components/guide_theme_selector/guide_theme_selector.tsx index 23af6f090fb..bc9d4449b9d 100644 --- a/packages/eui/src-docs/src/components/guide_theme_selector/guide_theme_selector.tsx +++ b/packages/eui/src-docs/src/components/guide_theme_selector/guide_theme_selector.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { EuiThemeProvider, + useEuiTheme, useIsWithinBreakpoints, } from '../../../../src/services'; import { EUI_THEME, EUI_THEMES } from '../../../../src/themes'; @@ -34,8 +35,6 @@ export const GuideThemeSelector: React.FunctionComponent< ); }; -const STORAGE_KEY = 'legacy_theme_notification'; - const GuideThemeSelectorComponent: React.FunctionComponent< GuideThemeSelectorProps > = ({ context, onToggleLocale, selectedLocale }) => { @@ -44,15 +43,17 @@ const GuideThemeSelectorComponent: React.FunctionComponent< const onButtonClick = () => { setPopover(!isPopoverOpen); - localStorage.setItem(STORAGE_KEY, 'dismissed'); }; const closePopover = () => { setPopover(false); }; + const systemColorMode = useEuiTheme().colorMode.toLowerCase(); const currentTheme: EUI_THEME = - EUI_THEMES.find((theme) => theme.value === context.theme) || EUI_THEMES[0]; + EUI_THEMES.find( + (theme) => theme.value === (context.theme ?? systemColorMode) + ) || EUI_THEMES[0]; const getIconType = (value: EUI_THEME['value']) => { return value === currentTheme.value ? 'check' : 'empty'; diff --git a/packages/eui/src-docs/src/components/with_theme/theme_context.tsx b/packages/eui/src-docs/src/components/with_theme/theme_context.tsx index fc16b9a5358..f0fc4688557 100644 --- a/packages/eui/src-docs/src/components/with_theme/theme_context.tsx +++ b/packages/eui/src-docs/src/components/with_theme/theme_context.tsx @@ -28,30 +28,27 @@ export const theme_languages: THEME_LANGUAGES[] = [ const THEME_NAMES = EUI_THEMES.map(({ value }) => value); const THEME_LANGS = theme_languages.map(({ id }) => id); -const defaultState = { - themeLanguage: THEME_LANGS[0], - // eslint-disable-next-line @typescript-eslint/no-unused-vars - changeThemeLanguage: (language: THEME_LANGUAGES['id']) => {}, - theme: THEME_NAMES[0], - changeTheme: (themeValue: EUI_THEME['value']) => { - applyTheme(themeValue); - }, -}; - -interface State { - theme: EUI_THEME['value']; +type ThemeContextType = { + theme?: EUI_THEME['value']; + changeTheme: (themeValue: EUI_THEME['value']) => void; themeLanguage: THEME_LANGUAGES['id']; -} + changeThemeLanguage: (language: THEME_LANGUAGES['id']) => void; +}; +export const ThemeContext = React.createContext({ + theme: undefined, + changeTheme: () => {}, + themeLanguage: THEME_LANGS[0], + changeThemeLanguage: () => {}, +}); -export const ThemeContext = React.createContext(defaultState); +type State = Pick; export class ThemeProvider extends React.Component { constructor(props: object) { super(props); - let theme = localStorage.getItem('theme'); - if (!theme || !THEME_NAMES.includes(theme)) theme = defaultState.theme; - applyTheme(theme); + const theme = localStorage.getItem('theme') || undefined; + applyTheme(theme && THEME_NAMES.includes(theme) ? theme : THEME_NAMES[0]); const themeLanguage = this.getThemeLanguage(); @@ -83,7 +80,7 @@ export class ThemeProvider extends React.Component { // If not set by either param or storage, or an invalid value, use the default if (!themeLanguage || !THEME_LANGS.includes(themeLanguage)) - themeLanguage = defaultState.themeLanguage; + themeLanguage = THEME_LANGS[0]; return themeLanguage; }; diff --git a/packages/eui/src-docs/src/index.js b/packages/eui/src-docs/src/index.js index b9254d7da37..a69a07e3613 100644 --- a/packages/eui/src-docs/src/index.js +++ b/packages/eui/src-docs/src/index.js @@ -97,15 +97,10 @@ root.render( isDeprecated, }} > - {({ theme }) => ( - <> - {meta} - {createElement(component, { - selectedTheme: theme, - title: name, - })} - - )} + {meta} + {createElement(component, { + title: name, + })} ); } diff --git a/packages/eui/src-docs/src/views/app_context.js b/packages/eui/src-docs/src/views/app_context.js index 606ce277bb1..729f4c94079 100644 --- a/packages/eui/src-docs/src/views/app_context.js +++ b/packages/eui/src-docs/src/views/app_context.js @@ -56,7 +56,9 @@ export const AppContext = ({ children }) => { utility: utilityCache, }} theme={EUI_THEMES.find((t) => t.value === theme)?.provider} - colorMode={theme.includes('light') ? 'light' : 'dark'} + colorMode={ + theme ? (theme.includes('light') ? 'light' : 'dark') : undefined + } > { const toggleLocale = (locale) => dispatch(_toggleLocale(locale)); const locale = useSelector((state) => getLocale(state)); const routes = useSelector((state) => getRoutes(state)); - const { theme } = useContext(ThemeContext); const portalledHeadingAnchorLinks = useHeadingAnchorLinks(); @@ -59,7 +58,7 @@ export const AppView = ({ children, currentRoute = {} }) => { /> - {children({ theme })} + {children} ); diff --git a/packages/eui/src-docs/src/views/avatar/avatar_icon.js b/packages/eui/src-docs/src/views/avatar/avatar_icon.js index b1159470976..6d122e6d24f 100644 --- a/packages/eui/src-docs/src/views/avatar/avatar_icon.js +++ b/packages/eui/src-docs/src/views/avatar/avatar_icon.js @@ -1,15 +1,8 @@ -import React, { useContext } from 'react'; -import { ThemeContext } from '../../components'; - -import { EuiAvatar, EuiSpacer, EuiTitle } from '../../../../src/components'; +import React from 'react'; +import { EuiAvatar, EuiSpacer, EuiTitle, useEuiTheme } from '../../../../src'; export default () => { - const themeContext = useContext(ThemeContext); - - /** - * Setup theme based on current light/dark theme - */ - const isDarkTheme = themeContext.theme.includes('dark'); + const isDarkTheme = useEuiTheme().colorMode === 'DARK'; return (
diff --git a/packages/eui/src-docs/src/views/empty_prompt/_types_of_empty_states.tsx b/packages/eui/src-docs/src/views/empty_prompt/_types_of_empty_states.tsx index d08e56ecd71..475b9657fa6 100644 --- a/packages/eui/src-docs/src/views/empty_prompt/_types_of_empty_states.tsx +++ b/packages/eui/src-docs/src/views/empty_prompt/_types_of_empty_states.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useState, useEffect } from 'react'; import reactElementToJSXString from 'react-element-to-jsx-string'; import classNames from 'classnames'; import { @@ -20,8 +20,8 @@ import { EuiDescriptionListDescription, EuiLoadingSpinner, useIsWithinBreakpoints, + useEuiTheme, } from '../../../../src'; -import { ThemeContext } from '../../components/with_theme'; import { typesOfPanelColors } from './_types_of_panel_colors'; // @ts-ignore Importing from JS file import { typesOfUseCases } from './_types_of_use_cases'; @@ -43,12 +43,7 @@ import singleSvg from '../../images/single.svg'; import contentCenterSvg from '../../images/content_center.svg'; export default () => { - const themeContext = useContext(ThemeContext); - - /** - * Setup theme based on current light/dark theme - */ - const isDarkTheme = themeContext.theme.includes('dark'); + const isDarkTheme = useEuiTheme().colorMode === 'DARK'; const useCasesOptions: EuiRadioGroupOption[] = Object.values( typesOfUseCases diff --git a/packages/eui/src-docs/src/views/empty_prompt/prompt_types/no_permission.tsx b/packages/eui/src-docs/src/views/empty_prompt/prompt_types/no_permission.tsx index af4b1d8bf50..bebc4753915 100644 --- a/packages/eui/src-docs/src/views/empty_prompt/prompt_types/no_permission.tsx +++ b/packages/eui/src-docs/src/views/empty_prompt/prompt_types/no_permission.tsx @@ -1,17 +1,16 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { EuiEmptyPrompt, EuiImage, EuiButton, EuiButtonEmpty, -} from '../../../../../src/components'; -import { ThemeContext } from '../../../components/with_theme'; + useEuiTheme, +} from '../../../../../src'; import pageNotFoundLight from '../../../images/empty-prompt/accessDenied--light.png'; import pageNotFoundDark from '../../../images/empty-prompt/accessDenied--dark.png'; export default () => { - const themeContext = useContext(ThemeContext); - const isDarkTheme = themeContext.theme.includes('dark'); + const isDarkTheme = useEuiTheme().colorMode === 'DARK'; const iconImg: string = isDarkTheme ? pageNotFoundDark : pageNotFoundLight; diff --git a/packages/eui/src-docs/src/views/empty_prompt/prompt_types/page_not_found.tsx b/packages/eui/src-docs/src/views/empty_prompt/prompt_types/page_not_found.tsx index 3ec185d806b..337d27ca3af 100644 --- a/packages/eui/src-docs/src/views/empty_prompt/prompt_types/page_not_found.tsx +++ b/packages/eui/src-docs/src/views/empty_prompt/prompt_types/page_not_found.tsx @@ -1,11 +1,11 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { EuiEmptyPrompt, EuiImage, EuiButton, EuiButtonEmpty, -} from '../../../../../src/components'; -import { ThemeContext } from '../../../components/with_theme'; + useEuiTheme, +} from '../../../../../src'; import pageNotFoundDark from '../../../images/empty-prompt/pageNotFound--dark.png'; import pageNotFoundLight from '../../../images/empty-prompt/pageNotFound--light.png'; @@ -13,8 +13,7 @@ import pageNotFoundDark2x from '../../../images/empty-prompt/pageNotFound--dark@ import pageNotFoundLight2x from '../../../images/empty-prompt/pageNotFound--light@2x.png'; export default () => { - const themeContext = useContext(ThemeContext); - const isDarkTheme = themeContext.theme.includes('dark'); + const isDarkTheme = useEuiTheme().colorMode === 'DARK'; const pageNotFound = isDarkTheme ? pageNotFoundDark : pageNotFoundLight; const pageNotFound2x = isDarkTheme ? pageNotFoundDark2x : pageNotFoundLight2x; diff --git a/packages/eui/src-docs/src/views/home/home_illustration.tsx b/packages/eui/src-docs/src/views/home/home_illustration.tsx index 8166ac42561..6f40d7eaf2b 100644 --- a/packages/eui/src-docs/src/views/home/home_illustration.tsx +++ b/packages/eui/src-docs/src/views/home/home_illustration.tsx @@ -1,17 +1,17 @@ -import React, { useContext } from 'react'; -import { ThemeContext } from '../../components/with_theme'; +import React from 'react'; import illustrationDarkMode from '../../images/illustration-eui-hero-500-darkmode-shadow.svg'; import illustrationLightMode from '../../images/illustration-eui-hero-500-shadow.svg'; -import { EuiImage } from '../../../../src/components/image'; +import { EuiImage, useEuiTheme } from '../../../../src'; function Icon() { - const themeContext: any = useContext(ThemeContext); + const { colorMode } = useEuiTheme(); - const illustration = themeContext.theme.includes('dark') ? ( - - ) : ( - - ); + const illustration = + colorMode === 'DARK' ? ( + + ) : ( + + ); return (
diff --git a/packages/eui/src-docs/src/views/text/text_example.js b/packages/eui/src-docs/src/views/text/text_example.js index 48070e13dd7..bde0b3b941d 100644 --- a/packages/eui/src-docs/src/views/text/text_example.js +++ b/packages/eui/src-docs/src/views/text/text_example.js @@ -1,5 +1,4 @@ -import React, { useContext } from 'react'; -import { ThemeContext } from '../../components/with_theme'; +import React from 'react'; import { GuideSectionTypes } from '../../components'; @@ -45,22 +44,6 @@ const textAlignSnippet = [ `, ]; -const LineHeightText = () => { - const themeContext = useContext(ThemeContext); - let text; - switch (themeContext.theme) { - default: - text = ( - <> - The goal is that the every line-height lands on the{' '} - 4px baseline grid. - - ); - } - - return text; -}; - export const TextExample = { title: 'Text', sections: [ @@ -130,7 +113,8 @@ export const TextExample = {

Using the size prop on EuiText you can get smaller sizes of text than the default. This demo compares the - scaling for all sizes. + scaling for all sizes. The goal is that the every line-height lands on + the 4px baseline grid.

), snippet: textScalingSnippet, diff --git a/packages/eui/src-docs/src/views/text/text_scaling.js b/packages/eui/src-docs/src/views/text/text_scaling.js index 4e2e2942f38..5cd9aa2e736 100644 --- a/packages/eui/src-docs/src/views/text/text_scaling.js +++ b/packages/eui/src-docs/src/views/text/text_scaling.js @@ -1,5 +1,4 @@ -import React, { useState, useContext } from 'react'; -import { ThemeContext } from '../../components/with_theme'; +import React, { useState } from 'react'; import { EuiText, @@ -84,7 +83,6 @@ const text = [ ]; export default () => { - const themeContext = useContext(ThemeContext); const textSizeArray = ['xs', 's', 'm']; const textSizeNamesArray = ['Extra small', 'Small', 'Medium']; @@ -132,10 +130,7 @@ export default () => { options={firstOptions} /> - + {text} @@ -154,10 +149,7 @@ export default () => { options={secondOptions} /> - + {text} diff --git a/packages/eui/src/components/provider/provider.test.tsx b/packages/eui/src/components/provider/provider.test.tsx index 0f61e2d61e2..c738185d7ac 100644 --- a/packages/eui/src/components/provider/provider.test.tsx +++ b/packages/eui/src/components/provider/provider.test.tsx @@ -12,6 +12,11 @@ import { cache as emotionCache } from '@emotion/css'; import createCache from '@emotion/cache'; import { setEuiDevProviderWarning } from '../../services'; +import { EuiSystemColorModeProvider } from './system_color_mode'; +jest.mock('./system_color_mode', () => ({ + EuiSystemColorModeProvider: jest.fn(({ children }: any) => children('LIGHT')), +})); + import { EuiProvider } from './provider'; describe('EuiProvider', () => { @@ -152,16 +157,40 @@ describe('EuiProvider', () => { expect(getByText('Modified')).toHaveStyleRule('color', '#aaa'); }); - it('passes `colorMode`', () => { - const { getByText } = render( - -
({ color: euiTheme.colors.lightShade })}> - Dark mode -
-
- ); + describe('colorMode', () => { + beforeEach(() => { + (EuiSystemColorModeProvider as jest.Mock).mockImplementationOnce( + ({ children }) => children('DARK') + ); + }); + + it('inherits from system color mode by default', () => { + const { getByText } = render( + +
({ color: euiTheme.colors.lightShade })} + > + Dark mode +
+
+ ); + + expect(getByText('Dark mode')).toHaveStyleRule('color', '#333'); + }); + + it('overrides the system color mode with any passed `colorMode`', () => { + const { getByText } = render( + +
({ color: euiTheme.colors.lightShade })} + > + Light mode +
+
+ ); - expect(getByText('Dark mode')).toHaveStyleRule('color', '#333'); + expect(getByText('Light mode')).toHaveStyleRule('color', '#aaa'); + }); }); }); diff --git a/packages/eui/src/components/provider/provider.tsx b/packages/eui/src/components/provider/provider.tsx index 2275009bd7c..dba42b595ab 100644 --- a/packages/eui/src/components/provider/provider.tsx +++ b/packages/eui/src/components/provider/provider.tsx @@ -13,6 +13,7 @@ import { EuiThemeProvider, EuiThemeProviderProps, EuiThemeSystem, + EuiThemeColorMode, } from '../../services'; import { emitEuiProviderWarning } from '../../services/theme/warning'; import { cache as fallbackCache } from '../../services/emotion/css'; @@ -25,6 +26,7 @@ import { EuiUtilityClasses } from '../../global_styling/utility/utility'; import { EuiThemeAmsterdam } from '../../themes'; import { EuiCacheProvider } from './cache'; +import { EuiSystemColorModeProvider } from './system_color_mode'; import { EuiProviderNestedCheck, useIsNestedEuiProvider } from './nested'; import { EuiComponentDefaults, @@ -38,12 +40,17 @@ const isEmotionCacheObject = ( export interface EuiProviderProps extends PropsWithChildren, EuiGlobalStylesProps, - Pick, 'colorMode' | 'modify'> { + Pick, 'modify'> { /** * Provide a specific EuiTheme; Defaults to EuiThemeAmsterdam; * Pass `null` to remove all theming including global reset */ theme?: EuiThemeSystem | null; + /** + * Allows setting `light` or `dark` mode. + * Defaults to the user's OS/system setting if undefined. + */ + colorMode?: EuiThemeColorMode; /** * Provide global styles via `@emotion/react` `Global` for your custom theme. * Pass `false` to remove the default EUI global styles. @@ -101,9 +108,9 @@ export const EuiProvider = ({ return children as any; } - let defaultCache; - let globalCache; - let utilityCache; + let defaultCache: EmotionCache | undefined; + let globalCache: EmotionCache | undefined; + let utilityCache: EmotionCache | undefined; if (cache) { if (isEmotionCacheObject(cache)) { cache.compat = true; @@ -127,27 +134,33 @@ export const EuiProvider = ({ return ( - - {theme && ( - <> - } - /> - } - /> - + + {(systemColorMode) => ( + + {theme && ( + <> + } + /> + } + /> + + )} + + {children} + + )} - - {children} - - + ); diff --git a/packages/eui/src/components/provider/system_color_mode/index.ts b/packages/eui/src/components/provider/system_color_mode/index.ts new file mode 100644 index 00000000000..122c0a3402f --- /dev/null +++ b/packages/eui/src/components/provider/system_color_mode/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { EuiSystemColorModeProvider } from './system_color_mode_provider'; diff --git a/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.test.tsx b/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.test.tsx new file mode 100644 index 00000000000..2782e05d67a --- /dev/null +++ b/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.test.tsx @@ -0,0 +1,86 @@ +/* + * 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 from 'react'; +import { render, act } from '@testing-library/react'; + +import { EuiSystemColorModeProvider } from './system_color_mode_provider'; + +describe('EuiSystemColorModeProvider', () => { + // @see https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + const mockAddEventListener = jest.fn(); + const mockRemoveEventListener = jest.fn(); + const mockMatchMedia = (matches = false) => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn((query) => ({ + matches, + media: query, + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + })), + }); + }; + + beforeEach(() => { + mockMatchMedia(); + jest.clearAllMocks(); + }); + + it('falls back to light mode if no dark mode media query has been set', () => { + const { container } = render( + + {(systemColorMode) => <>{systemColorMode}} + + ); + + expect(container.textContent).toEqual('LIGHT'); + }); + + it('detects dark mode system settings', () => { + mockMatchMedia(true); + const { container } = render( + + {(systemColorMode) => <>{systemColorMode}} + + ); + + expect(container.textContent).toEqual('DARK'); + }); + + describe('event listener', () => { + it('initializes an event listener that listens for system light/dark mode changes', () => { + const { container } = render( + + {(systemColorMode) => <>{systemColorMode}} + + ); + expect(container.textContent).toEqual('LIGHT'); + + expect(mockAddEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function) + ); + act(() => { + mockAddEventListener.mock.calls[0][1]({ matches: true }); + }); + + expect(container.textContent).toEqual('DARK'); + }); + + it('removes the event listener on unmount', () => { + const { unmount } = render( + + {(systemColorMode) => <>{systemColorMode}} + + ); + unmount(); + expect(mockRemoveEventListener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.tsx b/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.tsx new file mode 100644 index 00000000000..5396c967c37 --- /dev/null +++ b/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.tsx @@ -0,0 +1,43 @@ +/* + * 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 { FunctionComponent, ReactElement, useState, useEffect } from 'react'; +import { EuiThemeColorModeStandard } from '../../../services'; + +export const COLOR_MODE_MEDIA_QUERY = '(prefers-color-scheme: dark)'; + +export const EuiSystemColorModeProvider: FunctionComponent<{ + children: (systemColorMode: EuiThemeColorModeStandard) => ReactElement; +}> = ({ children }) => { + // Use optional chaining here for SSR or test environments + const [systemColorMode, setSystemColorMode] = + useState(() => + window?.matchMedia?.(COLOR_MODE_MEDIA_QUERY).matches ? 'DARK' : 'LIGHT' + ); + + // Listen for system changes + useEffect(() => { + const eventListener = (event: MediaQueryListEvent) => { + setSystemColorMode(event.matches ? 'DARK' : 'LIGHT'); + }; + + // Optional chaining here is for test environments - SSR should not run useEffect + window + .matchMedia?.(COLOR_MODE_MEDIA_QUERY) + .addEventListener?.('change', eventListener); + + // Clean up the listener on unmount + return () => { + window + .matchMedia?.(COLOR_MODE_MEDIA_QUERY) + .removeEventListener?.('change', eventListener); + }; + }, []); + + return children(systemColorMode); +};