diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 41809e3837e..06a3f6f9716 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,6 +12,8 @@ Remove or strikethrough items that do not apply to your PR. - Browser QA - [ ] Checked in both **light and dark** modes + - [ ] Checked in both [MacOS](https://support.apple.com/lv-lv/guide/mac-help/unac089/mac) and [Windows](https://support.microsoft.com/en-us/windows/turn-high-contrast-mode-on-or-off-in-windows-909e9d89-a0f9-a3a9-b993-7a6dcee85025) **high contrast modes** + - (_[emulate forced colors](https://devtoolstips.org/tips/en/emulate-forced-colors/) if you do not have access to a Windows machine_.) - [ ] Checked in **mobile** - [ ] Checked in **Chrome**, **Safari**, **Edge**, and **Firefox** - [ ] Checked for **accessibility** including keyboard-only and screenreader modes diff --git a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_Font_Default_Units.png b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_Font_Default_Units.png index 80531851d59..7ee34320f54 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_Font_Default_Units.png and b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_Font_Default_Units.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_Playground.png b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_Playground.png new file mode 100644 index 00000000000..00f088fe753 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_System_Defaults.png b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_System_Defaults.png new file mode 100644 index 00000000000..f0f3ed5cff0 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiProvider_System_Defaults.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_CSS_Variables_Global.png b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_CSS_Variables_Global.png index c026abf05f6..b139027d44f 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_CSS_Variables_Global.png and b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_CSS_Variables_Global.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_CSS_Variables_Nearest.png b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_CSS_Variables_Nearest.png index 8cc2126ebd8..0001abee890 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_CSS_Variables_Nearest.png and b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_CSS_Variables_Nearest.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_Dark_Mode.png b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_Dark_Mode.png new file mode 100644 index 00000000000..452df8fc100 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_Dark_Mode.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_High_Contrast_Mode.png b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_High_Contrast_Mode.png new file mode 100644 index 00000000000..d8574f3dc31 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_High_Contrast_Mode.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_Wrapper_Clone_Element.png b/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_Wrapper_Clone_Element.png deleted file mode 100644 index d95588d2e63..00000000000 Binary files a/packages/eui/.loki/reference/chrome_desktop_Theming_EuiThemeProvider_Wrapper_Clone_Element.png and /dev/null differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_Font_Default_Units.png b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_Font_Default_Units.png index 0c20a607f30..af57fd59c7f 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_Font_Default_Units.png and b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_Font_Default_Units.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_Playground.png b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_Playground.png new file mode 100644 index 00000000000..fb359f391d8 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_System_Defaults.png b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_System_Defaults.png new file mode 100644 index 00000000000..895667723d6 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiProvider_System_Defaults.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_CSS_Variables_Global.png b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_CSS_Variables_Global.png index 764c9960ab5..d991467ed3c 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_CSS_Variables_Global.png and b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_CSS_Variables_Global.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_CSS_Variables_Nearest.png b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_CSS_Variables_Nearest.png index 434d81242b1..1b1efd6d189 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_CSS_Variables_Nearest.png and b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_CSS_Variables_Nearest.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_Dark_Mode.png b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_Dark_Mode.png new file mode 100644 index 00000000000..35be106db8b Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_Dark_Mode.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_High_Contrast_Mode.png b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_High_Contrast_Mode.png new file mode 100644 index 00000000000..63cab4b7bb4 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_High_Contrast_Mode.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_Wrapper_Clone_Element.png b/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_Wrapper_Clone_Element.png deleted file mode 100644 index ca7bb824564..00000000000 Binary files a/packages/eui/.loki/reference/chrome_mobile_Theming_EuiThemeProvider_Wrapper_Clone_Element.png and /dev/null differ diff --git a/packages/eui/.storybook/decorator.tsx b/packages/eui/.storybook/decorator.tsx index 6b78a2e2353..6c8dc3267ed 100644 --- a/packages/eui/.storybook/decorator.tsx +++ b/packages/eui/.storybook/decorator.tsx @@ -86,6 +86,13 @@ const storybookToolbarColorModes: Array< { value: 'dark', title: 'Dark mode', icon: 'circle' }, ]; +const storybookToolbarHighContrastMode: Array< + ToolbarDisplay & { value: boolean } +> = [ + { value: false, title: 'High contrast off', icon: 'circlehollow' }, + { value: true, title: 'High contrast on', icon: 'circle' }, +]; + const storybookToolbarWritingModes: Array< ToolbarDisplay & { value: WritingModes } > = [ @@ -112,6 +119,17 @@ export const euiProviderDecoratorGlobals: Preview['globalTypes'] = { dynamicTitle: true, }, }, + highContrastMode: { + description: 'High contrast mode for EuiProvider theme', + defaultValue: window?.matchMedia?.('(prefers-contrast: more)').matches + ? true + : false, + toolbar: { + title: 'Contrast mode', + items: storybookToolbarHighContrastMode, + dynamicTitle: true, + }, + }, writingMode: { description: 'Writing mode for testing logical property directions', defaultValue: 'ltr', diff --git a/packages/eui/.storybook/preview.tsx b/packages/eui/.storybook/preview.tsx index c34435f5032..d230e00baee 100644 --- a/packages/eui/.storybook/preview.tsx +++ b/packages/eui/.storybook/preview.tsx @@ -46,6 +46,7 @@ const preview: Preview = { (Story, context) => ( diff --git a/packages/eui/changelogs/upcoming/8036.md b/packages/eui/changelogs/upcoming/8036.md new file mode 100644 index 00000000000..3e47054f021 --- /dev/null +++ b/packages/eui/changelogs/upcoming/8036.md @@ -0,0 +1,3 @@ +- Updated `EuiProvider` `and `EuiThemeProvider` with a new `highContrastMode` + - This prop allows toggling a higher contrast visual style that primarily affects borders and shadows + - On `EuiProvider`, if the `highContrastMode` prop is not passed, this setting will inherit from the user's OS/system light/dark mode setting diff --git a/packages/eui/src-docs/src/actions/action_types.js b/packages/eui/src-docs/src/actions/action_types.js index 07aa8e948fa..95ca9e6de82 100644 --- a/packages/eui/src-docs/src/actions/action_types.js +++ b/packages/eui/src-docs/src/actions/action_types.js @@ -2,7 +2,4 @@ export default { // Example nav actions REGISTER_SECTION: 'REGISTER_SECTION', UNREGISTER_SECTION: 'UNREGISTER_SECTION', - - // Locale actions - TOGGLE_LOCALE: 'TOGGLE_LOCALE', }; diff --git a/packages/eui/src-docs/src/actions/index.js b/packages/eui/src-docs/src/actions/index.js deleted file mode 100644 index 5c3431e4d43..00000000000 --- a/packages/eui/src-docs/src/actions/index.js +++ /dev/null @@ -1 +0,0 @@ -export { toggleLocale } from './locale_actions'; diff --git a/packages/eui/src-docs/src/actions/locale_actions.js b/packages/eui/src-docs/src/actions/locale_actions.js deleted file mode 100644 index c306dc9b991..00000000000 --- a/packages/eui/src-docs/src/actions/locale_actions.js +++ /dev/null @@ -1,8 +0,0 @@ -import ActionTypes from './action_types'; - -export const toggleLocale = (locale) => ({ - type: ActionTypes.TOGGLE_LOCALE, - data: { - locale, - }, -}); diff --git a/packages/eui/src-docs/src/components/guide_locale_selector/guide_locale_selector.js b/packages/eui/src-docs/src/components/guide_locale_selector/guide_locale_selector.js deleted file mode 100644 index 4b53fc35199..00000000000 --- a/packages/eui/src-docs/src/components/guide_locale_selector/guide_locale_selector.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import moment from 'moment'; -import { translateUsingPseudoLocale } from '../../../src/services/string/pseudo_locale_translator'; - -// For testing/demoing EuiDatePicker, process moment's `en` locale config into a babelfished version -const enConfig = moment.localeData('en')._config; -moment.defineLocale('en-xa', { - ...enConfig, - months: enConfig.months.map(translateUsingPseudoLocale), - monthsShort: enConfig.monthsShort.map(translateUsingPseudoLocale), - weekdays: enConfig.weekdays.map(translateUsingPseudoLocale), - weekdaysMin: enConfig.weekdaysMin.map(translateUsingPseudoLocale), - weekdaysShort: enConfig.weekdaysShort.map(translateUsingPseudoLocale), -}); -// Reset default moment locale after using `defineLocale` -moment.locale('en'); - -import { EuiSwitch, EuiToolTip } from '../../../../src/components'; - -export const GuideLocaleSelector = ({ selectedLocale, onToggleLocale }) => { - return ( - - - onToggleLocale(selectedLocale === 'en' ? 'en-xa' : 'en') - } - /> - - ); -}; - -GuideLocaleSelector.propTypes = { - onToggleLocale: PropTypes.func.isRequired, - selectedLocale: PropTypes.string.isRequired, -}; diff --git a/packages/eui/src-docs/src/components/guide_locale_selector/index.js b/packages/eui/src-docs/src/components/guide_locale_selector/index.js deleted file mode 100644 index 7f4a4fbcfb4..00000000000 --- a/packages/eui/src-docs/src/components/guide_locale_selector/index.js +++ /dev/null @@ -1 +0,0 @@ -export { GuideLocaleSelector } from './guide_locale_selector'; diff --git a/packages/eui/src-docs/src/components/guide_page/guide_page_chrome.js b/packages/eui/src-docs/src/components/guide_page/guide_page_chrome.js index 223aee0cb90..7c339c92149 100644 --- a/packages/eui/src-docs/src/components/guide_page/guide_page_chrome.js +++ b/packages/eui/src-docs/src/components/guide_page/guide_page_chrome.js @@ -302,7 +302,5 @@ export class GuidePageChrome extends Component { GuidePageChrome.propTypes = { currentRoute: PropTypes.object.isRequired, - onToggleLocale: PropTypes.func.isRequired, - selectedLocale: PropTypes.string.isRequired, navigation: PropTypes.array.isRequired, }; diff --git a/packages/eui/src-docs/src/components/guide_page/guide_page_header.tsx b/packages/eui/src-docs/src/components/guide_page/guide_page_header.tsx index f68018f3927..9be1dc1bc8b 100644 --- a/packages/eui/src-docs/src/components/guide_page/guide_page_header.tsx +++ b/packages/eui/src-docs/src/components/guide_page/guide_page_header.tsx @@ -21,15 +21,7 @@ import { VersionSwitcher } from './version_switcher'; const GITHUB_URL = 'https://github.com/elastic/eui'; -export type GuidePageHeaderProps = { - onToggleLocale: () => {}; - selectedLocale: string; -}; - -export const GuidePageHeader: React.FunctionComponent = ({ - onToggleLocale, - selectedLocale, -}) => { +export const GuidePageHeader = () => { const isMobileSize = useIsWithinBreakpoints(['xs', 's']); const logo = useMemo(() => { @@ -112,18 +104,9 @@ export const GuidePageHeader: React.FunctionComponent = ({ }, [codesandbox, github]); const rightSideItems = isMobileSize - ? [ - , - mobileMenu, - ] + ? [, mobileMenu] : [ - , + , github, , codesandbox, 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 bc9d4449b9d..2851a4941c5 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 @@ -1,78 +1,32 @@ /* eslint-disable no-restricted-globals */ -import React, { useState } from 'react'; +import React, { useState, useContext } from 'react'; -import { - EuiThemeProvider, - useEuiTheme, - useIsWithinBreakpoints, -} from '../../../../src/services'; +import { EuiThemeProvider, useEuiTheme } from '../../../../src/services'; import { EUI_THEME, EUI_THEMES } from '../../../../src/themes'; import { ThemeContext } from '../with_theme'; -// @ts-ignore Not TS -import { GuideLocaleSelector } from '../guide_locale_selector'; import { EuiPopover, EuiHorizontalRule, EuiButton, EuiContextMenuPanel, EuiContextMenuItem, + EuiSwitch, + EuiSwitchEvent, } from '../../../../src/components'; -type GuideThemeSelectorProps = { - onToggleLocale: () => {}; - selectedLocale: string; - context?: any; -}; - -export const GuideThemeSelector: React.FunctionComponent< - GuideThemeSelectorProps -> = ({ ...rest }) => { - return ( - - {(context) => } - - ); -}; - -const GuideThemeSelectorComponent: React.FunctionComponent< - GuideThemeSelectorProps -> = ({ context, onToggleLocale, selectedLocale }) => { - const isMobileSize = useIsWithinBreakpoints(['xs', 's']); - const [isPopoverOpen, setPopover] = useState(false); - - const onButtonClick = () => { - setPopover(!isPopoverOpen); - }; - - const closePopover = () => { - setPopover(false); - }; - - const systemColorMode = useEuiTheme().colorMode.toLowerCase(); +export const GuideThemeSelector = () => { + const context = useContext(ThemeContext); + const euiThemeContext = useEuiTheme(); + const colorMode = context.colorMode ?? euiThemeContext.colorMode; + const highContrastMode = + context.highContrastMode ?? euiThemeContext.highContrastMode; const currentTheme: EUI_THEME = - EUI_THEMES.find( - (theme) => theme.value === (context.theme ?? systemColorMode) - ) || EUI_THEMES[0]; - - const getIconType = (value: EUI_THEME['value']) => { - return value === currentTheme.value ? 'check' : 'empty'; - }; + EUI_THEMES.find((theme) => theme.value === context.theme) || EUI_THEMES[0]; - const items = EUI_THEMES.map((theme) => { - return ( - { - closePopover(); - context.changeTheme(theme.value); - }} - > - {theme.text} - - ); - }); + const [isPopoverOpen, setPopover] = useState(false); + const onButtonClick = () => setPopover(!isPopoverOpen); + const closePopover = () => setPopover(false); const button = ( @@ -84,11 +38,34 @@ const GuideThemeSelectorComponent: React.FunctionComponent< minWidth={0} onClick={onButtonClick} > - {isMobileSize ? 'Theme' : currentTheme.text} + Theme ); + const toggles = [ + { + label: 'Dark mode', + checked: colorMode.toLowerCase() === 'dark', + onChange: (e: EuiSwitchEvent) => + context.setContext({ + colorMode: e.target.checked ? 'DARK' : 'LIGHT', + }), + }, + { + label: 'High contrast', + checked: !!highContrastMode, + onChange: (e: EuiSwitchEvent) => + context.setContext({ highContrastMode: e.target.checked }), + }, + location.host.includes('803') && { + label: 'i18n testing', + checked: context.i18n === 'en-xa', + onChange: (e: EuiSwitchEvent) => + context.setContext({ i18n: e.target.checked ? 'en-xa' : 'en' }), + }, + ]; + return ( - - {location.host.includes('803') && ( - <> - -
- { + return ( + { + closePopover(); + context.setContext({ theme: theme.value }); + }} + > + {theme.text} + + ); + })} + /> + + {toggles.map((item) => + item ? ( +
({ padding: euiTheme.size.s })}> +
- + ) : null )} ); diff --git a/packages/eui/src-docs/src/components/with_theme/index.ts b/packages/eui/src-docs/src/components/with_theme/index.ts index 46d6e96d7c1..7cc4f114627 100644 --- a/packages/eui/src-docs/src/components/with_theme/index.ts +++ b/packages/eui/src-docs/src/components/with_theme/index.ts @@ -1,2 +1,6 @@ -export { ThemeProvider, ThemeContext } from './theme_context'; +export { + ThemeProvider, + ThemeContext, + type ThemeContextType, +} from './theme_context'; export { LanguageSelector } from './language_selector'; diff --git a/packages/eui/src-docs/src/components/with_theme/language_selector.tsx b/packages/eui/src-docs/src/components/with_theme/language_selector.tsx index 45931a8d423..212fe0c271e 100644 --- a/packages/eui/src-docs/src/components/with_theme/language_selector.tsx +++ b/packages/eui/src-docs/src/components/with_theme/language_selector.tsx @@ -1,75 +1,56 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext } from 'react'; -import { - EuiButtonGroup, - EuiIcon, - EuiLink, - EuiText, - EuiTourStep, -} from '../../../../src/components'; +import { EuiButtonGroup } from '../../../../src/components'; -import { - ThemeContext, - theme_languages, - THEME_LANGUAGES, -} from './theme_context'; +import { ThemeContext } from './theme_context'; -const NOTIF_STORAGE_KEY = 'js_vs_sass_notification'; -const NOTIF_STORAGE_VALUE = 'dismissed'; +export const THEME_LANGUAGES = ['language--js', 'language--sass'] as const; + +export type ThemeLanguages = { + id: (typeof THEME_LANGUAGES)[number]; + label: string; + title: string; +}; + +export const themeLanguagesOptions: ThemeLanguages[] = [ + { + id: 'language--js', + label: 'CSS-in-JS', + title: 'Language selector: CSS-in-JS', + }, + { + id: 'language--sass', + label: 'Sass', + title: 'Language selector: Sass', + }, +]; + +const ids = themeLanguagesOptions.map(({ id }) => id); export const LanguageSelector = ({ onChange, - showTour = false, }: { onChange?: (id: string) => void; - showTour?: boolean; }) => { const themeContext = useContext(ThemeContext); const toggleIdSelected = themeContext.themeLanguage; const onLanguageChange = (optionId: string) => { - themeContext.changeThemeLanguage(optionId as THEME_LANGUAGES['id']); + themeContext.setContext({ + themeLanguage: optionId as ThemeLanguages['id'], + }); onChange?.(optionId); - setTourIsOpen(false); - localStorage.setItem(NOTIF_STORAGE_KEY, NOTIF_STORAGE_VALUE); - }; - - const [isTourOpen, setTourIsOpen] = useState( - localStorage.getItem(NOTIF_STORAGE_KEY) === NOTIF_STORAGE_VALUE - ? false - : showTour - ); - - const onTourDismiss = () => { - setTourIsOpen(false); - localStorage.setItem(NOTIF_STORAGE_KEY, NOTIF_STORAGE_VALUE); }; return ( - -

Select your preferred styling language with this toggle button.

- - } - isStepOpen={isTourOpen} - onFinish={onTourDismiss} - step={1} - stepsTotal={1} - title={ - <> -   Theming update - + Got it!} - > - onLanguageChange(id)} - /> -
+ onChange={(id) => onLanguageChange(id)} + /> ); }; 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 f0fc4688557..519f90450af 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 @@ -1,47 +1,54 @@ import React, { PropsWithChildren } from 'react'; -import { EUI_THEMES, EUI_THEME } from '../../../../src/themes'; +import { + EUI_THEMES, + EUI_THEME, + AMSTERDAM_NAME_KEY, +} from '../../../../src/themes'; +import { EuiThemeColorModeStandard } from '../../../../src/services'; // @ts-ignore importing from a JS file -import { applyTheme } from '../../services'; - -const STYLE_STORAGE_KEY = 'js_vs_sass_preference'; -const URL_PARAM_KEY = 'themeLanguage'; - -export type THEME_LANGUAGES = { - id: 'language--js' | 'language--sass'; - label: string; - title: string; -}; - -export const theme_languages: THEME_LANGUAGES[] = [ - { - id: 'language--js', - label: 'CSS-in-JS', - title: 'Language selector: CSS-in-JS', - }, - { - id: 'language--sass', - label: 'Sass', - title: 'Language selector: Sass', +import { applyTheme, registerTheme } from '../../services'; + +// @ts-ignore Sass +import amsterdamThemeLight from '../../theme_light.scss'; +// @ts-ignore Sass +import amsterdamThemeDark from '../../theme_dark.scss'; +const THEME_CSS_MAP = { + [AMSTERDAM_NAME_KEY]: { + LIGHT: amsterdamThemeLight, + DARK: amsterdamThemeDark, }, -]; - +}; +EUI_THEMES.forEach((theme) => { + registerTheme( + theme.value, + THEME_CSS_MAP[theme.value as keyof typeof THEME_CSS_MAP] + ); +}); const THEME_NAMES = EUI_THEMES.map(({ value }) => value); -const THEME_LANGS = theme_languages.map(({ id }) => id); -type ThemeContextType = { +import { type ThemeLanguages } from './language_selector'; + +export type ThemeContextType = { theme?: EUI_THEME['value']; - changeTheme: (themeValue: EUI_THEME['value']) => void; - themeLanguage: THEME_LANGUAGES['id']; - changeThemeLanguage: (language: THEME_LANGUAGES['id']) => void; + colorMode?: EuiThemeColorModeStandard; + highContrastMode?: boolean; + i18n?: 'en' | 'en-xa'; + themeLanguage: ThemeLanguages['id']; // TODO: Can likely be deleted once Sass is fully deprecated + setContext: (context: Partial) => void; }; export const ThemeContext = React.createContext({ theme: undefined, - changeTheme: () => {}, - themeLanguage: THEME_LANGS[0], - changeThemeLanguage: () => {}, + colorMode: undefined, + highContrastMode: undefined, + themeLanguage: 'language--js', + i18n: 'en', + setContext: () => {}, }); -type State = Pick; +type State = Pick< + ThemeContextType, + 'theme' | 'colorMode' | 'highContrastMode' | 'themeLanguage' | 'i18n' +>; export class ThemeProvider extends React.Component { constructor(props: object) { @@ -50,69 +57,96 @@ export class ThemeProvider extends React.Component { const theme = localStorage.getItem('theme') || undefined; applyTheme(theme && THEME_NAMES.includes(theme) ? theme : THEME_NAMES[0]); + const colorMode = + (localStorage.getItem('colorMode') as EuiThemeColorModeStandard) || + undefined; + + const highContrastMode = localStorage.getItem('highContrastMode') + ? localStorage.getItem('highContrastMode') === 'true' + : undefined; + + const i18n = (localStorage.getItem('i18n') as any) || 'en'; + const themeLanguage = this.getThemeLanguage(); this.state = { theme, + colorMode, + highContrastMode, + i18n, themeLanguage, }; } - changeTheme = (themeValue: EUI_THEME['value']) => { - this.setState({ theme: themeValue }, () => { - localStorage.setItem('theme', themeValue); - applyTheme(themeValue); - }); + setContext = (state: Partial) => { + this.setState(state as State); }; + componentDidUpdate(_prevProps: never, prevState: State) { + const stateToSetInLocalStorage = [ + 'theme', + 'colorMode', + 'highContrastMode', + 'i18n', + 'themeLanguage', + ] as const; + + stateToSetInLocalStorage.forEach((key) => { + if (prevState[key] !== this.state[key]) { + localStorage.setItem(key, String(this.state[key])); + + // Side effects + if (key === 'theme') { + applyTheme(this.state.theme); + } + if (key === 'themeLanguage') { + this.setThemeLanguageParam(this.state.themeLanguage!); + } + } + }); + } + getThemeLanguage = () => { // Allow theme language to be set by URL param, so we can link people // to specific docs, e.g. ?themeLanguage=js, ?themeLanguage=sass // Note that because of our hash router, this logic only works on page load/full reload const urlParams = window?.location?.href?.split('?')[1]; // Note: we can't use location.search because of our hash router - const fromUrlParam = new URLSearchParams(urlParams).get(URL_PARAM_KEY); + const fromUrlParam = new URLSearchParams(urlParams).get('themeLanguage'); // Otherwise, obtain it from localStorage - const fromLocalStorage = localStorage.getItem(STYLE_STORAGE_KEY); + const fromLocalStorage = localStorage.getItem('themeLanguage'); - let themeLanguage = ( + const themeLanguage = ( fromUrlParam ? `language--${fromUrlParam}` : fromLocalStorage - ) as THEME_LANGUAGES['id']; + ) as ThemeLanguages['id']; // If not set by either param or storage, or an invalid value, use the default - if (!themeLanguage || !THEME_LANGS.includes(themeLanguage)) - themeLanguage = THEME_LANGS[0]; - - return themeLanguage; + return themeLanguage || 'language--js'; }; - setThemeLanguageParam = (languageKey: THEME_LANGUAGES['id']) => { + setThemeLanguageParam = (languageKey: ThemeLanguages['id']) => { const languageValue = languageKey.replace('language--', ''); // Make our params more succinct const hash = window?.location?.hash?.split('?'); // Note: we can't use location.search because of our hash router const queryParams = hash[1]; const params = new URLSearchParams(queryParams); - params.set(URL_PARAM_KEY, languageValue); + params.set('themeLanguage', languageValue); window.location.hash = `${hash[0]}?${params.toString()}`; }; - changeThemeLanguage = (language: THEME_LANGUAGES['id']) => { - this.setState({ themeLanguage: language }, () => { - localStorage.setItem(STYLE_STORAGE_KEY, language); - this.setThemeLanguageParam(language); - }); - }; - render() { const { children } = this.props; - const { theme, themeLanguage } = this.state; + const { theme, colorMode, highContrastMode, i18n, themeLanguage } = + this.state; return ( {children} diff --git a/packages/eui/src-docs/src/index.js b/packages/eui/src-docs/src/index.js index a69a07e3613..7132f648180 100644 --- a/packages/eui/src-docs/src/index.js +++ b/packages/eui/src-docs/src/index.js @@ -10,16 +10,11 @@ import { AppContext } from './views/app_context'; import { AppView } from './views/app_view'; import { HomeView } from './views/home/home_view'; import { NotFoundView } from './views/not_found/not_found_view'; -import { registerTheme, ExampleContext } from './services'; +import { ExampleContext } from './services'; import Routes from './routes'; -import themeLight from './theme_light.scss'; -import themeDark from './theme_dark.scss'; import { ThemeProvider } from './components/with_theme/theme_context'; -registerTheme('light', [themeLight]); -registerTheme('dark', [themeDark]); - // Set up app // Whether the docs app should be wrapped in diff --git a/packages/eui/src-docs/src/routes.js b/packages/eui/src-docs/src/routes.js index 8038e2fa687..9a0f95448a3 100644 --- a/packages/eui/src-docs/src/routes.js +++ b/packages/eui/src-docs/src/routes.js @@ -245,6 +245,7 @@ import { SuperSelectExample } from './views/super_select/super_select_example'; import { ThemeExample } from './views/theme/theme_example'; import { ColorModeExample } from './views/theme/color_mode/color_mode_example'; +import { HighContrastModeExample } from './views/theme/high_contrast_mode/high_contrast_mode_example'; import { BreakpointsExample } from './views/theme/breakpoints/breakpoints_example'; import Borders, { bordersSections } from './views/theme/borders/borders'; import Color, { colorsInfo, colorsSections } from './views/theme/color/tokens'; @@ -431,6 +432,7 @@ const navigation = [ items: [ createExample(ThemeExample, 'Theme provider'), createExample(ColorModeExample), + createExample(HighContrastModeExample), createTabbedPage(BreakpointsExample), { name: 'Borders', diff --git a/packages/eui/src-docs/src/services/theme/theme.js b/packages/eui/src-docs/src/services/theme/theme.js index a3bbf6030c2..316ac207bc6 100644 --- a/packages/eui/src-docs/src/services/theme/theme.js +++ b/packages/eui/src-docs/src/services/theme/theme.js @@ -4,9 +4,9 @@ export function registerTheme(theme, cssFiles) { themes[theme] = cssFiles; } -export function applyTheme(newTheme) { +export function applyTheme(newTheme, colorMode = 'LIGHT') { Object.keys(themes).forEach((theme) => - themes[theme].forEach((cssFile) => cssFile.unuse()) + Object.values(themes[theme]).forEach((cssFile) => cssFile.unuse()) ); - themes[newTheme]?.forEach((cssFile) => cssFile.use()); + themes[newTheme]?.[colorMode]?.use(); } diff --git a/packages/eui/src-docs/src/store/configure_store.js b/packages/eui/src-docs/src/store/configure_store.js index bcd8efc8540..0f4ea4addbf 100644 --- a/packages/eui/src-docs/src/store/configure_store.js +++ b/packages/eui/src-docs/src/store/configure_store.js @@ -3,18 +3,13 @@ import thunk from 'redux-thunk'; import Routes from '../routes'; -import localeReducer from './reducers/locale_reducer'; -import themeReducer from './reducers/theme_reducer'; - /** * @param {Object} initialState An object defining the application's initial * state. */ export default function configureStore(initialState) { - function rootReducer(state = {}, action) { + function rootReducer() { return { - theme: themeReducer(state.theme, action), - locale: localeReducer(state.locale, action), routes: Routes, }; } diff --git a/packages/eui/src-docs/src/store/index.js b/packages/eui/src-docs/src/store/index.js index 04ef96b83ea..8421502c9c9 100644 --- a/packages/eui/src-docs/src/store/index.js +++ b/packages/eui/src-docs/src/store/index.js @@ -1,11 +1,3 @@ -export function getTheme(state) { - return state.theme.theme; -} - export function getRoutes(state) { return state.routes; } - -export function getLocale(state) { - return state.locale.locale; -} diff --git a/packages/eui/src-docs/src/views/app_context.js b/packages/eui/src-docs/src/views/app_context.js index 729f4c94079..f5ecad2f8fb 100644 --- a/packages/eui/src-docs/src/views/app_context.js +++ b/packages/eui/src-docs/src/views/app_context.js @@ -1,10 +1,8 @@ import React, { useContext } from 'react'; import { Helmet } from 'react-helmet'; -import { useSelector } from 'react-redux'; import createCache from '@emotion/cache'; import { ThemeContext } from '../components'; import { translateUsingPseudoLocale } from '../services'; -import { getLocale } from '../store'; import { EuiContext, EuiProvider } from '../../../src/components'; import { @@ -33,18 +31,7 @@ const utilityCache = createCache({ }); export const AppContext = ({ children }) => { - const { theme } = useContext(ThemeContext); - const locale = useSelector((state) => getLocale(state)); - - const mappingFuncs = { - 'en-xa': translateUsingPseudoLocale, - }; - - const i18n = { - mappingFunc: mappingFuncs[locale], - formatNumber: (value) => new Intl.NumberFormat(locale).format(value), - locale, - }; + const { theme, colorMode, highContrastMode, i18n } = useContext(ThemeContext); const isLocalDev = window.location.host.includes('803'); setEuiDevProviderWarning(isLocalDev ? 'error' : 'warn'); // Note: this can't be in a useEffect, otherwise it fires too late for style memoization warnings to error on page reload @@ -56,9 +43,8 @@ export const AppContext = ({ children }) => { utility: utilityCache, }} theme={EUI_THEMES.find((t) => t.value === theme)?.provider} - colorMode={ - theme ? (theme.includes('light') ? 'light' : 'dark') : undefined - } + colorMode={colorMode} + highContrastMode={highContrastMode} > { rel="stylesheet" /> - {children} + new Intl.NumberFormat(i18n).format(value), + }} + > + {children} + ); }; diff --git a/packages/eui/src-docs/src/views/app_view.js b/packages/eui/src-docs/src/views/app_view.js index 8911d2a8332..91a094ef143 100644 --- a/packages/eui/src-docs/src/views/app_view.js +++ b/packages/eui/src-docs/src/views/app_view.js @@ -1,9 +1,8 @@ import React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; -import { toggleLocale as _toggleLocale } from '../actions'; import { GuidePageChrome, GuidePageHeader } from '../components'; -import { getLocale, getRoutes } from '../store'; +import { getRoutes } from '../store'; import { useScrollToHash, useHeadingAnchorLinks, @@ -17,9 +16,6 @@ import { } from '../../../src/components'; export const AppView = ({ children, currentRoute = {} }) => { - const dispatch = useDispatch(); - const toggleLocale = (locale) => dispatch(_toggleLocale(locale)); - const locale = useSelector((state) => getLocale(state)); const routes = useSelector((state) => getRoutes(state)); const portalledHeadingAnchorLinks = useHeadingAnchorLinks(); @@ -43,7 +39,7 @@ export const AppView = ({ children, currentRoute = {} }) => { Skip to content {portalledHeadingAnchorLinks} - + { diff --git a/packages/eui/src-docs/src/views/provider/provider_example.js b/packages/eui/src-docs/src/views/provider/provider_example.js index 0808935cb91..00e7420617c 100644 --- a/packages/eui/src-docs/src/views/provider/provider_example.js +++ b/packages/eui/src-docs/src/views/provider/provider_example.js @@ -64,20 +64,15 @@ export const ProviderExample = {

