diff --git a/src-docs/src/views/emotion/canopy.tsx b/src-docs/src/views/emotion/canopy.tsx index 2b683e320c2..abec3dcdc0c 100644 --- a/src-docs/src/views/emotion/canopy.tsx +++ b/src-docs/src/views/emotion/canopy.tsx @@ -30,6 +30,7 @@ import { computed, euiThemeDefault, buildTheme, + EuiThemeModifications, } from '../../../../src/services'; const View = () => { @@ -76,7 +77,7 @@ const View3 = () => { const overrides = { colors: { light: { euiColorPrimary: '#8A07BD' }, - dark: { euiColorPrimary: '#bd07a5' }, + dark: { euiColorPrimary: '#BD07A5' }, }, }; return ( @@ -84,7 +85,7 @@ const View3 = () => { - + Overriding primary @@ -98,16 +99,16 @@ const View2 = () => { light: { euiColorSecondary: computed( ['colors.euiColorPrimary'], - () => '#85e89d' + () => '#85E89d' ), }, - dark: { euiColorSecondary: '#f0fff4' }, + dark: { euiColorSecondary: '#F0FFF4' }, }, }; return ( <> - + Overriding secondary @@ -176,15 +177,95 @@ export default () => { 'CUSTOM' ); + // Difference is due to automatic colorMode reduction during value computation. + // Makes typing slightly inconvenient, but makes consuming values very convenient. + type ExtensionsUncomputed = { + colors: { light: { myColor: string }; dark: { myColor: string } }; + custom: { + colors: { + light: { customColor: string }; + dark: { customColor: string }; + }; + mySize: number; + }; + }; + type ExtensionsComputed = { + colors: { myColor: string }; + custom: { colors: { customColor: string }; mySize: number }; + }; + + // Type (EuiThemeModifications) only necessary if you want IDE autocomplete support here + const extend: EuiThemeModifications = { + colors: { + light: { + euiColorPrimary: '#F56407', + myColor: computed(['colors.euiColorPrimary'], ([primary]) => primary), + }, + dark: { + euiColorPrimary: '#FA924F', + myColor: computed(['colors.euiColorPrimary'], ([primary]) => primary), + }, + }, + custom: { + colors: { + light: { customColor: '#080AEF' }, + dark: { customColor: '#087EEF' }, + }, + mySize: 5, + }, + }; + + const Extend = () => { + // Generic type (ExtensionsComputed) necessary if accessing extensions/custom properties + const [{ colors, custom }, colorMode] = useEuiTheme(); + return ( + + + {colorMode} + + {JSON.stringify({ colors, custom }, null, 2)} + + + + + + + + + + + + + + + ); + }; + return ( <> + modify={overrides}> - {/* @ts-ignore strike */} - Toggle Color Mode! Use global config + Toggle Color Mode! Use global config @@ -209,6 +290,11 @@ export default () => { + {/* Generic type is not necessary here. Note that it should be the uncomputed type */} + modify={extend}> + Extensions + + > ); }; diff --git a/src/components/common.ts b/src/components/common.ts index 639ac7fcbbd..c29ccf5382e 100644 --- a/src/components/common.ts +++ b/src/components/common.ts @@ -103,6 +103,15 @@ export type DistributivePick> = T extends any export type DistributiveOmit> = T extends any ? Omit> : never; +type RecursiveDistributiveOmit = T extends any + ? T extends object + ? RecursiveOmit + : T + : never; +export type RecursiveOmit = Omit< + { [P in keyof T]: RecursiveDistributiveOmit }, + K +>; /* TypeScript's discriminated unions are overly permissive: as long as one type of the union is satisfied @@ -221,8 +230,8 @@ export type RecursivePartial = { ? T[P] : T[P] extends Array ? Array> - : T[P] extends ReadonlyArray // eslint-disable-line @typescript-eslint/array-type - ? ReadonlyArray> // eslint-disable-line @typescript-eslint/array-type + : T[P] extends ReadonlyArray + ? ReadonlyArray> : T[P] extends Set // checks for Sets ? Set> : T[P] extends Map // checks for Maps diff --git a/src/services/index.ts b/src/services/index.ts index fc982597295..ae9aa24f62d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -121,7 +121,7 @@ export { useCombinedRefs, useDependentState } from './hooks'; export { EuiSystemContext, EuiThemeContext, - EuiOverrideContext, + EuiModificationsContext, EuiColorModeContext, useEuiTheme, withEuiTheme, @@ -142,7 +142,7 @@ export { EuiThemeColor, EuiThemeColorMode, EuiThemeComputed, - EuiThemeOverrides, + EuiThemeModifications, EuiThemeShape, EuiThemeSystem, } from './theme'; diff --git a/src/services/theme/README.md b/src/services/theme/README.md index ca88888a666..48e65de41f3 100644 --- a/src/services/theme/README.md +++ b/src/services/theme/README.md @@ -49,23 +49,14 @@ Returned from `getComputed`, in the shape of: ```js getComputed( EuiThemeDefault, // Theme system (Proxy) - {}, // Overrides object + {}, // Modifications object 'light' // Color mode ) ``` -#### Overrides +#### Modifications -Compute-time value overrides for theme property values. Because a theme system is unchangeable, this mechanism allows for changing values at certain points during consumption. -The overrides object must match the partial shape of the theme system: - -```js -{ - sizes: { - euiSize: 4 - } -} -``` +Because the theme system (built theme) is immutable, modifications can only be made at compute time by providing overrides and extensions for theme property values. These modifications are passed to the `EuiThemeProvider` via the `modify` prop and should match the high-level object shape of the theme. #### Color mode @@ -84,14 +75,14 @@ colors: { ### EuiThemeProvider -Umbrella provider component that holds the various top-level theme configuration option providers: theme system, color mode, overrides; as well as the primary output provider: computed theme. -The actual computation for computed theme values takes place at this level, where the three inputs are known (theme system, color mode, overrides) and the output (computed theme) can be cached for consumption. Input changes are captured and the output is recomputed. +Umbrella provider component that holds the various top-level theme configuration option providers: theme system, color mode, modifications; as well as the primary output provider: computed theme. +The actual computation for computed theme values takes place at this level, where the three inputs are known (theme system, color mode, modifications) and the output (computed theme) can be cached for consumption. Input changes are captured and the output is recomputed. ```js ``` @@ -99,10 +90,10 @@ All three props are optional. The default values for EUI will be used in the eve ### useEuiTheme -A custom React hook that is returns the computed theme. This hook it little more than a wrapper around the `useContext` hook, accessing three of the top-level providers: computed theme, color mode, and overrides. +A custom React hook that returns the computed theme. This hook is little more than a wrapper around the `useContext` hook, accessing three of the top-level providers: computed theme, color mode, and modifications. ```js -const [theme, colorMode, overrides] = useEuiTheme(); +const [theme, colorMode, modifications] = useEuiTheme(); ``` The `theme` variable has TypeScript support, which will result in IDE autocomplete availability. @@ -145,4 +136,4 @@ Snapshot testing ([as currently configured](https://emotion.sh/docs/testing#writ + } + } > -``` \ No newline at end of file +``` diff --git a/src/services/theme/context.ts b/src/services/theme/context.ts index 53746c38d56..f7f0a47aa54 100644 --- a/src/services/theme/context.ts +++ b/src/services/theme/context.ts @@ -21,14 +21,14 @@ import { createContext } from 'react'; import { EuiThemeColorMode, EuiThemeSystem, - EuiThemeOverrides, + EuiThemeModifications, EuiThemeComputed, } from './types'; import { EuiThemeDefault } from './theme'; import { DEFAULT_COLOR_MODE, getComputed } from './utils'; export const EuiSystemContext = createContext(EuiThemeDefault); -export const EuiOverrideContext = createContext({}); +export const EuiModificationsContext = createContext({}); export const EuiColorModeContext = createContext( DEFAULT_COLOR_MODE ); diff --git a/src/services/theme/hooks.tsx b/src/services/theme/hooks.tsx index ff8575d735a..ee7e8065349 100644 --- a/src/services/theme/hooks.tsx +++ b/src/services/theme/hooks.tsx @@ -21,28 +21,28 @@ import React, { forwardRef, useContext } from 'react'; import { EuiThemeContext, - EuiOverrideContext, + EuiModificationsContext, EuiColorModeContext, } from './context'; import { EuiThemeColorMode, - EuiThemeOverrides, + EuiThemeModifications, EuiThemeComputed, } from './types'; export const useEuiTheme = (): [ EuiThemeComputed, EuiThemeColorMode, - EuiThemeOverrides + EuiThemeModifications ] => { const theme = useContext(EuiThemeContext); - const overrides = useContext(EuiOverrideContext); + const modifications = useContext(EuiModificationsContext); const colorMode = useContext(EuiColorModeContext); return [ theme as EuiThemeComputed, colorMode, - overrides as EuiThemeOverrides, + modifications as EuiThemeModifications, ]; }; diff --git a/src/services/theme/index.ts b/src/services/theme/index.ts index e8960997d1c..58ada6c9dc6 100644 --- a/src/services/theme/index.ts +++ b/src/services/theme/index.ts @@ -20,7 +20,7 @@ export { EuiSystemContext, EuiThemeContext, - EuiOverrideContext, + EuiModificationsContext, EuiColorModeContext, } from './context'; export { useEuiTheme, withEuiTheme } from './hooks'; @@ -40,7 +40,7 @@ export { EuiThemeColor, EuiThemeColorMode, EuiThemeComputed, - EuiThemeOverrides, + EuiThemeModifications, EuiThemeShape, EuiThemeSystem, } from './types'; diff --git a/src/services/theme/provider.tsx b/src/services/theme/provider.tsx index a4aff284c8f..777a049e09b 100644 --- a/src/services/theme/provider.tsx +++ b/src/services/theme/provider.tsx @@ -29,37 +29,41 @@ import isEqual from 'lodash/isEqual'; import { EuiSystemContext, EuiThemeContext, - EuiOverrideContext, + EuiModificationsContext, EuiColorModeContext, } from './context'; import { buildTheme, getColorMode, getComputed, mergeDeep } from './utils'; -import { EuiThemeColorMode, EuiThemeSystem, EuiThemeOverrides } from './types'; +import { + EuiThemeColorMode, + EuiThemeSystem, + EuiThemeModifications, +} from './types'; export interface EuiThemeProviderProps { theme?: EuiThemeSystem; colorMode?: EuiThemeColorMode; - overrides?: EuiThemeOverrides; + modify?: EuiThemeModifications; children: any; } export function EuiThemeProvider({ theme: _system, colorMode: _colorMode, - overrides: _overrides, + modify: _modifications, children, }: PropsWithChildren>) { const parentSystem = useContext(EuiSystemContext); - const parentOverrides = useContext(EuiOverrideContext); + const parentModifications = useContext(EuiModificationsContext); const parentColorMode = useContext(EuiColorModeContext); const parentTheme = useContext(EuiThemeContext); const [system, setSystem] = useState(_system || parentSystem); const prevSystemKey = useRef(system.key); - const [overrides, setOverrides] = useState( - mergeDeep(parentOverrides, _overrides) + const [modifications, setModifications] = useState( + mergeDeep(parentModifications, _modifications) ); - const prevOverrides = useRef(overrides); + const prevModifications = useRef(modifications); const [colorMode, setColorMode] = useState( getColorMode(_colorMode, parentColorMode) @@ -70,15 +74,15 @@ export function EuiThemeProvider({ const isParentTheme = useRef( prevSystemKey.current === parentSystem.key && colorMode === parentColorMode && - isEqual(parentOverrides, overrides) + isEqual(parentModifications, modifications) ); const [theme, setTheme] = useState( - Object.keys(parentTheme).length + isParentTheme.current && Object.keys(parentTheme).length ? parentTheme : getComputed( system, - buildTheme(overrides, `_${system.key}`) as typeof system, + buildTheme(modifications, `_${system.key}`) as typeof system, colorMode ) ); @@ -93,13 +97,13 @@ export function EuiThemeProvider({ }, [_system, parentSystem]); useEffect(() => { - const newOverrides = mergeDeep(parentOverrides, _overrides); - if (!isEqual(prevOverrides.current, newOverrides)) { - setOverrides(newOverrides); - prevOverrides.current = newOverrides; + const newModifications = mergeDeep(parentModifications, _modifications); + if (!isEqual(prevModifications.current, newModifications)) { + setModifications(newModifications); + prevModifications.current = newModifications; isParentTheme.current = false; } - }, [_overrides, parentOverrides]); + }, [_modifications, parentModifications]); useEffect(() => { const newColorMode = getColorMode(_colorMode, parentColorMode); @@ -115,21 +119,21 @@ export function EuiThemeProvider({ setTheme( getComputed( system, - buildTheme(overrides, `_${system.key}`) as typeof system, + buildTheme(modifications, `_${system.key}`) as typeof system, colorMode ) ); } - }, [colorMode, system, overrides]); + }, [colorMode, system, modifications]); return ( - + {children} - + ); diff --git a/src/services/theme/types.ts b/src/services/theme/types.ts index e5da512a442..d13f9e70a63 100644 --- a/src/services/theme/types.ts +++ b/src/services/theme/types.ts @@ -17,6 +17,7 @@ * under the License. */ +import { RecursiveOmit, RecursivePartial } from '../../components/common'; import { euiThemeDefault } from './theme'; type EuiThemeColorModeInverse = 'inverse'; @@ -35,29 +36,16 @@ export type EuiThemeSystem = { key: string; }; -type DeepPartial = T extends Function - ? T - : T extends object - ? { [P in keyof T]?: DeepPartial } - : T; -export type EuiThemeOverrides = DeepPartial; +export type EuiThemeModifications = RecursivePartial; -type OmitDistributive = T extends any - ? T extends object - ? OmitRecursively - : T - : never; -type OmitRecursively = Omit< - { [P in keyof T]: OmitDistributive }, - K ->; - -type Colorless = OmitRecursively; +type Colorless = RecursiveOmit; +// I don't like this. +// Requires manually maintaining sections (e.g., `buttons`) containing colorMode options. +// Also cannot account for extended theme sections (`T`) that use colorMode options. export type EuiThemeComputed = Colorless & { themeName: string; colors: EuiThemeColor; - // I don't like this buttons: Colorless & { colors: EuiThemeShape['buttons']['colors']['light']; }; -}; +} & T; diff --git a/src/services/theme/utils.test.ts b/src/services/theme/utils.test.ts index 182b4d6d1b5..16dd38ef1ba 100644 --- a/src/services/theme/utils.test.ts +++ b/src/services/theme/utils.test.ts @@ -207,6 +207,61 @@ describe('getComputed', () => { themeName: 'minimal', }); }); + it('respects property extensions', () => { + expect( + getComputed( + // @ts-ignore intentionally not using a full EUI theme definition + theme, + buildTheme({ colors: { light: { tertiary: '#333' } } }, ''), + 'light' + ) + ).toEqual({ + colors: { primary: '#000', secondary: '#000000', tertiary: '#333' }, + sizes: { small: 8 }, + themeName: 'minimal', + }); + }); + it('respects section extensions', () => { + expect( + getComputed( + // @ts-ignore intentionally not using a full EUI theme definition + theme, + buildTheme({ custom: { myProp: '#333' } }, ''), + 'light' + ) + ).toEqual({ + colors: { primary: '#000', secondary: '#000000' }, + sizes: { small: 8 }, + custom: { myProp: '#333' }, + themeName: 'minimal', + }); + }); + it('respects extensions in computation', () => { + expect( + getComputed( + // @ts-ignore intentionally not using a full EUI theme definition + theme, + buildTheme( + { + colors: { + light: { + tertiary: computed( + ['colors.primary'], + ([primary]) => `${primary}333` + ), + }, + }, + }, + '' + ), + 'light' + ) + ).toEqual({ + colors: { primary: '#000', secondary: '#000000', tertiary: '#000333' }, + sizes: { small: 8 }, + themeName: 'minimal', + }); + }); }); describe('buildTheme', () => { @@ -246,18 +301,15 @@ describe('currentColorModeOnly', () => { sizes: { small: 8, }, - themeName: 'minimal', }; it('object with only the current color mode colors', () => { expect(currentColorModeOnly('light', theme)).toEqual({ colors: { primary: '#000' }, sizes: { small: 8 }, - themeName: 'minimal', }); expect(currentColorModeOnly('dark', theme)).toEqual({ colors: { primary: '#FFF' }, sizes: { small: 8 }, - themeName: 'minimal', }); }); }); diff --git a/src/services/theme/utils.ts b/src/services/theme/utils.ts index 82b090bfb3f..d9237b5bb94 100644 --- a/src/services/theme/utils.ts +++ b/src/services/theme/utils.ts @@ -19,7 +19,7 @@ import { EuiThemeColorMode, - EuiThemeOverrides, + EuiThemeModifications, EuiThemeSystem, EuiThemeShape, EuiThemeComputed, @@ -52,7 +52,7 @@ export const getColorMode = ( export const getOn = ( model: { [key: string]: any }, _path: string, - colorMode: EuiThemeColorMode + colorMode?: EuiThemeColorMode ) => { const path = _path.split('.'); let node = model; @@ -61,7 +61,7 @@ export const getOn = ( if (node.hasOwnProperty(segment) === false) { return undefined; } - if (segment === COLOR_MODE_KEY) { + if (colorMode && segment === COLOR_MODE_KEY) { if (node[segment].hasOwnProperty(colorMode) === false) { return undefined; } else { @@ -108,7 +108,7 @@ export class Computed { getValue( base: EuiThemeSystem | EuiThemeShape, - overrides: EuiThemeOverrides = {}, + modifications: EuiThemeModifications = {}, working: EuiThemeComputed, colorMode: EuiThemeColorMode ) { @@ -116,7 +116,7 @@ export class Computed { this.dependencies.map((dependency) => { return ( getOn(working, dependency, colorMode) ?? - getOn(overrides, dependency, colorMode) ?? + getOn(modifications, dependency, colorMode) ?? getOn(base, dependency, colorMode) ); }) @@ -141,6 +141,7 @@ export const getComputed = ( function loop( base: { [key: string]: any }, over: { [key: string]: any }, + checkExisting: boolean = false, path?: string ) { Object.keys(base).forEach((key) => { @@ -150,23 +151,29 @@ export const getComputed = ( // Intentional no-op } else { const newPath = path ? `${path}.${key}` : `${key}`; - const baseValue = - base[key] instanceof Computed - ? base[key].getValue(base.root, over.root, output, colorMode) - : base[key]; - const overValue = - over[key] instanceof Computed - ? over[key].getValue(base.root, over.root, output, colorMode) - : over[key]; - if (isObject(baseValue)) { - loop(baseValue, overValue ?? {}, newPath); - } else { - setOn(output, newPath, overValue ?? baseValue); + const existing = checkExisting && getOn(output, newPath); + if (!existing || isObject(existing)) { + const baseValue = + base[key] instanceof Computed + ? base[key].getValue(base.root, over.root, output, colorMode) + : base[key]; + const overValue = + over[key] instanceof Computed + ? over[key].getValue(base.root, over.root, output, colorMode) + : over[key]; + if (isObject(baseValue)) { + loop(baseValue, overValue ?? {}, checkExisting, newPath); + } else { + setOn(output, newPath, overValue ?? baseValue); + } } } }); } + // Compute standard theme values and apply overrides loop(base, over); + // Compute and apply extension values only + loop(over, {}, true); return currentColorModeOnly(colorMode, (output as unknown) as T); };
+ {JSON.stringify({ colors, custom }, null, 2)} +
{JSON.stringify({ colors, custom }, null, 2)}