From 9328b1b9020756343e2f06e12f7ef8df5d60c7f3 Mon Sep 17 00:00:00 2001 From: Levi Thomason Date: Fri, 3 Aug 2018 20:24:34 -0700 Subject: [PATCH] test: add mergeTheme tests --- .../ComponentExample/ComponentExample.tsx | 8 +- src/components/Provider/Provider.tsx | 79 ++---- src/components/Provider/ProviderConsumer.tsx | 4 +- src/lib/index.ts | 1 + src/lib/mergeThemes.ts | 122 +++++++++ src/lib/renderComponent.tsx | 39 ++- src/lib/themeUtils.ts | 30 ++- src/lib/toCompactArray.ts | 2 +- .../components/Accordion/accordionStyles.ts | 4 +- .../teams/components/Avatar/avatarStyles.ts | 4 +- .../teams/components/Button/buttonStyles.ts | 4 +- .../teams/components/Chat/chatStyles.ts | 4 +- .../teams/components/Divider/dividerStyles.ts | 8 +- .../teams/components/Icon/iconStyles.ts | 4 +- .../teams/components/Input/inputStyles.ts | 4 +- .../teams/components/Label/labelStyles.ts | 4 +- .../teams/components/Layout/layoutStyles.ts | 4 +- .../teams/components/Menu/menuItemStyles.ts | 4 +- src/themes/teams/index.ts | 4 +- test/specs/lib/mergeThemes-test.ts | 233 ++++++++++++++++++ types/theme.d.ts | 170 ++++++++----- 21 files changed, 556 insertions(+), 180 deletions(-) create mode 100644 src/lib/mergeThemes.ts create mode 100644 test/specs/lib/mergeThemes-test.ts diff --git a/docs/src/components/ComponentDoc/ComponentExample/ComponentExample.tsx b/docs/src/components/ComponentDoc/ComponentExample/ComponentExample.tsx index 4fa6d316fe..5b7c0c594d 100644 --- a/docs/src/components/ComponentDoc/ComponentExample/ComponentExample.tsx +++ b/docs/src/components/ComponentDoc/ComponentExample/ComponentExample.tsx @@ -24,7 +24,7 @@ import ComponentControls from '../ComponentControls' import ComponentExampleTitle from './ComponentExampleTitle' import ContributionPrompt from '../ContributionPrompt' import getSourceCodeManager, { ISourceCodeManager, SourceCodeType } from './SourceCodeManager' -import { IMergedThemes, ITheme } from 'types/theme' +import { IThemePrepared, IThemeInput } from 'types/theme' export interface IComponentExampleProps extends RouteComponentProps { title: string @@ -35,7 +35,7 @@ export interface IComponentExampleProps extends RouteComponentProps { interface IComponentExampleState { knobs: Object - theme: ITheme + theme: IThemeInput exampleElement?: JSX.Element handleMouseLeave?: () => void handleMouseMove?: () => void @@ -345,7 +345,7 @@ class ComponentExample extends PureComponentTheme { + render={({ siteVariables, componentVariables }: IThemeInput | IThemePrepared) => { // TODO: refactor to handle variables as a call stack once mergeThemes is updated // TODO: refactor to handle variables as a call stack once mergeThemes is updated // TODO: refactor to handle variables as a call stack once mergeThemes is updated diff --git a/src/components/Provider/Provider.tsx b/src/components/Provider/Provider.tsx index 4ca487162d..b0c3706e14 100644 --- a/src/components/Provider/Provider.tsx +++ b/src/components/Provider/Provider.tsx @@ -3,69 +3,34 @@ import PropTypes from 'prop-types' import React, { Component } from 'react' import { Provider as RendererProvider, ThemeProvider } from 'react-fela' -import { felaRenderer as felaLtrRenderer, felaRtlRenderer, toCompactArray } from '../../lib' -import { FontFaces, IMergedThemes, ITheme, StaticStyles } from '../../../types/theme' +import { + callable, + felaRenderer as felaLtrRenderer, + felaRtlRenderer, + mergeThemes, + toCompactArray, +} from '../../lib' +import { + FontFaces, + IThemePrepared, + IThemeInput, + StaticStyles, + ComponentVariablesInput, + IThemeComponentStylesInput, + IComponentPartStylesInput, + IThemeComponentVariablesInput, + ComponentStyleFunctionParam, + ComponentPartStyleFunction, +} from '../../../types/theme' import ProviderConsumer from './ProviderConsumer' export interface IProviderProps { fontFaces?: FontFaces - theme: ITheme + theme: IThemeInput staticStyles?: StaticStyles children: React.ReactNode } -const mergeThemes = (...themes: ITheme[]): IMergedThemes => { - const [first, ...rest]: ITheme[] = toCompactArray(...themes) - - const merged = { - siteVariables: first.siteVariables, - componentVariables: toCompactArray(first.componentVariables), - componentStyles: toCompactArray(first.componentStyles), - rtl: first.rtl, - renderer: first.renderer, - } - - if (rest.length === 0) { - return merged - } - - return rest.reduce((acc, next) => { - // Site variables can safely be merged at each Provider in the tree. - // They are flat objects and do not depend on render-time values, such as props. - acc.siteVariables = { ...acc.siteVariables, ...next.siteVariables } - - // Do not resolve variables in the middle of the tree. - // Component variables can be objects, functions, or an array of these. - // The functions must be called with the final result of siteVariables. - // Component variable objects have no ability to apply siteVariables. - // Therefore, componentVariables must be resolved by the component at render time. - // We instead pass down an array of variables to be resolved at the end of the tree. - // TODO: refactor to call stack, variables should always be a single function - // TODO: refactor to call stack, variables should always be a single function - // TODO: refactor to call stack, variables should always be a single function - if (next.componentVariables) { - acc.componentVariables = acc.componentVariables.concat(next.componentVariables) - } - - // See component variables reasoning above. - // (Component styles are just like component variables, except they return style objects.) - // TODO: refactor to call stack, styles should always be a single function - // TODO: refactor to call stack, styles should always be a single function - // TODO: refactor to call stack, styles should always be a single function - if (next.componentStyles) { - acc.componentStyles = acc.componentStyles.concat(next.componentStyles) - } - - // Latest RTL value wins - acc.rtl = next.rtl || acc.rtl - - // Use the correct renderer for RTL - acc.renderer = acc.rtl ? felaRtlRenderer : felaLtrRenderer - - return acc - }, merged) -} - /** * The Provider passes the CSS in JS renderer and theme down context. */ @@ -172,12 +137,12 @@ class Provider extends Component { return ( { + render={(incomingTheme: IThemePrepared) => { // The provider must: // 1. Normalize it's theme props, reducing and merging where possible. // 2. Merge prop values onto any incoming context values. // 3. Provide the result down stream. - const outgoingTheme: IMergedThemes = mergeThemes(incomingTheme, theme) + const outgoingTheme: IThemePrepared = mergeThemes(incomingTheme, theme) return ( diff --git a/src/components/Provider/ProviderConsumer.tsx b/src/components/Provider/ProviderConsumer.tsx index 28e38928a3..fb740f7613 100644 --- a/src/components/Provider/ProviderConsumer.tsx +++ b/src/components/Provider/ProviderConsumer.tsx @@ -2,10 +2,10 @@ import PropTypes from 'prop-types' import React from 'react' import { FelaTheme } from 'react-fela' -import { IMergedThemes, ITheme } from '../../../types/theme' +import { IThemePrepared } from '../../../types/theme' export interface IProviderConsumerProps { - render: (theme: ITheme | IMergedThemes) => React.ReactNode + render: (theme: IThemePrepared) => React.ReactNode } /** diff --git a/src/lib/index.ts b/src/lib/index.ts index 46e52c1560..cfe1702800 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -14,6 +14,7 @@ export * from './factories' export * from './themeUtils' export { default as getElementType } from './getElementType' export { default as getUnhandledProps } from './getUnhandledProps' +export { default as mergeThemes } from './mergeThemes' export { default as renderComponent, IRenderResultConfig } from './renderComponent' export { useKeyOnly, diff --git a/src/lib/mergeThemes.ts b/src/lib/mergeThemes.ts new file mode 100644 index 0000000000..2091a00024 --- /dev/null +++ b/src/lib/mergeThemes.ts @@ -0,0 +1,122 @@ +import _ from 'lodash' +import { + ISiteVariables, + IThemeComponentStylesInput, + IThemeComponentStylesPrepared, + IThemeComponentVariablesInput, + IThemeComponentVariablesPrepared, + IThemeInput, + IThemePrepared, +} from '../../types/theme' +import { callable, felaRenderer, felaRtlRenderer } from './index' + +/** + * Site variables can safely be merged at each Provider in the tree. + * They are flat objects and do not depend on render-time values, such as props. + */ +const mergeSiteVariables = ( + target: ISiteVariables, + ...sources: ISiteVariables[] +): ISiteVariables => { + return sources.reduce((acc, next) => ({ ...acc, ...next }), target) +} + +/** + * Component variables can be objects, functions, or an array of these. + * The functions must be called with the final result of siteVariables, otherwise + * the component variable objects would have no ability to apply siteVariables. + * Therefore, componentVariables must be resolved by the component at render time. + * We instead pass down call stack of component variable functions to be resolved later. + */ +const mergeComponentVariables = ( + target: IThemeComponentVariablesInput, + ...sources: IThemeComponentVariablesInput[] +): IThemeComponentVariablesPrepared => { + const displayNames = _.union(_.keys(target), ..._.map(sources, _.keys)) + + return sources.reduce((acc, next) => { + return displayNames.reduce((componentVariables, displayName) => { + // Break references to avoid an infinite loop. + // We are replacing functions with new ones that calls the originals. + const originalTarget = acc[displayName] + const originalSource = next[displayName] + + componentVariables[displayName] = siteVariables => { + return { + ...callable(originalTarget)(siteVariables), + ...callable(originalSource)(siteVariables), + } + } + + return componentVariables + }, {}) + }, target) +} + +/** + * See mergeComponentVariables() description. + * Component styles adhere to the same pattern as component variables, except + * that they return style objects. + */ +const mergeComponentStyles = ( + target: IThemeComponentStylesInput, + ...sources: IThemeComponentStylesInput[] +): IThemeComponentStylesPrepared => { + const initial: IThemeComponentStylesPrepared = _.mapValues(target, stylesByPart => { + return _.mapValues(stylesByPart, callable) + }) + + return sources.reduce((acc, next) => { + _.forEach(next, (stylesByPart, displayName) => { + acc[displayName] = acc[displayName] || {} + + _.forEach(stylesByPart, (partStyle, partName) => { + // Break references to avoid an infinite loop. + // We are replacing functions with a new ones that calls the originals. + const originalTarget = acc[displayName][partName] + const originalSource = next[displayName][partName] + + acc[displayName][partName] = styleParam => { + return _.merge(callable(originalTarget)(styleParam), callable(originalSource)(styleParam)) + } + }) + }) + + return acc + }, initial) +} + +const mergeRTL = (target, ...sources) => { + return !!sources.reduce((acc, next) => { + return typeof next === 'boolean' ? next : acc + }, target) +} + +const mergeThemes = (...themes: IThemeInput[]): IThemePrepared => { + const emptyTheme = { + siteVariables: {}, + componentVariables: {}, + componentStyles: {}, + } as IThemePrepared + + return themes.reduce((acc: IThemePrepared, next: IThemeInput) => { + acc.siteVariables = mergeSiteVariables(acc.siteVariables, next.siteVariables) + + acc.componentVariables = mergeComponentVariables( + acc.componentVariables, + next.componentVariables, + ) + + acc.componentStyles = mergeComponentStyles(acc.componentStyles, next.componentStyles) + + // Latest RTL value wins + acc.rtl = mergeRTL(acc.rtl, next.rtl) + + // Use the correct renderer for RTL + acc.renderer = acc.rtl === true ? felaRtlRenderer : felaRenderer + + return acc + }, emptyTheme) +} + +export default mergeThemes diff --git a/src/lib/renderComponent.tsx b/src/lib/renderComponent.tsx index 3bd221cda7..85a711d542 100644 --- a/src/lib/renderComponent.tsx +++ b/src/lib/renderComponent.tsx @@ -1,18 +1,20 @@ +import _ from 'lodash' import cx from 'classnames' import React from 'react' import { FelaTheme } from 'react-fela' +import callable from './callable' import getElementType from './getElementType' import getUnhandledProps from './getUnhandledProps' import toCompactArray from './toCompactArray' -import { renderComponentStyles, resolveComponentVariables } from './themeUtils' - +import { renderComponentStyles } from './themeUtils' import { - ComponentVariables, + ComponentStyleFunctionParam, + ComponentVariablesInput, ComponentVariablesObject, - IComponentStyles, - IMergedThemes, - ITheme, + IComponentPartStylesInput, + IThemeInput, + IThemePrepared, } from '../../types/theme' export interface IRenderResultConfig

{ @@ -25,8 +27,8 @@ export type RenderComponentCallback

= (config: IRenderResultConfig

) => any type IRenderConfigProps = { [key: string]: any - variables?: ComponentVariables - styles?: IComponentStyles + variables?: ComponentVariablesInput + styles?: IComponentPartStylesInput } export interface IRenderConfig { @@ -45,22 +47,17 @@ const renderComponent =

( return ( { + render={(theme: IThemeInput | IThemePrepared) => { const ElementType = getElementType({ defaultProps }, props) const rest = getUnhandledProps({ handledProps }, props) // - // Resolve variables using final siteVariables, allow props.variables to override + // Resolve variables for this component, allow props.variables to override // - const variablesForComponent = toCompactArray(theme.componentVariables) - .map(variables => variables[displayName]) - .concat(props.variables) - .filter(Boolean) - - const variables: ComponentVariablesObject = resolveComponentVariables( - variablesForComponent, - theme.siteVariables, - ) + const variables: ComponentVariablesObject = { + ...callable(theme.componentVariables[displayName])(theme.siteVariables), + ...callable(props.variables)(theme.siteVariables), + } // // Resolve styles using resolved variables, merge results, allow props.styles to override @@ -70,7 +67,7 @@ const renderComponent =

( .concat(props.styles) .filter(Boolean) - const styleArg = { + const styleParam: ComponentStyleFunctionParam = { props, variables, siteVariables: theme.siteVariables, @@ -80,7 +77,7 @@ const renderComponent =

( const classes: ComponentVariablesObject = renderComponentStyles( theme.renderer, stylesForComponent, - styleArg, + styleParam, ) classes.root = cx(className, classes.root, props.className) diff --git a/src/lib/themeUtils.ts b/src/lib/themeUtils.ts index 8c47f8c48a..536691fdd6 100644 --- a/src/lib/themeUtils.ts +++ b/src/lib/themeUtils.ts @@ -3,11 +3,12 @@ import { combineRules } from 'fela' import callable from './callable' import { - ComponentStyleFunctionArg, - ComponentVariables, + ComponentPartStyleFunction, + ComponentStyleFunctionParam, + ComponentVariablesInput, ComponentVariablesObject, - IComponentStyleClasses, - IComponentStyles, + IComponentPartClasses, + IComponentPartStylesInput, IRenderer, ISiteVariables, OneOrArray, @@ -20,7 +21,7 @@ import { toCompactArray } from './index' * Component variables objects are merged as-is. */ export const resolveComponentVariables = ( - componentVariables: OneOrArray, + componentVariables: OneOrArray, siteVariables: ISiteVariables, ): ComponentVariablesObject => { return toCompactArray(componentVariables).reduce((acc, next) => { @@ -37,9 +38,9 @@ export const resolveComponentVariables = ( */ export const renderComponentStyles = ( renderer: IRenderer, - componentStyles: IComponentStyles[], - styleArg: ComponentStyleFunctionArg, -): IComponentStyleClasses => { + componentStyles: OneOrArray, + styleParam: ComponentStyleFunctionParam, +): IComponentPartClasses => { const stylesArr = toCompactArray(componentStyles) // root, icon, etc. @@ -48,15 +49,18 @@ export const renderComponentStyles = ( }, []) return componentParts.reduce((classes, partName) => { - const styleFunctionsForPart = stylesArr.reduce((stylesForPart, nextStyle) => { - if (nextStyle[partName]) stylesForPart.push(callable(nextStyle[partName])) + const styleFunctionsForPart = stylesArr.reduce( + (stylesForPart: ComponentPartStyleFunction[], nextStyle) => { + if (nextStyle[partName]) stylesForPart.push(callable(nextStyle[partName])) - return stylesForPart - }, []) + return stylesForPart + }, + [], + ) const combinedFunctions = combineRules(...styleFunctionsForPart) - classes[partName] = renderer.renderRule(combinedFunctions, styleArg) + classes[partName] = renderer.renderRule(combinedFunctions, styleParam) return classes }, {}) diff --git a/src/lib/toCompactArray.ts b/src/lib/toCompactArray.ts index 6d544681de..1ac21eac94 100644 --- a/src/lib/toCompactArray.ts +++ b/src/lib/toCompactArray.ts @@ -1,4 +1,4 @@ -const toCompactArray = (...values: any[]): any[] => { +const toCompactArray = (...values: T[]): T[] => { return [].concat(...values).filter(Boolean) } diff --git a/src/themes/teams/components/Accordion/accordionStyles.ts b/src/themes/teams/components/Accordion/accordionStyles.ts index 2392fd8d1f..3edef8e6d5 100644 --- a/src/themes/teams/components/Accordion/accordionStyles.ts +++ b/src/themes/teams/components/Accordion/accordionStyles.ts @@ -1,6 +1,6 @@ -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' -const accordionStyles: IComponentStyles = { +const accordionStyles: IComponentPartStylesInput = { root: (): ICSSInJSStyle => ({ verticalAlign: 'middle', display: 'flex', diff --git a/src/themes/teams/components/Avatar/avatarStyles.ts b/src/themes/teams/components/Avatar/avatarStyles.ts index 0c29acd28d..3edfa224c0 100644 --- a/src/themes/teams/components/Avatar/avatarStyles.ts +++ b/src/themes/teams/components/Avatar/avatarStyles.ts @@ -1,5 +1,5 @@ import { pxToRem } from '../../../../lib' -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' const getAvatarDimension = size => { return 12 + size * 4 @@ -37,7 +37,7 @@ const getPresenceSpanTop = (size, presenceIconPadding) => { return getPresenceIconSize(size) + getPresenceIconPadding(size, presenceIconPadding) } -const avatarStyles: IComponentStyles = { +const avatarStyles: IComponentPartStylesInput = { root: (): ICSSInJSStyle => ({ display: 'inline-block', verticalAlign: 'middle', diff --git a/src/themes/teams/components/Button/buttonStyles.ts b/src/themes/teams/components/Button/buttonStyles.ts index 55de55c4c0..ef7b828a77 100644 --- a/src/themes/teams/components/Button/buttonStyles.ts +++ b/src/themes/teams/components/Button/buttonStyles.ts @@ -1,8 +1,8 @@ import { pxToRem } from '../../../../lib' -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' import { disabledStyle, truncateStyle } from '../../../../styles/customCSS' -const buttonStyles: IComponentStyles = { +const buttonStyles: IComponentPartStylesInput = { root: ({ props, variables }): ICSSInJSStyle => { const { circular, disabled, fluid, icon, iconPosition, type } = props const primary = type === 'primary' diff --git a/src/themes/teams/components/Chat/chatStyles.ts b/src/themes/teams/components/Chat/chatStyles.ts index 65f81ce1ea..edbd648c4b 100644 --- a/src/themes/teams/components/Chat/chatStyles.ts +++ b/src/themes/teams/components/Chat/chatStyles.ts @@ -1,6 +1,6 @@ -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' -const chatStyles: IComponentStyles = { +const chatStyles: IComponentPartStylesInput = { root: (): ICSSInJSStyle => ({ display: 'flex', flexDirection: 'column', diff --git a/src/themes/teams/components/Divider/dividerStyles.ts b/src/themes/teams/components/Divider/dividerStyles.ts index 74e0942ebd..af03f399a9 100644 --- a/src/themes/teams/components/Divider/dividerStyles.ts +++ b/src/themes/teams/components/Divider/dividerStyles.ts @@ -1,5 +1,9 @@ import { childrenExist, pxToRem } from '../../../../lib' -import { IComponentStyles, ICSSInJSStyle, ICSSPseudoElementStyle } from '../../../../../types/theme' +import { + IComponentPartStylesInput, + ICSSInJSStyle, + ICSSPseudoElementStyle, +} from '../../../../../types/theme' const dividerBorderRule = (size, color): ICSSInJSStyle => ({ height: `${size + 1}px`, @@ -18,7 +22,7 @@ const beforeAndAfter = (size, type, variables): ICSSPseudoElementStyle => ({ }), }) -const dividerStyles: IComponentStyles = { +const dividerStyles: IComponentPartStylesInput = { root: ({ props, variables }): ICSSInJSStyle => { const { children, size, type, important, content } = props return { diff --git a/src/themes/teams/components/Icon/iconStyles.ts b/src/themes/teams/components/Icon/iconStyles.ts index 72ce0c8ec4..2c7bee9c63 100644 --- a/src/themes/teams/components/Icon/iconStyles.ts +++ b/src/themes/teams/components/Icon/iconStyles.ts @@ -1,6 +1,6 @@ import fontAwesomeIcons from './fontAwesomeIconStyles' import { disabledStyle, fittedStyle } from '../../../../styles/customCSS' -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' import { IconXSpacing } from '../../../../components/Icon/Icon' const sizes = new Map([ @@ -53,7 +53,7 @@ const getBorderedStyles = (circular, borderColor, color): ICSSInJSStyle => ({ ...(circular ? { borderRadius: '50%' } : { verticalAlign: 'baseline' }), }) -const iconStyles: IComponentStyles = { +const iconStyles: IComponentPartStylesInput = { root: ({ props: { color, disabled, kind, name, size, bordered, circular, xSpacing }, variables, diff --git a/src/themes/teams/components/Input/inputStyles.ts b/src/themes/teams/components/Input/inputStyles.ts index 9022354682..eb12c623cb 100644 --- a/src/themes/teams/components/Input/inputStyles.ts +++ b/src/themes/teams/components/Input/inputStyles.ts @@ -1,6 +1,6 @@ -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' -const inputStyles: IComponentStyles = { +const inputStyles: IComponentPartStylesInput = { root: ({ props, variables }): ICSSInJSStyle => { return { display: 'inline-flex', diff --git a/src/themes/teams/components/Label/labelStyles.ts b/src/themes/teams/components/Label/labelStyles.ts index cbd9257d70..d3d58f3736 100644 --- a/src/themes/teams/components/Label/labelStyles.ts +++ b/src/themes/teams/components/Label/labelStyles.ts @@ -1,7 +1,7 @@ import { pxToRem } from '../../../../lib' -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' -const labelStyles: IComponentStyles = { +const labelStyles: IComponentPartStylesInput = { root: ({ props, variables }): ICSSInJSStyle => ({ padding: variables.padding, fontWeight: 500, diff --git a/src/themes/teams/components/Layout/layoutStyles.ts b/src/themes/teams/components/Layout/layoutStyles.ts index b56d0a5bcb..17baa8cc20 100644 --- a/src/themes/teams/components/Layout/layoutStyles.ts +++ b/src/themes/teams/components/Layout/layoutStyles.ts @@ -1,5 +1,5 @@ import { debugRoot, debugArea, debugGap } from '../../../../styles/debugStyles' -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' const truncateRule = { overflow: 'hidden', @@ -7,7 +7,7 @@ const truncateRule = { whiteSpace: 'nowrap', } -const layoutStyles: IComponentStyles = { +const layoutStyles: IComponentPartStylesInput = { root: ({ props }): ICSSInJSStyle => { const { alignItems, diff --git a/src/themes/teams/components/Menu/menuItemStyles.ts b/src/themes/teams/components/Menu/menuItemStyles.ts index 063b464b30..77ca241c88 100644 --- a/src/themes/teams/components/Menu/menuItemStyles.ts +++ b/src/themes/teams/components/Menu/menuItemStyles.ts @@ -1,5 +1,5 @@ import { pxToRem } from '../../../../lib' -import { IComponentStyles, ICSSInJSStyle } from '../../../../../types/theme' +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' import { IMenuItemProps } from '../../../../components/Menu/MenuItem' const underlinedItem = (color): ICSSInJSStyle => ({ @@ -39,7 +39,7 @@ const itemSeparator = ({ } } -const menuItemStyles: IComponentStyles = { +const menuItemStyles: IComponentPartStylesInput = { root: ({ props, variables }: { props: IMenuItemProps; variables: any }): ICSSInJSStyle => { const { active, shape, type } = props return { diff --git a/src/themes/teams/index.ts b/src/themes/teams/index.ts index f240d051b0..c2cc417dde 100644 --- a/src/themes/teams/index.ts +++ b/src/themes/teams/index.ts @@ -1,10 +1,10 @@ -import { ITheme } from '../../../types/theme' +import { IThemeInput } from '../../../types/theme' import * as siteVariables from './siteVariables' import * as componentVariables from './componentVariables' import * as componentStyles from './componentStyles' -export const theme: ITheme = { +export const theme: IThemeInput = { rtl: false, siteVariables, componentVariables, diff --git a/test/specs/lib/mergeThemes-test.ts b/test/specs/lib/mergeThemes-test.ts new file mode 100644 index 0000000000..ccef0b8517 --- /dev/null +++ b/test/specs/lib/mergeThemes-test.ts @@ -0,0 +1,233 @@ +import mergeThemes from '../../../src/lib/mergeThemes' +import { felaRtlRenderer, felaRenderer } from '../../../src/lib' + +describe('mergeThemes', () => { + describe('siteVariables', () => { + test('merges top level keys', () => { + const target = { siteVariables: { overridden: false, keep: true } } + const source = { siteVariables: { overridden: true, add: true } } + + expect(mergeThemes(target, source)).toMatchObject({ + siteVariables: { overridden: true, keep: true, add: true }, + }) + }) + + test('disregards nested keys', () => { + const target = { siteVariables: { nested: { replaced: true } } } + const source = { siteVariables: { nested: { other: 'value' } } } + + expect(mergeThemes(target, source)).toMatchObject({ + siteVariables: { nested: { other: 'value' } }, + }) + }) + }) + + describe('componentVariables', () => { + test('component names are merged', () => { + const target = { componentVariables: { Button: {} } } + const source = { componentVariables: { Icon: {} } } + + const merged = mergeThemes(target, source) + + expect(merged.componentVariables).toHaveProperty('Button') + expect(merged.componentVariables).toHaveProperty('Icon') + }) + + test('objects are converted to functions', () => { + const target = { componentVariables: { Button: { color: 'red' } } } + const source = { componentVariables: { Icon: { color: 'blue' } } } + + const merged = mergeThemes(target, source) + + expect(merged.componentVariables.Button).toBeInstanceOf(Function) + expect(merged.componentVariables.Icon).toBeInstanceOf(Function) + }) + + test('functions return merged variables', () => { + const target = { componentVariables: { Button: () => ({ one: 1, three: 3 }) } } + const source = { + componentVariables: { Button: () => ({ one: 'one', two: 'two' }) }, + } + + const merged = mergeThemes(target, source) + + expect(merged.componentVariables.Button()).toMatchObject({ + one: 'one', + two: 'two', + three: 3, + }) + }) + + test('functions accept and apply siteVariables', () => { + const target = { + componentVariables: { + Button: siteVariables => ({ one: 1, target: true, ...siteVariables }), + }, + } + + const source = { + componentVariables: { + Button: siteVariables => ({ two: 2, source: true, ...siteVariables }), + }, + } + + const merged = mergeThemes(target, source) + + const siteVariables = { one: 'one', two: 'two' } + + expect(merged.componentVariables.Button(siteVariables)).toMatchObject({ + one: 'one', + two: 'two', + source: true, + target: true, + }) + }) + }) + + describe('componentStyles', () => { + test('component names are merged', () => { + const target = { componentStyles: { Button: {} } } + const source = { componentStyles: { Icon: {} } } + + const merged = mergeThemes(target, source) + + expect(merged.componentStyles).toHaveProperty('Button') + expect(merged.componentStyles).toHaveProperty('Icon') + }) + + test('component parts are merged', () => { + const target = { componentStyles: { Button: { root: {} } } } + const source = { componentStyles: { Button: { icon: {} } } } + + const merged = mergeThemes(target, source) + + expect(merged.componentStyles.Button).toHaveProperty('root') + expect(merged.componentStyles.Button).toHaveProperty('icon') + }) + + test('component part objects are converted to functions', () => { + const target = { componentStyles: { Button: { root: {} } } } + const source = { componentStyles: { Icon: { root: {} } } } + + const merged = mergeThemes(target, source) + + expect(merged.componentStyles.Button.root).toBeInstanceOf(Function) + expect(merged.componentStyles.Icon.root).toBeInstanceOf(Function) + }) + + test('component part styles are deeply merged', () => { + const target = { + componentStyles: { + Button: { + root: { + display: 'inline-block', + color: 'green', + '::before': { + content: 'before content', + }, + }, + }, + }, + } + + const source = { + componentStyles: { + Button: { + root: { + color: 'blue', + '::before': { + color: 'red', + }, + }, + }, + }, + } + + const merged = mergeThemes(target, source) + + expect(merged.componentStyles.Button.root()).toMatchObject({ + display: 'inline-block', + color: 'blue', + '::before': { + content: 'before content', + color: 'red', + }, + }) + }) + + test('functions can accept and apply params', () => { + const target = { + componentStyles: { + Button: { + root: param => ({ target: true, ...param }), + }, + }, + } + + const source = { + componentStyles: { + Button: { + root: param => ({ source: true, ...param }), + }, + }, + } + + const merged = mergeThemes(target, source) + + const styleParam = { + siteVariables: { brand: '#38E' }, + variables: { iconSize: 'large' }, + props: { primary: true }, + rtl: false, + } + + expect(merged.componentStyles.Button.root(styleParam)).toMatchObject({ + source: true, + target: true, + ...styleParam, + }) + }) + }) + + describe('rtl', () => { + test('latest boolean value wins', () => { + expect(mergeThemes({ rtl: false }, { rtl: true })).toHaveProperty('rtl', true) + expect(mergeThemes({ rtl: true }, { rtl: false })).toHaveProperty('rtl', false) + + expect(mergeThemes({ rtl: null }, { rtl: true })).toHaveProperty('rtl', true) + expect(mergeThemes({ rtl: null }, { rtl: false })).toHaveProperty('rtl', false) + + expect(mergeThemes({ rtl: undefined }, { rtl: true })).toHaveProperty('rtl', true) + expect(mergeThemes({ rtl: undefined }, { rtl: false })).toHaveProperty('rtl', false) + }) + + test('null values do not override boolean values', () => { + expect(mergeThemes({ rtl: false }, { rtl: null })).toHaveProperty('rtl', false) + expect(mergeThemes({ rtl: true }, { rtl: null })).toHaveProperty('rtl', true) + }) + + test('undefined values do not override boolean values', () => { + expect(mergeThemes({ rtl: false }, { rtl: undefined })).toHaveProperty('rtl', false) + expect(mergeThemes({ rtl: true }, { rtl: undefined })).toHaveProperty('rtl', true) + }) + + test('default to false if no boolean was provided', () => { + expect(mergeThemes({ rtl: null }, { rtl: null })).toHaveProperty('rtl', false) + expect(mergeThemes({ rtl: null }, { rtl: undefined })).toHaveProperty('rtl', false) + + expect(mergeThemes({ rtl: undefined }, { rtl: null })).toHaveProperty('rtl', false) + expect(mergeThemes({ rtl: undefined }, { rtl: undefined })).toHaveProperty('rtl', false) + }) + }) + + describe('renderer', () => { + test('felaRtlRenderer is chosen if rtl is true', () => { + expect(mergeThemes({ rtl: true })).toHaveProperty('renderer', felaRtlRenderer) + }) + test('felaRenderer is chosen if rtl is not true', () => { + expect(mergeThemes({ rtl: false })).toHaveProperty('renderer', felaRenderer) + expect(mergeThemes({ rtl: null })).toHaveProperty('renderer', felaRenderer) + expect(mergeThemes({ rtl: undefined })).toHaveProperty('renderer', felaRenderer) + }) + }) +}) diff --git a/types/theme.d.ts b/types/theme.d.ts index b1fee39cd9..05f7b60c1a 100644 --- a/types/theme.d.ts +++ b/types/theme.d.ts @@ -1,6 +1,13 @@ import * as CSSType from 'csstype' import { IRenderer as IFelaRenderer } from 'fela' -import React from 'react' +import * as React from 'react' + +// Themes go through 3 phases. +// 1. Input - (from the user), variable and style objects/functions, some values optional +// 2. Prepared - (on context), variable and style functions only, all values required +// 3. Resolved - (for rendering), plain object variables and styles, all values required +// +// We use these terms in typings to indicate which phase the typings apply to. // ======================================================== // Utilities @@ -22,17 +29,19 @@ export type IProps = ObjectOf export interface ISiteVariables { [key: string]: any - brand: string - htmlFontSize: string + brand?: string + htmlFontSize?: string } -export type ComponentVariableValue = any +export type ComponentVariableValue = string | number | boolean export type ComponentVariablesObject = ObjectOf -export type ComponentVariablesFunction = (siteVariables: ISiteVariables) => ComponentVariablesObject +export type ComponentVariablesFunction = ( + siteVariables?: ISiteVariables, +) => ComponentVariablesObject -export type ComponentVariables = ComponentVariablesObject | ComponentVariablesFunction +export type ComponentVariablesInput = ComponentVariablesObject | ComponentVariablesFunction // ======================================================== // Styles @@ -59,28 +68,34 @@ export interface ICSSInJSStyle extends React.CSSProperties { ':last-child'?: ICSSInJSStyle } -export type ComponentPartStyle = ComponentStyleFunction | ICSSInJSStyle +export interface ComponentStyleFunctionParam { + props: IProps + variables: ComponentVariablesObject + siteVariables: ISiteVariables + rtl: boolean +} + +export type ComponentPartStyleFunction = (styleParam?: ComponentStyleFunctionParam) => ICSSInJSStyle -export interface IComponentStyles { +export type ComponentPartStyle = ComponentPartStyleFunction | ICSSInJSStyle + +export interface IComponentPartStylesInput { [part: string]: ComponentPartStyle root?: ComponentPartStyle } -export interface IComponentStyleClasses { - [part: string]: string +export interface IComponentPartStylesPrepared { + [part: string]: ComponentPartStyleFunction - root?: string + root?: ComponentPartStyleFunction } -export interface ComponentStyleFunctionArg { - props: IProps - variables: ComponentVariablesObject - siteVariables: ISiteVariables - rtl: boolean -} +export interface IComponentPartClasses { + [part: string]: string -export type ComponentStyleFunction = (arg: ComponentStyleFunctionArg) => ICSSInJSStyle + root?: string +} // ======================================================== // Static Styles @@ -101,12 +116,11 @@ export type StaticStyles = OneOrArray // ======================================================== // Theme // ======================================================== - -export interface ITheme { - siteVariables: ISiteVariables - componentVariables: IThemeComponentVariables - componentStyles: IThemeComponentStyles - rtl: boolean +export interface IThemeInput { + siteVariables?: ISiteVariables + componentVariables?: IThemeComponentVariablesInput + componentStyles?: IThemeComponentStylesInput + rtl?: boolean renderer?: IRenderer } @@ -118,46 +132,82 @@ export interface ITheme { // // As a theme cascades down the tree and is merged with the previous theme on // context, the resulting theme takes this shape. -export interface IMergedThemes { +export interface IThemePrepared { siteVariables: ISiteVariables - componentVariables: IThemeComponentVariables[] - componentStyles: IThemeComponentStyles[] + componentVariables: { + [key in keyof IThemeComponentVariablesPrepared]: ComponentVariablesFunction + } + componentStyles: { [key in keyof IThemeComponentStylesPrepared]: IComponentPartStylesPrepared } rtl: boolean - renderer?: IRenderer + renderer: IRenderer +} + +export interface IThemeComponentStylesInput { + Accordion?: IComponentPartStylesInput + Avatar?: IComponentPartStylesInput + Button?: IComponentPartStylesInput + Chat?: IComponentPartStylesInput + Divider?: IComponentPartStylesInput + Header?: IComponentPartStylesInput + Icon?: IComponentPartStylesInput + Image?: IComponentPartStylesInput + Input?: IComponentPartStylesInput + Label?: IComponentPartStylesInput + Layout?: IComponentPartStylesInput + List?: IComponentPartStylesInput + Menu?: IComponentPartStylesInput + Text?: IComponentPartStylesInput +} + +export interface IThemeComponentStylesPrepared { + Accordion?: IComponentPartStylesPrepared + Avatar?: IComponentPartStylesPrepared + Button?: IComponentPartStylesPrepared + Chat?: IComponentPartStylesPrepared + Divider?: IComponentPartStylesPrepared + Header?: IComponentPartStylesPrepared + Icon?: IComponentPartStylesPrepared + Image?: IComponentPartStylesPrepared + Input?: IComponentPartStylesPrepared + Label?: IComponentPartStylesPrepared + Layout?: IComponentPartStylesPrepared + List?: IComponentPartStylesPrepared + Menu?: IComponentPartStylesPrepared + Text?: IComponentPartStylesPrepared +} + +export interface IThemeComponentVariablesInput { + Accordion?: ComponentVariablesInput + Avatar?: ComponentVariablesInput + Button?: ComponentVariablesInput + Chat?: ComponentVariablesInput + Divider?: ComponentVariablesInput + Header?: ComponentVariablesInput + Icon?: ComponentVariablesInput + Image?: ComponentVariablesInput + Input?: ComponentVariablesInput + Label?: ComponentVariablesInput + Layout?: ComponentVariablesInput + List?: ComponentVariablesInput + Menu?: ComponentVariablesInput + Text?: ComponentVariablesInput } -export interface IThemeComponentStyles { - Accordion?: IComponentStyles - Avatar?: IComponentStyles - Button?: IComponentStyles - Chat?: IComponentStyles - Divider?: IComponentStyles - Header?: IComponentStyles - Icon?: IComponentStyles - Image?: IComponentStyles - Input?: IComponentStyles - Label?: IComponentStyles - Layout?: IComponentStyles - List?: IComponentStyles - Menu?: IComponentStyles - Text?: IComponentStyles -} - -export interface IThemeComponentVariables { - Accordion?: ComponentVariables - Avatar?: ComponentVariables - Button?: ComponentVariables - Chat?: ComponentVariables - Divider?: ComponentVariables - Header?: ComponentVariables - Icon?: ComponentVariables - Image?: ComponentVariables - Input?: ComponentVariables - Label?: ComponentVariables - Layout?: ComponentVariables - List?: ComponentVariables - Menu?: ComponentVariables - Text?: ComponentVariables +export interface IThemeComponentVariablesPrepared { + Accordion?: ComponentVariablesFunction + Avatar?: ComponentVariablesFunction + Button?: ComponentVariablesFunction + Chat?: ComponentVariablesFunction + Divider?: ComponentVariablesFunction + Header?: ComponentVariablesFunction + Icon?: ComponentVariablesFunction + Image?: ComponentVariablesFunction + Input?: ComponentVariablesFunction + Label?: ComponentVariablesFunction + Layout?: ComponentVariablesFunction + List?: ComponentVariablesFunction + Menu?: ComponentVariablesFunction + Text?: ComponentVariablesFunction } export interface IRenderer extends IFelaRenderer {}