To customize the global theme of your app, use the{' '} - theme, colorMode, and{' '} - modify props (documented in{' '} + theme and modify props + (documented in{' '} EuiThemeProvider - ). For instance, it's likely that you will want to implement - color mode switching at the top level: + ). The colorMode and{' '} + highContrastMode props automatically default to + the users' system settings, but can also be overridden if needed.

- - - {""} - - -

If you do not wish your app to include EUI's default global reset CSS or{' '} diff --git a/packages/eui/src-docs/src/views/theme/color_mode/_color_mode_intro.tsx b/packages/eui/src-docs/src/views/theme/color_mode/_color_mode_intro.tsx index c856a47c338..83436bc4ea8 100644 --- a/packages/eui/src-docs/src/views/theme/color_mode/_color_mode_intro.tsx +++ b/packages/eui/src-docs/src/views/theme/color_mode/_color_mode_intro.tsx @@ -20,6 +20,12 @@ export default () => { The colorMode determines which values to return based on LIGHT or DARK mode.

+

+ By default, if this prop is not passed, EuiProvider{' '} + will detect and use the user's system dark mode preference. If the + prop is passed, it will override the user's system + settings. +

diff --git a/packages/eui/src-docs/src/views/theme/high_contrast_mode/high_contrast_mode_example.js b/packages/eui/src-docs/src/views/theme/high_contrast_mode/high_contrast_mode_example.js new file mode 100644 index 00000000000..d779aaee562 --- /dev/null +++ b/packages/eui/src-docs/src/views/theme/high_contrast_mode/high_contrast_mode_example.js @@ -0,0 +1,118 @@ +import React from 'react'; + +import { GuideSectionTypes } from '../../../components'; + +import { EuiCallOut, EuiCode, EuiLink, EuiText } from '../../../../../src'; + +import Rendering from './rendering'; +const RenderingSource = require('!!raw-loader!./rendering'); + +import Reacting from './reacting'; +const ReactingSource = require('!!raw-loader!./reacting'); + +export const HighContrastModeExample = { + title: 'High contrast mode', + isBeta: true, + intro: ( + +

+ The highContrastMode determines and sets certain + un-overrideable modifications to the EUI theme, primarily around borders + and shadows. Borders will always be pure black or white (depending on + the color mode), and shadows will be entirely replaced with borders. +

+

+ By default, if this prop is not passed, EuiProvider{' '} + will detect and use the user's system contrast preferences. +

+
+ ), + sections: [ + { + title: 'Rendering a specific contrast mode', + text: ( + <> +

+ While it's usually best to keep all high contrast mode the same + across your app for visual consistency, some instances may benefit + from an exaggerated change in contrast. For this you can set{' '} + EuiThemeProvider's{' '} + highContrastMode to true. +

+ + In general, we do not ever recommend manually turning off high + contrast via highContrastMode={'{false}'}. + Respect the user's contrast preferences where possible. + + } + /> + + ), + demo: , + source: [ + { + type: GuideSectionTypes.TSX, + code: RenderingSource, + }, + ], + }, + { + title: 'Forced contrast themes and colors', + text: ( + <> +

+ Please note that some OSes and browsers have something called{' '} + + forced colors mode + + , which overrides all colors, backgrounds, borders, + and shadows. An example of this is Windows High Contrast modes. +

+

+ Since this is done at a level that EUI can do nothing about, if + forced colors mode is detected by EuiProvider, EUI + will ignore any passed highContrastMode prop, as + this user choice and system setting takes precedence. +

+ + To quickly test your application in forced colors mode without + switching OS themes, you can{' '} + + use Chrome or Edge's devtools to emulate forced-colors mode. + + + + ), + }, + { + title: 'Reacting to user high contrast modes', + text: ( +

+ The detected or current highContrastMode is available via + useEuiTheme(). It returns either{' '} + "forced", "preferred", or simply{' '} + false. You can use this information to (for + example) conditionally render or opt out of rendering certain styles + or colors. +

+ ), + demo: , + source: [ + { + type: GuideSectionTypes.JS, + code: ReactingSource, + }, + ], + }, + ], +}; diff --git a/packages/eui/src-docs/src/views/theme/high_contrast_mode/reacting.tsx b/packages/eui/src-docs/src/views/theme/high_contrast_mode/reacting.tsx new file mode 100644 index 00000000000..59a58474d1c --- /dev/null +++ b/packages/eui/src-docs/src/views/theme/high_contrast_mode/reacting.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { useEuiTheme, EuiPanel } from '../../../../../src'; + +export default () => { + const { highContrastMode, euiTheme } = useEuiTheme(); + + return ( + + This panel will have a thick border in high contrast mode. + + ); +}; diff --git a/packages/eui/src-docs/src/views/theme/high_contrast_mode/rendering.tsx b/packages/eui/src-docs/src/views/theme/high_contrast_mode/rendering.tsx new file mode 100644 index 00000000000..68e2ea74f1c --- /dev/null +++ b/packages/eui/src-docs/src/views/theme/high_contrast_mode/rendering.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { faker } from '@faker-js/faker'; +import { + EuiThemeProvider, + EuiBasicTable, + EuiBasicTableColumn, +} from '../../../../../src'; + +type User = { + firstName: string; + lastName: string; +}; + +const users: User[] = []; +for (let i = 0; i < 5; i++) { + users.push({ + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }); +} + +const columns: Array> = [ + { + field: 'firstName', + name: 'First name', + }, + { + field: 'lastName', + name: 'Last name', + }, +]; + +export default () => { + return ( + + + + ); +}; diff --git a/packages/eui/src-docs/src/views/theme/provider.tsx b/packages/eui/src-docs/src/views/theme/provider.tsx index 9454943d271..6cab1216518 100644 --- a/packages/eui/src-docs/src/views/theme/provider.tsx +++ b/packages/eui/src-docs/src/views/theme/provider.tsx @@ -10,7 +10,7 @@ import { } from '../../../../src'; export default () => { - const { euiTheme, colorMode } = useEuiTheme(); + const { euiTheme, colorMode, highContrastMode } = useEuiTheme(); return ( @@ -44,13 +44,13 @@ export default () => { colorMode: + {colorMode} - -

- {colorMode} -

+ + highContrastMode: + {String(highContrastMode)}
diff --git a/packages/eui/src-docs/src/views/theme/theme_example.js b/packages/eui/src-docs/src/views/theme/theme_example.js index eb88f718b90..9e0f67016ca 100644 --- a/packages/eui/src-docs/src/views/theme/theme_example.js +++ b/packages/eui/src-docs/src/views/theme/theme_example.js @@ -33,16 +33,13 @@ export const ThemeExample = { <>

- EUI is in the progress of switching it's core styles processor - from Sass to Emotion. To - take full advantage of this context layer, wrap the root of your - application with a single{' '} + While{' '} EuiProvider - - . While EuiProvider should not be included more than - once, you may use multiple nested EuiThemeProviders{' '} - to customize section-specific or component-specific{' '} + {' '} + should not be included more than once at the top level of your app, + you may use multiple nested EuiThemeProviders to + customize section-specific or component-specific{' '} color modes {' '} @@ -57,32 +54,44 @@ export const ThemeExample = { text: ( <>

- The context layer that enables theming (including the default theme - styles) comes from EuiThemeProvider.{' '} - EuiThemeProvider accepts three props, all of - which have default values and are therefore optional. To use the - default EUI theme, no configuration is required. + The context layer that enables theming comes from{' '} + EuiThemeProvider.{' '} + EuiThemeProvider accepts four main props (all of + which have default values and are therefore optional):

  • - theme: EuiThemeSystem Raw theme - values. Calculated values are acceptable. + theme: Raw theme values. + Calculated values are acceptable. For the full shape of an EUI + theme, see the{' '} + + global values + {' '} + page.
  • - colorMode: EuiThemeColorMode{' '} - Simply {"'light'"} or {"'dark'"} + modify: Accepts an object of + overrides for theme values. For usage examples, see{' '} + + Simple instance overrides + {' '} + below.
  • - modify: EuiThemeModifications{' '} - Overrides and modifications for theme values. + colorMode: Accepts 'light', + 'dark', or 'inverse'. For usage, see the{' '} + Color mode page. +
  • +
  • + highContrastMode: Accepts a + true/false boolean. For usage, see the{' '} + + High contrast mode + {' '} + page.
-

- The concept for each prop is explained in subsequent sections. More - information on the full shape of an EUI theme, see the{' '} - Global Values{' '} - page. -

+

To use the default EUI theme, no configuration is required.

), demo: , @@ -99,25 +108,27 @@ export const ThemeExample = { text: ( <>

- Using the react hook useEuiTheme() makes it very - easy to consume the EUI static and computed variables like colors - and sizing. It simply passes back an object of the current theme - which includes + Using the React hook useEuiTheme() makes it very + easy to consume EUI's static and computed variables, like colors and + sizing. It simply passes back an object of the current theme which + includes:

  • - euiTheme: EuiThemeComputed All - the calculated keys including any modifications + euiTheme: All the calculated keys + including any modifications +
  • +
  • + modifications: Only the + modification keys
  • - colorMode: EuiThemeColorMode{' '} - Simply {"'light'"} or {"'dark'"} + colorMode: Either "LIGHT" or + "DARK"
  • - - modifications: EuiThemeModifications - {' '} - Only the modification keys + highContrastMode: Either + 'forced', 'preferred', or false

diff --git a/packages/eui/src/components/provider/provider.stories.tsx b/packages/eui/src/components/provider/provider.stories.tsx index 08115a748b9..375aa2cb1da 100644 --- a/packages/eui/src/components/provider/provider.stories.tsx +++ b/packages/eui/src/components/provider/provider.stories.tsx @@ -8,8 +8,11 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; - import { SPREAD_STORY_ARGS_MARKER } from '../../../.storybook/addons/code-snippet/constants'; + +import { EuiPanel } from '../panel'; +import { EuiCode } from '../code'; + import { EuiProvider, EuiProviderProps } from './provider'; const meta: Meta> = { @@ -18,30 +21,99 @@ const meta: Meta> = { argTypes: { colorMode: { control: 'select', - options: ['light', 'dark', 'inverse', 'LIGHT', 'DARK', 'INVERSE'], + options: [ + undefined, + 'light', + 'dark', + 'inverse', + 'LIGHT', + 'DARK', + 'INVERSE', + ], + }, + highContrastMode: { + control: 'select', + options: [undefined, true, false], }, modify: { control: 'object' }, componentDefaults: { control: 'object' }, - globalStyles: { control: 'boolean' }, - utilityClasses: { control: 'boolean' }, + globalStyles: { + control: 'boolean', + mapping: { true: undefined, false: false }, + }, + utilityClasses: { + control: 'boolean', + mapping: { true: undefined, false: false }, + }, + }, + parameters: { + codeSnippet: { + snippet: ` + + `, + }, }, }; export default meta; type Story = StoryObj>; -export const FontDefaultUnits: Story = { +export const Playground: Story = { + render: () => ( + <> + + Setting globalStyles to false will remove all body + and font styles, but retain component styles (e.g. this{' '} + EuiPanel). + + + Setting utilityClasses to false will remove the + centering on this text, which has .eui-textCenter{' '} + applied. + + + ), +}; + +export const SystemDefaults: Story = { parameters: { - codeSnippet: { - snippet: ` - - `, + controls: { + include: ['colorMode', 'highContrastMode'], + }, + }, + argTypes: { + colorMode: { + control: 'radio', + options: [undefined, 'light', 'dark'], + }, + highContrastMode: { + control: 'radio', + options: [undefined, false, true], }, }, + args: { + colorMode: undefined, + highContrastMode: undefined, + }, + // _args is needed (even if unused) for controls.include to work as expected + // see https://github.com/storybookjs/storybook/issues/23343 + render: (_args) => ( + + When undefined, colorMode and{' '} + highContrastMode will inherit from the user's OS/system + settings. + + ), +}; + +export const FontDefaultUnits: Story = { + parameters: { + controls: { include: ['modify'] }, + }, args: { modify: { font: { defaultUnits: 'rem' } }, }, - render: () => ( + render: (_args) => ( <> Change `modify.font.defaultUnits` to{' '} `rem`, `em`, or `px` and then inspect this demo's `html` diff --git a/packages/eui/src/components/provider/provider.test.tsx b/packages/eui/src/components/provider/provider.test.tsx index c738185d7ac..3247f0047bc 100644 --- a/packages/eui/src/components/provider/provider.test.tsx +++ b/packages/eui/src/components/provider/provider.test.tsx @@ -11,10 +11,10 @@ import { render } from '@testing-library/react'; // Note - don't use the EUI cus 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 { setEuiDevProviderWarning, useEuiTheme } from '../../services'; +import { useWindowMediaMatcher } from './system_defaults/match_media_hook'; +jest.mock('./system_defaults/match_media_hook', () => ({ + useWindowMediaMatcher: jest.fn(), })); import { EuiProvider } from './provider'; @@ -158,10 +158,10 @@ describe('EuiProvider', () => { }); describe('colorMode', () => { - beforeEach(() => { - (EuiSystemColorModeProvider as jest.Mock).mockImplementationOnce( - ({ children }) => children('DARK') - ); + beforeAll(() => { + (useWindowMediaMatcher as jest.Mock).mockImplementation((media) => { + if (media === '(prefers-color-scheme: dark)') return true; + }); }); it('inherits from system color mode by default', () => { @@ -192,6 +192,40 @@ describe('EuiProvider', () => { expect(getByText('Light mode')).toHaveStyleRule('color', '#aaa'); }); }); + + describe('highContrastMode', () => { + const Output = () => { + const { highContrastMode } = useEuiTheme(); + return <>{String(highContrastMode)}; + }; + + beforeEach(() => { + (useWindowMediaMatcher as jest.Mock).mockImplementation((media) => { + if (media === '(prefers-contrast: more)') return true; + }); + }); + afterEach(jest.resetAllMocks); + + it('inherits from system contrast preference by default', () => { + const { container } = render( + + + + ); + + expect(container.textContent).toEqual('preferred'); + }); + + it('overrides the system preference with the passed prop', () => { + const { container } = render( + + + + ); + + expect(container.textContent).toEqual('false'); + }); + }); }); describe('nested EuiProviders', () => { diff --git a/packages/eui/src/components/provider/provider.tsx b/packages/eui/src/components/provider/provider.tsx index dba42b595ab..9f023ac0188 100644 --- a/packages/eui/src/components/provider/provider.tsx +++ b/packages/eui/src/components/provider/provider.tsx @@ -14,6 +14,7 @@ import { EuiThemeProviderProps, EuiThemeSystem, EuiThemeColorMode, + EuiThemeHighContrastModeProp, } from '../../services'; import { emitEuiProviderWarning } from '../../services/theme/warning'; import { cache as fallbackCache } from '../../services/emotion/css'; @@ -26,7 +27,7 @@ import { EuiUtilityClasses } from '../../global_styling/utility/utility'; import { EuiThemeAmsterdam } from '../../themes'; import { EuiCacheProvider } from './cache'; -import { EuiSystemColorModeProvider } from './system_color_mode'; +import { EuiSystemDefaultsProvider } from './system_defaults'; import { EuiProviderNestedCheck, useIsNestedEuiProvider } from './nested'; import { EuiComponentDefaults, @@ -51,6 +52,14 @@ export interface EuiProviderProps * Defaults to the user's OS/system setting if undefined. */ colorMode?: EuiThemeColorMode; + /** + * Allows enabling a high contrast mode preference for better accessibility. + * Defaults to the user's OS/system setting if undefined. + * + * - @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-contrast + * - @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors (system only, supercedes this prop) + */ + highContrastMode?: EuiThemeHighContrastModeProp; /** * Provide global styles via `@emotion/react` `Global` for your custom theme. * Pass `false` to remove the default EUI global styles. @@ -95,6 +104,7 @@ export const EuiProvider = ({ globalStyles: Globals = EuiGlobalStyles, utilityClasses: Utilities = EuiUtilityClasses, colorMode, + highContrastMode, modify, componentDefaults, children, @@ -134,33 +144,30 @@ export const EuiProvider = ({ return ( - - {(systemColorMode) => ( - - {theme && ( - <> - } - /> - } - /> - - )} - - {children} - - - )} - + + + {theme && ( + <> + } + /> + } + /> + + )} + + {children} + + + ); 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 deleted file mode 100644 index 2782e05d67a..00000000000 --- a/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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 deleted file mode 100644 index 9148a16e0cb..00000000000 --- a/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 }) => { - // Check typeof and use optional chaining for SSR or test environments - const [systemColorMode, setSystemColorMode] = - useState(() => - typeof window !== 'undefined' && - 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); -}; diff --git a/packages/eui/src/components/provider/system_color_mode/index.ts b/packages/eui/src/components/provider/system_defaults/index.ts similarity index 72% rename from packages/eui/src/components/provider/system_color_mode/index.ts rename to packages/eui/src/components/provider/system_defaults/index.ts index 122c0a3402f..bfedd7889a5 100644 --- a/packages/eui/src/components/provider/system_color_mode/index.ts +++ b/packages/eui/src/components/provider/system_defaults/index.ts @@ -6,4 +6,5 @@ * Side Public License, v 1. */ -export { EuiSystemColorModeProvider } from './system_color_mode_provider'; +export { EuiSystemDefaultsProvider } from './system_defaults_provider'; +export { useWindowMediaMatcher } from './match_media_hook'; diff --git a/packages/eui/src/components/provider/system_defaults/match_media_hook.test.ts b/packages/eui/src/components/provider/system_defaults/match_media_hook.test.ts new file mode 100644 index 00000000000..14fea97480b --- /dev/null +++ b/packages/eui/src/components/provider/system_defaults/match_media_hook.test.ts @@ -0,0 +1,71 @@ +/* + * 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 { renderHook, renderHookAct } from '../../../test/rtl'; + +import { useWindowMediaMatcher } from './match_media_hook'; + +describe('useWindowMediaMatcher', () => { + const useMockMediaQuery = () => useWindowMediaMatcher('(min-width: 500px)'); + + // @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: boolean) => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn((query) => ({ + matches, + media: query, + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + })), + }); + }; + + beforeEach(() => jest.clearAllMocks()); + + describe('returns the `.matches` value of the window.matchMedia', () => { + test('true', () => { + mockMatchMedia(true); + const { result } = renderHook(useMockMediaQuery); + expect(result.current).toEqual(true); + }); + + test('false', () => { + mockMatchMedia(false); + const { result } = renderHook(useMockMediaQuery); + expect(result.current).toEqual(false); + }); + }); + + describe('event listener', () => { + it('initializes an event listener that listens for changes to the media query', () => { + mockMatchMedia(false); + + const { result } = renderHook(useMockMediaQuery); + expect(result.current).toEqual(false); + + expect(mockAddEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function) + ); + renderHookAct(() => { + mockAddEventListener.mock.calls[0][1]({ matches: true }); + }); + + expect(result.current).toEqual(true); + }); + + it('removes the event listener on unmount', () => { + const { unmount } = renderHook(useMockMediaQuery); + unmount(); + expect(mockRemoveEventListener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/eui/src/components/provider/system_defaults/match_media_hook.ts b/packages/eui/src/components/provider/system_defaults/match_media_hook.ts new file mode 100644 index 00000000000..16f956e8c20 --- /dev/null +++ b/packages/eui/src/components/provider/system_defaults/match_media_hook.ts @@ -0,0 +1,37 @@ +/* + * 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 { useState, useEffect } from 'react'; + +export const useWindowMediaMatcher = (mediaQuery: string) => { + // Check typeof and use optional chaining for SSR or test environments + const [mediaMatches, setMediaMatches] = useState( + () => + typeof window !== 'undefined' && + (window?.matchMedia?.(mediaQuery)?.matches ?? false) + ); + + // Listen for system changes + useEffect(() => { + const eventListener = (event: MediaQueryListEvent) => { + setMediaMatches(event.matches); + }; + + // Optional chaining here is for test environments - SSR should not run useEffect + window.matchMedia?.(mediaQuery).addEventListener?.('change', eventListener); + + // Clean up the listener on unmount + return () => { + window + .matchMedia?.(mediaQuery) + .removeEventListener?.('change', eventListener); + }; + }, [mediaQuery]); + + return mediaMatches; +}; diff --git a/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.server.test.tsx b/packages/eui/src/components/provider/system_defaults/system_defaults_provider.server.test.tsx similarity index 70% rename from packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.server.test.tsx rename to packages/eui/src/components/provider/system_defaults/system_defaults_provider.server.test.tsx index d05f44e68a7..dc56987af6d 100644 --- a/packages/eui/src/components/provider/system_color_mode/system_color_mode_provider.server.test.tsx +++ b/packages/eui/src/components/provider/system_defaults/system_defaults_provider.server.test.tsx @@ -13,17 +13,16 @@ import React from 'react'; import { renderToString } from 'react-dom/server'; -import { EuiSystemColorModeProvider } from './system_color_mode_provider'; +import { EuiSystemDefaultsProvider } from './system_defaults_provider'; -describe('EuiSystemColorModeProvider', () => { +describe('EuiSystemDefaultsProvider', () => { it('handles server-side rendering without crashing', () => { - const children = jest.fn(() => <>Test); - const renderOnServer = () => - renderToString(); + renderToString( + Test + ); expect(renderOnServer).not.toThrow(); expect(renderOnServer()).toEqual('Test'); - expect(children).toHaveBeenCalledWith('LIGHT'); }); }); diff --git a/packages/eui/src/components/provider/system_defaults/system_defaults_provider.test.tsx b/packages/eui/src/components/provider/system_defaults/system_defaults_provider.test.tsx new file mode 100644 index 00000000000..d57c37d7855 --- /dev/null +++ b/packages/eui/src/components/provider/system_defaults/system_defaults_provider.test.tsx @@ -0,0 +1,101 @@ +/* + * 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 } from '@testing-library/react'; + +import { useEuiTheme } from '../../../services'; + +jest.mock('./match_media_hook', () => ({ + useWindowMediaMatcher: jest.fn(), +})); +import { useWindowMediaMatcher } from './match_media_hook'; + +import { EuiSystemDefaultsProvider } from './system_defaults_provider'; + +describe('EuiSystemDefaultsProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('color mode', () => { + const Output = () => { + const { colorMode } = useEuiTheme(); + return <>{colorMode}; + }; + + it('falls back to light mode if no dark mode media query has been set', () => { + const { container } = render( + + + + ); + + expect(container.textContent).toEqual('LIGHT'); + }); + + it('detects dark mode system settings', () => { + (useWindowMediaMatcher as jest.Mock).mockImplementation((media) => { + if (media === '(prefers-color-scheme: dark)') return true; + }); + + const { container } = render( + + + + ); + + expect(container.textContent).toEqual('DARK'); + }); + }); + + describe('high contrast mode', () => { + const Output = () => { + const { highContrastMode } = useEuiTheme(); + return <>{String(highContrastMode)}; + }; + + it('returns `false` if no contrast-related media query has been set', () => { + const { container } = render( + + + + ); + + expect(container.textContent).toEqual('false'); + }); + + it('returns `preferred` for MacOS high contrast mode', () => { + (useWindowMediaMatcher as jest.Mock).mockImplementation((media) => { + if (media === '(prefers-contrast: more)') return true; + }); + + const { container } = render( + + + + ); + + expect(container.textContent).toEqual('preferred'); + }); + + it('returns `forced` for Windows high contrast mode', () => { + (useWindowMediaMatcher as jest.Mock).mockImplementation((media) => { + if (media === '(forced-colors: active)') return true; + }); + + const { container } = render( + + + + ); + + expect(container.textContent).toEqual('forced'); + }); + }); +}); diff --git a/packages/eui/src/components/provider/system_defaults/system_defaults_provider.tsx b/packages/eui/src/components/provider/system_defaults/system_defaults_provider.tsx new file mode 100644 index 00000000000..db9a81a18d0 --- /dev/null +++ b/packages/eui/src/components/provider/system_defaults/system_defaults_provider.tsx @@ -0,0 +1,44 @@ +/* + * 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, PropsWithChildren } from 'react'; +import { + EuiColorModeContext, + EuiHighContrastModeContext, +} from '../../../services'; + +import { useWindowMediaMatcher } from './match_media_hook'; + +export const EuiSystemDefaultsProvider: FunctionComponent< + PropsWithChildren +> = ({ children }) => { + // @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme + const systemColorMode = useWindowMediaMatcher('(prefers-color-scheme: dark)') + ? 'DARK' + : 'LIGHT'; + + // There are different types of high contrast modes based on system/OS settings. @see: + // - https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-contrast + // - https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors + // - https://kilianvalkhof.com/2023/css-html/i-no-longer-understand-prefers-contrast/ + const windowsHighContrast = useWindowMediaMatcher('(forced-colors: active)'); + const macHighContrast = useWindowMediaMatcher('(prefers-contrast: more)'); + const systemHighContrastMode = windowsHighContrast + ? 'forced' + : macHighContrast + ? 'preferred' + : false; + + return ( + + + {children} + + + ); +}; diff --git a/packages/eui/src/services/theme/context.ts b/packages/eui/src/services/theme/context.ts index d366a2ffba8..c2a10167bf5 100644 --- a/packages/eui/src/services/theme/context.ts +++ b/packages/eui/src/services/theme/context.ts @@ -9,6 +9,7 @@ import { createContext } from 'react'; import { EuiThemeColorModeStandard, + EuiThemeHighContrastMode, EuiThemeSystem, EuiThemeModifications, EuiThemeComputed, @@ -17,15 +18,27 @@ import { import { EuiThemeAmsterdam } from '../../themes/amsterdam/theme'; import { DEFAULT_COLOR_MODE, getComputed } from './utils'; -export const EuiSystemContext = - createContext(EuiThemeAmsterdam); -export const EuiModificationsContext = createContext({}); -export const EuiColorModeContext = - createContext(DEFAULT_COLOR_MODE); +export const DEFAULTS = { + system: EuiThemeAmsterdam, + modifications: {}, + colorMode: DEFAULT_COLOR_MODE, + highContrastMode: false as const, +}; + +export const EuiSystemContext = createContext(DEFAULTS.system); +export const EuiModificationsContext = createContext( + DEFAULTS.modifications +); +export const EuiColorModeContext = createContext( + DEFAULTS.colorMode +); +export const EuiHighContrastModeContext = + createContext(DEFAULTS.highContrastMode); + export const defaultComputedTheme = getComputed( - EuiThemeAmsterdam, - {}, - DEFAULT_COLOR_MODE + DEFAULTS.system, + DEFAULTS.modifications, + DEFAULTS.colorMode ); export const EuiThemeContext = createContext(defaultComputedTheme); diff --git a/packages/eui/src/services/theme/high_contrast_overrides.test.tsx b/packages/eui/src/services/theme/high_contrast_overrides.test.tsx new file mode 100644 index 00000000000..dad334e3739 --- /dev/null +++ b/packages/eui/src/services/theme/high_contrast_overrides.test.tsx @@ -0,0 +1,93 @@ +/* + * 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 } from '../../test/rtl'; + +import { EuiThemeProvider } from './provider'; + +describe('high contrast mode modification overrides', () => { + it('overrides the theme border color', () => { + const { getByText } = render( + +

({ border: euiTheme.border.thin })}> + High contrast light mode + +
({ border: euiTheme.border.thin })}> + High contrast dark mode +
+
+ +
({ border: euiTheme.border.thin })}> + Not high contrast mode +
+
+
+ + ); + + expect(getByText('High contrast light mode')).toHaveStyleRule( + 'border', + '1px solid #000' + ); + expect(getByText('High contrast dark mode')).toHaveStyleRule( + 'border', + '1px solid #FFF' + ); + expect(getByText('Not high contrast mode')).toHaveStyleRule( + 'border', + '1px solid #D3DAE6' + ); + }); + + it('overrides consumer border color modifications', () => { + const modify = { + colors: { + LIGHT: { border: '#aaa' }, + DARK: { border: '#333' }, + }, + }; + const { getByText } = render( + +
({ borderColor: euiTheme.border.color })}> + High contrast mode +
+
+ ); + + expect(getByText('High contrast mode')).toHaveStyleRule( + 'border-color', + '#000' + ); + }); + + it('preserves modified border widths', () => { + const { getByText } = render( + +
({ border: euiTheme.border.thin })}> + Thin border +
+
({ border: euiTheme.border.thick })}> + Thick border +
+
+ ); + + expect(getByText('Thin border')).toHaveStyleRule( + 'border', + '5px solid #000' + ); + expect(getByText('Thick border')).toHaveStyleRule( + 'border', + '10px solid #000' + ); + }); +}); diff --git a/packages/eui/src/services/theme/high_contrast_overrides.ts b/packages/eui/src/services/theme/high_contrast_overrides.ts new file mode 100644 index 00000000000..01e30f6205c --- /dev/null +++ b/packages/eui/src/services/theme/high_contrast_overrides.ts @@ -0,0 +1,53 @@ +/* + * 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 { useMemo } from 'react'; + +import type { + EuiThemeHighContrastMode, + EuiThemeColorModeStandard, + EuiThemeSystem, + EuiThemeModifications, +} from './types'; + +// Rather than being calculated when the theme's styles are being computed, we're bogarting the +// `modify` logic so we can ensure consumer modifications to border-color are also overriden. +// If in the future we need more complex high contrast mode logic (e.g. changing color tokens) +// we'll need to actually dive into theme/utils.ts's Computed.getValue logic at that point. +export const useHighContrastModifications = ({ + highContrastMode, + colorMode, + system, + modifications, +}: { + highContrastMode: EuiThemeHighContrastMode; + colorMode: EuiThemeColorModeStandard; + system: EuiThemeSystem; + modifications: EuiThemeModifications; +}) => { + const highContrastModifications = useMemo(() => { + const borderColor = system.root.colors[colorMode].fullShade; + const getBorderWidth = (width: 'thin' | 'thick') => + modifications?.border?.width?.[width] || system.root.border.width[width]; + + return { + border: { + color: borderColor, + thin: `${getBorderWidth('thin')} solid ${borderColor}`, + thick: `${getBorderWidth('thick')} solid ${borderColor}`, + }, + }; + }, [system, colorMode, modifications?.border?.width]); + + // Memoizing the object(s) returned is important for performance/referential equality + return useMemo(() => { + return highContrastMode + ? { ...modifications, ...highContrastModifications } + : modifications; + }, [highContrastMode, modifications, highContrastModifications]); +}; diff --git a/packages/eui/src/services/theme/hooks.test.tsx b/packages/eui/src/services/theme/hooks.test.tsx index d2cb442fe06..f483565ab7e 100644 --- a/packages/eui/src/services/theme/hooks.test.tsx +++ b/packages/eui/src/services/theme/hooks.test.tsx @@ -27,6 +27,7 @@ describe('useEuiTheme', () => { expect(result.current).toEqual({ euiTheme: expect.any(Object), colorMode: 'LIGHT', + highContrastMode: false, modifications: {}, }); }); @@ -68,7 +69,7 @@ describe('withEuiTheme', () => { it('provides underlying class components with a `theme` prop', () => { const { container } = render(); expect(container.firstChild!.textContent).toEqual( - 'euiTheme,colorMode,modifications' + 'euiTheme,colorMode,highContrastMode,modifications' ); }); }); @@ -81,7 +82,7 @@ describe('RenderWithEuiTheme', () => { ); expect(container.firstChild!.textContent).toEqual( - 'euiTheme,colorMode,modifications' + 'euiTheme,colorMode,highContrastMode,modifications' ); }); }); diff --git a/packages/eui/src/services/theme/hooks.tsx b/packages/eui/src/services/theme/hooks.tsx index bf87146612e..a4e71035ea6 100644 --- a/packages/eui/src/services/theme/hooks.tsx +++ b/packages/eui/src/services/theme/hooks.tsx @@ -12,12 +12,14 @@ import { EuiThemeContext, EuiModificationsContext, EuiColorModeContext, + EuiHighContrastModeContext, defaultComputedTheme, EuiNestedThemeContext, } from './context'; import { emitEuiProviderWarning } from './warning'; import { EuiThemeColorModeStandard, + EuiThemeHighContrastMode, EuiThemeModifications, EuiThemeComputed, } from './types'; @@ -31,12 +33,14 @@ Wrap your component in \`EuiProvider\`: https://ela.st/euiprovider.`; export interface UseEuiTheme { euiTheme: EuiThemeComputed; colorMode: EuiThemeColorModeStandard; + highContrastMode: EuiThemeHighContrastMode; modifications: EuiThemeModifications; } export const useEuiTheme = (): UseEuiTheme => { const theme = useContext(EuiThemeContext); const colorMode = useContext(EuiColorModeContext); + const highContrastMode = useContext(EuiHighContrastModeContext); const modifications = useContext(EuiModificationsContext); const isFallback = theme === defaultComputedTheme; @@ -48,9 +52,10 @@ export const useEuiTheme = (): UseEuiTheme => { () => ({ euiTheme: theme as EuiThemeComputed, colorMode, + highContrastMode, modifications: modifications as EuiThemeModifications, }), - [theme, colorMode, modifications] + [theme, colorMode, highContrastMode, modifications] ); return assembledTheme; diff --git a/packages/eui/src/services/theme/index.ts b/packages/eui/src/services/theme/index.ts index bed57027402..841947db4c8 100644 --- a/packages/eui/src/services/theme/index.ts +++ b/packages/eui/src/services/theme/index.ts @@ -12,6 +12,7 @@ export { EuiNestedThemeContext, EuiModificationsContext, EuiColorModeContext, + EuiHighContrastModeContext, } from './context'; export type { UseEuiTheme, WithEuiThemeProps } from './hooks'; export { @@ -44,6 +45,8 @@ export type { ComputedThemeShape, EuiThemeColorMode, EuiThemeColorModeStandard, + EuiThemeHighContrastMode, + EuiThemeHighContrastModeProp, EuiThemeComputed, EuiThemeModifications, EuiThemeShape, diff --git a/packages/eui/src/services/theme/provider.stories.tsx b/packages/eui/src/services/theme/provider.stories.tsx index 7ce87e1b4d8..63c8a20f6aa 100644 --- a/packages/eui/src/services/theme/provider.stories.tsx +++ b/packages/eui/src/services/theme/provider.stories.tsx @@ -9,6 +9,8 @@ import React, { FunctionComponent, useEffect } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import { EuiPanel } from '../../components/panel'; + import { useEuiThemeCSSVariables } from './hooks'; import { EuiThemeProvider, EuiThemeProviderProps } from './provider'; @@ -21,6 +23,9 @@ export default meta; type Story = StoryObj>; export const WrapperCloneElement: Story = { + parameters: { + loki: { skip: true }, + }, args: { wrapperProps: { cloneElement: true, @@ -101,3 +106,23 @@ const MockComponent: FunctionComponent<{

); }; + +/** + * VRT only stories + */ + +export const DarkMode: Story = { + tags: ['vrt-only'], + args: { + colorMode: 'dark', + children: Dark mode, + }, +}; + +export const HighContrastMode: Story = { + tags: ['vrt-only'], + args: { + highContrastMode: true, + children: High contrast mode, + }, +}; diff --git a/packages/eui/src/services/theme/provider.test.tsx b/packages/eui/src/services/theme/provider.test.tsx index 539016bca0a..6637ec645b0 100644 --- a/packages/eui/src/services/theme/provider.test.tsx +++ b/packages/eui/src/services/theme/provider.test.tsx @@ -11,8 +11,17 @@ import { render } from '@testing-library/react'; // Note - don't use the EUI cus import { css } from '@emotion/react'; import { EuiProvider } from '../../components/provider'; +import { + EuiSystemDefaultsProvider, + useWindowMediaMatcher, +} from '../../components/provider/system_defaults'; +jest.mock('../../components/provider/system_defaults/match_media_hook', () => ({ + useWindowMediaMatcher: jest.fn(), +})); import { useCurrentEuiBreakpoint } from '../breakpoint'; import { EuiNestedThemeContext } from './context'; +import { useEuiTheme } from './hooks'; + import { EuiThemeProvider } from './provider'; describe('EuiThemeProvider', () => { @@ -60,6 +69,75 @@ describe('EuiThemeProvider', () => { }); }); + describe('highContrastMode', () => { + const Output = () => { + const { highContrastMode } = useEuiTheme(); + return <>{String(highContrastMode)}; + }; + + afterEach(() => { + (useWindowMediaMatcher as jest.Mock).mockReset(); + }); + + it('always sets the contrast mode to forced if inherited from the system, overriding any application prop', () => { + (useWindowMediaMatcher as jest.Mock).mockImplementation((media) => { + if (media === '(forced-colors: active)') return true; + }); + + const { container } = render( + + + + + + ); + + expect(container.textContent).toEqual('forced'); + }); + + it("converts true to 'preferred'", () => { + const { container } = render( + + + + + + ); + + expect(container.textContent).toEqual('preferred'); + }); + + it('leaves false as `false`', () => { + const { container } = render( + + + + + + ); + + expect(container.textContent).toEqual('false'); + }); + + it('falls back to the system/parent contrast mode if not specified', () => { + (useWindowMediaMatcher as jest.Mock).mockImplementation((media) => { + if (media === '(prefers-contrast: more)') return true; + }); + + const { container } = render( + + + + + + ); + + expect(container.textContent).toEqual('preferred'); + }); + + // see high_contrast_overrides.test.tsx for tests that output styles are correctly overridden + }); + describe('modify', () => { it('allows overriding theme tokens', () => { const { getByText } = render( diff --git a/packages/eui/src/services/theme/provider.tsx b/packages/eui/src/services/theme/provider.tsx index c5a6957e8c3..2cf12cb62c3 100644 --- a/packages/eui/src/services/theme/provider.tsx +++ b/packages/eui/src/services/theme/provider.tsx @@ -31,13 +31,18 @@ import { EuiNestedThemeContext, EuiModificationsContext, EuiColorModeContext, + EuiHighContrastModeContext, + DEFAULTS, } from './context'; import { EuiEmotionThemeProvider } from './emotion'; import { EuiThemeMemoizedStylesProvider } from './style_memoization'; +import { useHighContrastModifications } from './high_contrast_overrides'; import { buildTheme, getColorMode, getComputed, mergeDeep } from './utils'; import { EuiThemeColorMode, EuiThemeColorModeStandard, + EuiThemeHighContrastModeProp, + EuiThemeHighContrastMode, EuiThemeSystem, EuiThemeModifications, } from './types'; @@ -45,6 +50,7 @@ import { export interface EuiThemeProviderProps extends PropsWithChildren { theme?: EuiThemeSystem; colorMode?: EuiThemeColorMode; + highContrastMode?: EuiThemeHighContrastModeProp; modify?: EuiThemeModifications; children: any; /** @@ -64,6 +70,7 @@ export interface EuiThemeProviderProps extends PropsWithChildren { export const EuiThemeProvider = ({ theme: _system, colorMode: _colorMode, + highContrastMode: _highContrastMode, modify: _modifications, children, wrapperProps, @@ -77,6 +84,7 @@ export const EuiThemeProvider = ({ const parentSystem = useContext(EuiSystemContext); const parentModifications = useContext(EuiModificationsContext); const parentColorMode = useContext(EuiColorModeContext); + const parentHighContrastMode = useContext(EuiHighContrastModeContext); const parentTheme = useContext(EuiThemeContext); const [system, setSystem] = useState(_system || parentSystem); @@ -101,10 +109,31 @@ export const EuiThemeProvider = ({ ); const prevColorMode = useRef(colorMode); + const highContrastMode: EuiThemeHighContrastMode = useMemo(() => { + if (parentHighContrastMode === 'forced') return 'forced'; // System forced high contrast mode will always supercede application settings + if (_highContrastMode === true) return 'preferred'; // Convert the boolean prop to our internal enum + if (_highContrastMode === false) return false; // Allow `false` prop to override user/system preference + return parentHighContrastMode; // Fall back to the parent/system setting + }, [_highContrastMode, parentHighContrastMode]); + const prevHighContrastMode = useRef(highContrastMode); + + const modificationsWithHighContrast = useHighContrastModifications({ + highContrastMode, + colorMode, + system, + modifications, + }); + const isParentTheme = useRef( - prevSystemKey.current === parentSystem.key && - colorMode === parentColorMode && - isEqual(parentModifications, modifications) + isGlobalTheme + ? prevSystemKey.current === DEFAULTS.system.key && + colorMode === DEFAULTS.colorMode && + highContrastMode === DEFAULTS.highContrastMode && + !_modifications + : prevSystemKey.current === parentSystem.key && + colorMode === parentColorMode && + highContrastMode === parentHighContrastMode && + isEqual(parentModifications, modifications) ); const [theme, setTheme] = useState( @@ -112,7 +141,7 @@ export const EuiThemeProvider = ({ ? { ...parentTheme } // Intentionally create a new object to break referential equality : getComputed( system, - buildTheme(modifications, `_${system.key}`) as typeof system, + buildTheme(modificationsWithHighContrast, `_${system.key}`), colorMode ) ); @@ -144,17 +173,23 @@ export const EuiThemeProvider = ({ } }, [_colorMode, parentColorMode]); + useEffect(() => { + if (prevHighContrastMode.current !== highContrastMode) { + isParentTheme.current = false; + } + }, [highContrastMode]); + useEffect(() => { if (!isParentTheme.current) { setTheme( getComputed( system, - buildTheme(modifications, `_${system.key}`) as typeof system, + buildTheme(modificationsWithHighContrast, `_${system.key}`), colorMode ) ); } - }, [colorMode, system, modifications]); + }, [colorMode, system, modificationsWithHighContrast]); const [themeCSSVariables, _setThemeCSSVariables] = useState(); const setThemeCSSVariables = useCallback( @@ -237,21 +272,23 @@ export const EuiThemeProvider = ({ )} - - - - - - - - {renderedChildren} - - - - - - - + + + + + + + + + {renderedChildren} + + + + + + + + ); diff --git a/packages/eui/src/services/theme/types.ts b/packages/eui/src/services/theme/types.ts index 04b93b40f20..df6242023e7 100644 --- a/packages/eui/src/services/theme/types.ts +++ b/packages/eui/src/services/theme/types.ts @@ -46,6 +46,12 @@ export type StrictColorModeSwitch = { [key in EuiThemeColorModeStandard]: T; }; +// Consumers can pass a boolean to manually toggle the preferred high contrast mode, +// but our internal high contrast mode enum is slightly more granular to account for +// Windows's high contrast themes, which force colors/backgrounds/shadows +export type EuiThemeHighContrastModeProp = boolean; +export type EuiThemeHighContrastMode = 'forced' | 'preferred' | false; + export type EuiThemeShape = { colors: _EuiThemeColors; /** - Default value: 16 */ diff --git a/packages/eui/src/themes/amsterdam/global_styling/mixins/shadow.ts b/packages/eui/src/themes/amsterdam/global_styling/mixins/shadow.ts index 21a2360c96e..f183e9423ce 100644 --- a/packages/eui/src/themes/amsterdam/global_styling/mixins/shadow.ts +++ b/packages/eui/src/themes/amsterdam/global_styling/mixins/shadow.ts @@ -8,6 +8,7 @@ import { useEuiTheme, UseEuiTheme } from '../../../../services/theme'; import { getShadowColor } from '../functions'; +import { logicalCSS } from '../../../../global_styling'; import { _EuiThemeShadowSize, _EuiThemeShadowCustomColor, @@ -21,10 +22,15 @@ export interface EuiShadowCustomColor { * euiSlightShadow */ export const euiShadowXSmall = ( - { euiTheme, colorMode }: UseEuiTheme, - { color: _color }: _EuiThemeShadowCustomColor = {} + { euiTheme, colorMode, highContrastMode }: UseEuiTheme, + options?: _EuiThemeShadowCustomColor ) => { - const color = _color || euiTheme.colors.shadow; + if (highContrastMode) { + return _highContrastBorderBottom(euiTheme); + } + + const color = options?.color || euiTheme.colors.shadow; + return ` box-shadow: 0 .8px .8px ${getShadowColor(color, 0.04, colorMode)}, @@ -36,10 +42,15 @@ box-shadow: * bottomShadowSmall */ export const euiShadowSmall = ( - { euiTheme, colorMode }: UseEuiTheme, - { color: _color }: _EuiThemeShadowCustomColor = {} + { euiTheme, colorMode, highContrastMode }: UseEuiTheme, + options?: _EuiThemeShadowCustomColor ) => { - const color = _color || euiTheme.colors.shadow; + if (highContrastMode) { + return _highContrastBorderBottom(euiTheme); + } + + const color = options?.color || euiTheme.colors.shadow; + return ` box-shadow: 0 .7px 1.4px ${getShadowColor(color, 0.07, colorMode)}, @@ -52,12 +63,16 @@ box-shadow: * bottomShadowMedium */ export const euiShadowMedium = ( - { euiTheme, colorMode }: UseEuiTheme, - { color: _color, property }: _EuiThemeShadowCustomColor = {} + { euiTheme, colorMode, highContrastMode }: UseEuiTheme, + options?: _EuiThemeShadowCustomColor ) => { - const color = _color || euiTheme.colors.shadow; + if (highContrastMode) { + return _highContrastBorderBottom(euiTheme); + } - if (property === 'filter') { + const color = options?.color || euiTheme.colors.shadow; + + if (options?.property === 'filter') { // Using only one drop-shadow filter instead of multiple is more performant & prevents Safari bugs return `filter: drop-shadow(0 5.7px 9px ${getShadowColor( color, @@ -77,10 +92,15 @@ export const euiShadowMedium = ( * bottomShadow */ export const euiShadowLarge = ( - { euiTheme, colorMode }: UseEuiTheme, - { color: _color }: _EuiThemeShadowCustomColor = {} + { euiTheme, colorMode, highContrastMode }: UseEuiTheme, + options?: _EuiThemeShadowCustomColor ) => { - const color = _color || euiTheme.colors.shadow; + if (highContrastMode) { + return _highContrastBorderBottom(euiTheme); + } + + const color = options?.color || euiTheme.colors.shadow; + return ` box-shadow: 0 1px 5px ${getShadowColor(color, 0.1, colorMode)}, @@ -97,10 +117,17 @@ export interface EuiShadowXLarge extends _EuiThemeShadowCustomColor { reverse?: boolean; } export const euiShadowXLarge = ( - { euiTheme, colorMode }: UseEuiTheme, - { color: _color, reverse }: EuiShadowXLarge = {} + { euiTheme, colorMode, highContrastMode }: UseEuiTheme, + options?: EuiShadowXLarge ) => { - const color = _color || euiTheme.colors.shadow; + if (highContrastMode) { + return _highContrastBorderBottom(euiTheme); + } + + const color = options?.color || euiTheme.colors.shadow; + + const reverse = options?.reverse ?? false; + return ` box-shadow: 0 ${reverse ? '-' : ''}2.7px 9px ${getShadowColor(color, 0.13, colorMode)}, @@ -113,10 +140,15 @@ box-shadow: * slightShadowHover */ export const euiSlightShadowHover = ( - { euiTheme, colorMode }: UseEuiTheme, - { color: _color }: _EuiThemeShadowCustomColor = {} + { euiTheme, colorMode, highContrastMode }: UseEuiTheme, + options?: _EuiThemeShadowCustomColor ) => { - const color = _color || euiTheme.colors.shadow; + if (highContrastMode) { + return _highContrastBorderBottom(euiTheme); + } + + const color = options?.color || euiTheme.colors.shadow; + return ` box-shadow: 0 1px 5px ${getShadowColor(color, 0.1, colorMode)}, @@ -139,10 +171,15 @@ export const useEuiSlightShadowHover = ( * Useful for popovers that drop UP rather than DOWN. */ export const euiShadowFlat = ( - { euiTheme, colorMode }: UseEuiTheme, - { color: _color }: _EuiThemeShadowCustomColor = {} + { euiTheme, colorMode, highContrastMode }: UseEuiTheme, + options?: _EuiThemeShadowCustomColor ) => { - const color = _color || euiTheme.colors.shadow; + if (highContrastMode) { + return _highContrastBorderBottom(euiTheme); + } + + const color = options?.color || euiTheme.colors.shadow; + return ` box-shadow: 0 0 .8px ${getShadowColor(color, 0.06, colorMode)}, @@ -161,19 +198,23 @@ export const useEuiShadowFlat = ( export const euiShadow = ( euiThemeContext: UseEuiTheme, size: _EuiThemeShadowSize = 'l', - { color }: _EuiThemeShadowCustomColor = {} + options?: _EuiThemeShadowCustomColor ) => { + if (euiThemeContext.highContrastMode) { + return _highContrastBorderBottom(euiThemeContext.euiTheme); + } + switch (size) { case 'xs': - return euiShadowXSmall(euiThemeContext, { color }); + return euiShadowXSmall(euiThemeContext, options); case 's': - return euiShadowSmall(euiThemeContext, { color }); + return euiShadowSmall(euiThemeContext, options); case 'm': - return euiShadowMedium(euiThemeContext, { color }); + return euiShadowMedium(euiThemeContext, options); case 'l': - return euiShadowLarge(euiThemeContext, { color }); + return euiShadowLarge(euiThemeContext, options); case 'xl': - return euiShadowXLarge(euiThemeContext, { color }); + return euiShadowXLarge(euiThemeContext, options); default: console.warn('Please provide a valid size option to useEuiShadow'); @@ -188,3 +229,12 @@ export const useEuiShadow = ( const euiThemeContext = useEuiTheme(); return euiShadow(euiThemeContext, size, { color }); }; + +/** + * Internal utilities for replacing shadows with high contrast borders instead. + * NOTE: Windows' high contrast themes ignore *all* `box-shadow` CSS, + * so we use `border` CSS explicitly instead of shadows + */ + +const _highContrastBorderBottom = ({ border }: UseEuiTheme['euiTheme']) => + logicalCSS('border-bottom', border.thin); diff --git a/packages/eui/src/themes/themes.ts b/packages/eui/src/themes/themes.ts index d2f714092df..8c51afb94a4 100644 --- a/packages/eui/src/themes/themes.ts +++ b/packages/eui/src/themes/themes.ts @@ -17,13 +17,8 @@ export interface EUI_THEME { export const EUI_THEMES: EUI_THEME[] = [ { - text: 'Light', - value: 'light', - provider: EuiThemeAmsterdam, - }, - { - text: 'Dark', - value: 'dark', + text: 'Amsterdam', + value: AMSTERDAM_NAME_KEY, provider: EuiThemeAmsterdam, }, ]; diff --git a/packages/website/docs/components/theming/color_mode.mdx b/packages/website/docs/components/theming/color_mode.mdx index d21612d4fe8..a29a9864a73 100644 --- a/packages/website/docs/components/theming/color_mode.mdx +++ b/packages/website/docs/components/theming/color_mode.mdx @@ -8,9 +8,11 @@ id: theming_color_mode The `colorMode` determines which values to return based on `LIGHT` or `DARK` mode. +By default, if this prop is not passed, **EuiProvider** will detect and use the user's system dark mode preference. If the prop _is_ passed, it will override the user's system settings. + import { ProviderDetails } from './provider_details'; - + ## Rendering a specific color mode diff --git a/packages/website/docs/components/theming/high_contrast_mode.mdx b/packages/website/docs/components/theming/high_contrast_mode.mdx new file mode 100644 index 00000000000..1251beeaad7 --- /dev/null +++ b/packages/website/docs/components/theming/high_contrast_mode.mdx @@ -0,0 +1,101 @@ +--- +sidebar_position: 2 +slug: /theming/high-contrast-mode +id: theming_high_contrast_mode +title: High contrast mode +--- + +import { EuiBetaBadge } from '@elastic/eui'; + +# High contrast mode + +The `highContrastMode` determines and sets certain un-overrideable modifications to the EUI theme, primarily around borders and shadows. Borders will always be pure black or white (depending on the color mode), and shadows will be entirely replaced with borders. + +By default, if this prop is not passed, **EuiProvider** will detect and use the user's system contrast preferences. + +## Rendering a specific contrast mode + +While it's usually best to keep all high contrast mode the same across your app for visual consistency, some instances may benefit from an exaggerated change in contrast. For this you can set **EuiThemeProvider**'s `highContrastMode` to `true`. + +:::warning +In general, we do not ever recommend manually turning off high contrast via `highContrastMode={false}`. Respect the user's contrast preferences where possible. +::: + +```tsx interactive +import React from 'react'; +import { + EuiThemeProvider, + EuiBasicTable, + EuiBasicTableColumn, +} from '@elastic/eui'; +import { faker } from '@faker-js/faker'; + +type User = { + firstName: string; + lastName: string; +}; + +const users: User[] = []; +for (let i = 0; i < 5; i++) { + users.push({ + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }); +} + +const columns: Array> = [ + { + field: 'firstName', + name: 'First name', + }, + { + field: 'lastName', + name: 'Last name', + } +]; + +export default () => { + return ( + + + + ); +}; +``` + +## Forced contrast themes and colors + +Please note that some OSes and browsers have something called [forced colors mode](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors), which overrides **all** colors, backgrounds, borders, and shadows. An example of this is Windows High Contrast modes. + +Since this is done at a level that EUI can do nothing about, if forced colors mode is detected by **EuiProvider**, EUI will ignore *any* passed `highContrastMode` prop, as this user choice and system setting takes precedence. + +:::tip +To quickly test your application in forced colors mode without switching OS themes, you can [use Chrome or Edge's devtools to emulate forced-colors mode](https://devtoolstips.org/tips/en/emulate-forced-colors/). +::: + +## Reacting to user high contrast modes + +The detected or current `highContrastMode` is available via `useEuiTheme()`. It returns either `"forced"`, `"preferred"`, or simply `false`. You can use this information to (for example) conditionally render or opt out of rendering certain styles or colors. + +```tsx interactive +import React from 'react'; +import { useEuiTheme, EuiPanel } from '@elastic/eui'; + +export default () => { + const { highContrastMode, euiTheme } = useEuiTheme(); + + return ( + + This panel will have a thick border in high contrast mode. + + ); +}; +``` diff --git a/packages/website/docs/components/theming/provider_details.tsx b/packages/website/docs/components/theming/provider_details.tsx index d1d39202a16..95fdd990513 100644 --- a/packages/website/docs/components/theming/provider_details.tsx +++ b/packages/website/docs/components/theming/provider_details.tsx @@ -11,10 +11,16 @@ import { interface ProviderDetailsProps { withThemeName?: boolean; + withColorMode?: boolean; + withHighContrastMode?: boolean; } -export const ProviderDetails = ({ withThemeName = true }: ProviderDetailsProps) => { - const { euiTheme, colorMode } = useEuiTheme(); +export const ProviderDetails = ({ + withThemeName = true, + withColorMode = true, + withHighContrastMode = true, +}: ProviderDetailsProps) => { + const { euiTheme, colorMode, highContrastMode } = useEuiTheme(); return ( @@ -47,18 +53,22 @@ export const ProviderDetails = ({ withThemeName = true }: ProviderDetailsProps)
)} - - - colorMode: - - - - -

+ {withColorMode && ( + + + colorMode: {colorMode} -

-
-
+ + + )} + {withHighContrastMode && ( + + + highContrastMode: + {String(highContrastMode)} + + + )} ); diff --git a/packages/website/docs/components/theming/theme_provider.mdx b/packages/website/docs/components/theming/theme_provider.mdx index 489bb118202..ac61c8facb3 100644 --- a/packages/website/docs/components/theming/theme_provider.mdx +++ b/packages/website/docs/components/theming/theme_provider.mdx @@ -10,13 +10,18 @@ While [**EuiProvider**](#utilities/provider) should not be wrapped around your a ## EuiThemeProvider -The context layer that enables theming (including the default theme styles) comes from `EuiThemeProvider`. `EuiThemeProvider` accepts three props, all of which have default values and are therefore optional. To use the default EUI theme, no configuration is required. +The context layer that enables theming (including the default theme styles) comes from `EuiThemeProvider`. `EuiThemeProvider` accepts four main props (all of which have default values and are therefore optional): -* `theme: EuiThemeSystem` Raw theme values. Calculated values are acceptable. -* `colorMode: EuiThemeColorMode` Simply 'light' or 'dark' -* `modify: EuiThemeModifications` Overrides and modifications for theme values. +* `theme:` Raw theme values. Calculated values are acceptable. + * For the full shape of an EUI theme, see the [global values](/docs/theming/customizing-themes) page. +* `modify:` Accepts an object of overrides for theme values. + * For examples of this prop, see [Simple instance overrides](#simple-instance-overrides) below. +* `colorMode:` Accepts 'light', 'dark', or 'inverse'. + * For usage, see the [Color mode](/docs/theming/color-mode) page. +* `highContrastMode`: Accepts a true/false boolean. + * For usage, see the [High contrast mode](/docs/theming/high-contrast-mode) page. -The concept for each prop is explained in subsequent sections. More information on the full shape of an EUI theme, see the [Global Values](/docs/theming/customizing-themes) page. + To use the default EUI theme, no configuration is required. import { ProviderDetails } from './provider_details'; @@ -24,11 +29,12 @@ import { ProviderDetails } from './provider_details'; ## Consuming with the React hook -Using the react hook **useEuiTheme()** makes it very easy to consume the EUI static and computed variables like colors and sizing. It simply passes back an object of the current theme which includes +Using the React hook `useEuiTheme()` makes it very easy to consume the EUI static and computed variables like colors and sizing. It simply passes back an object of the current theme which includes: -* `euiTheme: EuiThemeComputed` All the calculated keys including any modifications -* `colorMode: EuiThemeColorMode` Simply 'light' or 'dark' -* `modifications: EuiThemeModifications` Only the modification keys +* `euiTheme:` All the calculated keys including any modifications +* `modifications:` Only the modification keys +* `colorMode:` Either 'LIGHT' or 'DARK' +* `highContrastMode:` Either 'forced', 'preferred', or `false` When consuming the theme's keys like `euiTheme.colors.primary`, you'll want to pass them via the `css` property to take advantage of Emotion's compilation.