diff --git a/packages/react-theming/.storybook/config.js b/packages/react-theming/.storybook/config.js new file mode 100644 index 0000000000..e79852979e --- /dev/null +++ b/packages/react-theming/.storybook/config.js @@ -0,0 +1,13 @@ +import { configure } from '@storybook/react'; +import { withInfo } from '@storybook/addon-info'; +import { addDecorator } from '@storybook/react'; + +addDecorator(withInfo()); + +const req = require.context('../src', true, /\.stories\.tsx$/); + +function loadStories() { + return req.keys().map(req); +} + +configure(loadStories, module); diff --git a/packages/react-theming/.storybook/webpack.config.js b/packages/react-theming/.storybook/webpack.config.js new file mode 100644 index 0000000000..a9f022ff45 --- /dev/null +++ b/packages/react-theming/.storybook/webpack.config.js @@ -0,0 +1,2 @@ +const webpackConfig = require('@fluentui/scripts/config/storybook/webpack.config'); +module.exports = webpackConfig; diff --git a/packages/react-theming/package.json b/packages/react-theming/package.json index e54f81e390..d8cbcc6dde 100644 --- a/packages/react-theming/package.json +++ b/packages/react-theming/package.json @@ -23,6 +23,7 @@ "build": "just-scripts build", "clean": "just-scripts clean", "just": "just-scripts", + "start": "just-scripts start", "start-test": "just-scripts test:watch", "test": "just-scripts test", "update-snapshots": "just-scripts jest:snapshots" diff --git a/packages/react-theming/src/compose.ts b/packages/react-theming/src/compose.ts index d752b47887..15b8861c13 100644 --- a/packages/react-theming/src/compose.ts +++ b/packages/react-theming/src/compose.ts @@ -1,16 +1,20 @@ -import { useTheme } from './themeContext'; -import { resolveTokens } from './resolveTokens'; import jss from 'jss'; + +import { resolveTokens } from './resolveTokens'; import { ITheme } from './theme.types'; +import { useTheme } from './themeContext'; +import { Variant } from './variant'; type Options = ComposeOptions[]; type SlotsAssignment = any; +type Variants = { [variantName: string]: Variant }; interface ComposeOptions { name?: string; slots?: any; tokens?: any; styles?: any; + variants?: Variants; } export interface Composeable { @@ -26,6 +30,7 @@ export type ForwardRefComponent = React.FunctionComponent< interface ComposedFunctionComponent extends React.FunctionComponent { __optionsSet?: ComposeOptions[]; __directRender?: React.FunctionComponent; + variants?: Variants; // Needed for components using forwardRef (See https://github.com/facebook/react/issues/12453). render?: React.FunctionComponent; @@ -60,7 +65,7 @@ export const _composeFactory = (useThemeHook: any = useTheme) => { return renderFn({ ...props, - classes: _getClasses(componentName, theme, classNamesCache, optionsSet), + classes: _getClasses(componentName, theme, classNamesCache, optionsSet, props), slots, }); }; @@ -70,6 +75,7 @@ export const _composeFactory = (useThemeHook: any = useTheme) => { } Component.propTypes = baseComponent.propTypes; + Component.variants = options.variants; Component.__optionsSet = optionsSet; Component.__directRender = renderFn; @@ -130,20 +136,39 @@ const _mergeOptions = (options: ComposeOptions, baseOptions?: Options): Options return optionsSet; }; +/** + * _tokensFromOptions returns the accurate set of tokens + * based on the currently rendered props. + * + * @internal + * + * @param options A set of options + * @param props Props for this render + */ +export const _tokensFromOptions = (options: any[], props: any) => { + let result = options.reduce((acc, option) => { + return { ...acc, ...(option.tokens || {}) }; + }, {}); + options.forEach(option => { + Object.keys(option.variants || {}).forEach(variantName => { + const v: Variant = option.variants[variantName]; + result = { ...result, ...v.tokens(props[variantName]) }; + }); + }); + return result; +}; + const _getClasses = ( name: string | undefined, theme: ITheme, classNamesCache: WeakMap, optionsSet: any[], + props: any, ) => { - let classes = classNamesCache.get(theme); + let classes = null; // classNamesCache.get(theme); if (!classes) { - const tokens = resolveTokens( - name, - theme, - optionsSet.map(o => o.tokens || {}), - ); + const tokens = resolveTokens(name, theme, _tokensFromOptions(optionsSet, props)); let styles: any = {}; optionsSet.forEach((options: any) => { diff --git a/packages/react-theming/src/examples/compose/complex.stories.tsx b/packages/react-theming/src/examples/compose/complex.stories.tsx new file mode 100644 index 0000000000..e8dd1c3967 --- /dev/null +++ b/packages/react-theming/src/examples/compose/complex.stories.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +import { ThemeProvider } from './../../components/ThemeProvider/ThemeProvider'; +import { compose } from './../../compose'; +import { Variant } from './../../variant'; +import { theme } from './../theme'; + +export default { + component: 'complex compose', + title: 'Slightly More Complex Compose Demos', +}; + +const BaseDisplay: React.FunctionComponent<{ + classes: any; + slots: any; + slotProps: any; + title: string; +}> = props => { + return ( +
+ {props.title} + {props.children} +
+ ); +}; + +const ComposedDisplay = compose(BaseDisplay as any, { + slots: { + header: 'h2', + }, + tokens: { + fontWeight: 300, + borderRadius: 0, + disabled: false, + }, + styles: (tokens: any) => { + const style: any = { + root: { + background: 'red', + borderRadius: tokens.borderRadius, + margin: 10, + padding: 10, + }, + header: { + fontWeight: tokens.fontWeight, + }, + }; + if (!tokens.disabled) { + style.root['&:hover'] = { + background: 'blue', + }; + style.header['&:hover'] = { + textDecoration: 'underline', + }; + } else { + style.root.background = '#ddd'; + style.root.color = '#333'; + } + return style; + }, + variants: { + disabled: Variant.boolean({ disabled: true }), + strong: Variant.boolean({ fontWeight: 700 }), + rounded: Variant.boolean({ borderRadius: 30 }), + }, +}); + +export const composedDemo = () => ( + + I am children + +); + +export const variantDemo = () => { + return ( + + Default control + + Strong variant + + + Rounded variant + + + Strong & rounded variants + + + Disabled variant + + + Strong & Disabled variant + + + Rounded & Disabled variant + + + Strong & Rounded & Disabled variants + + + ); +}; diff --git a/packages/react-theming/src/examples/compose/index.stories.tsx b/packages/react-theming/src/examples/compose/index.stories.tsx new file mode 100644 index 0000000000..accc092caf --- /dev/null +++ b/packages/react-theming/src/examples/compose/index.stories.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { ThemeProvider } from './../../components/ThemeProvider/ThemeProvider'; +import { compose } from './../../compose'; +import { Variant } from './../../variant'; +import { theme } from './../theme'; + +export default { + component: 'compose', + title: 'Compose Demos', +}; + +const BaseDiv: React.FunctionComponent<{ classes: any }> = props => { + return
{props.children}
; +}; + +const ComposedDiv = compose(BaseDiv as any, { + tokens: { + fontWeight: 300, + borderRadius: 0, + }, + styles: (tokens: any) => { + return { + root: { + background: 'red', + fontWeight: tokens.fontWeight, + borderRadius: tokens.borderRadius, + margin: 10, + padding: 10, + }, + }; + }, + variants: { + strong: Variant.boolean({ fontWeight: 700 }), + rounded: Variant.boolean({ borderRadius: 30 }), + }, +}); + +export const composedDemo = () => ( + + I am children + +); + +export const variantDemo = () => { + return ( + + Default control + Strong variant + Rounded variant + + Strong & rounded variants + + + ); +}; diff --git a/packages/react-theming/src/examples/theme.ts b/packages/react-theming/src/examples/theme.ts new file mode 100644 index 0000000000..8b2ae37123 --- /dev/null +++ b/packages/react-theming/src/examples/theme.ts @@ -0,0 +1,47 @@ +import { initializeStyling, IColorRamp, ITheme } from './../index'; + +initializeStyling(); + +const emptyRamp: IColorRamp = { values: [], index: -1 }; +export const theme: ITheme = { + components: {}, + colors: { + background: 'white', + bodyText: 'black', + subText: 'black', + disabledText: 'green', + brand: emptyRamp, + accent: emptyRamp, + neutral: emptyRamp, + success: emptyRamp, + warning: emptyRamp, + danger: emptyRamp, + }, + fonts: { + default: '', + userContent: '', + mono: '', + }, + fontSizes: { + base: 1, + scale: 1, + unit: 'rem', + }, + animations: { + fadeIn: {}, + fadeOut: {}, + }, + direction: 'ltr', + spacing: { + base: 0, + scale: 0, + unit: 'rem', + }, + radius: { + base: 0, + scale: 0, + unit: 'rem', + }, + icons: {}, + schemes: {}, +}; diff --git a/packages/react-theming/src/index.ts b/packages/react-theming/src/index.ts index f27630efab..f977bc4407 100644 --- a/packages/react-theming/src/index.ts +++ b/packages/react-theming/src/index.ts @@ -26,4 +26,4 @@ export { ThemeProvider } from './components/ThemeProvider/ThemeProvider'; export { Box } from './components/Box/Box'; export { createTheme } from './utilities/createTheme'; -jss.setup(preset()); +export const initializeStyling = () => jss.setup(preset()); diff --git a/packages/react-theming/src/resolveTokens.test.ts b/packages/react-theming/src/resolveTokens.test.ts index 035273aceb..57c401958c 100644 --- a/packages/react-theming/src/resolveTokens.test.ts +++ b/packages/react-theming/src/resolveTokens.test.ts @@ -24,7 +24,7 @@ const reifyColors = (partial: Partial): IThemeColorDefini describe('resolveTokens', () => { it('can resolve a literal', () => { - expect(resolveTokens('', reifyTheme({}), [{ value: 'abc' }])).toEqual({ + expect(resolveTokens('', reifyTheme({}), { value: 'abc' })).toEqual({ value: 'abc', }); }); @@ -41,26 +41,22 @@ describe('resolveTokens', () => { }, }), }), - [ - { - value: (_: any, t: ITheme) => t.colors.brand.values[t.colors.brand.index], - }, - ], + { + value: (_: any, t: ITheme) => t.colors.brand.values[t.colors.brand.index], + }, ), ).toEqual({ value: '#bbb' }); }); it('can resolve a token related to another', () => { expect( - resolveTokens('', reifyTheme({}), [ - { - value: 'abc', - value2: { - dependsOn: ['value'], - resolve: ([value]: any, theme: any) => `${value}def`, - }, + resolveTokens('', reifyTheme({}), { + value: 'abc', + value2: { + dependsOn: ['value'], + resolve: ([value]: any, theme: any) => `${value}def`, }, - ]), + }), ).toEqual({ value: 'abc', value2: 'abcdef' }); }); @@ -76,15 +72,13 @@ describe('resolveTokens', () => { }, }), }), - [ - { - value2: { - dependsOn: ['value'], - resolve: ([value]: any, theme: ITheme) => `${value}def`, - }, - value: (_: any, t: ITheme) => t.colors.brand.values[0], + { + value2: { + dependsOn: ['value'], + resolve: ([value]: any, theme: ITheme) => `${value}def`, }, - ], + value: (_: any, t: ITheme) => t.colors.brand.values[0], + }, ), ).toEqual({ value: '#aaa', value2: '#aaadef' }); }); @@ -100,7 +94,7 @@ describe('resolveTokens', () => { }, }, }); - expect(resolveTokens('MyComponent', theme, [{ value: 'foo' }])).toEqual({ + expect(resolveTokens('MyComponent', theme, { value: 'foo' })).toEqual({ value: 'bar', }); }); @@ -125,7 +119,7 @@ describe('resolveTokens', () => { resolve: ([value]: any, theme: ITheme) => `${value}foo`, }, }; - const result = resolveTokens('MyComponent', theme, [baseTokens]); + const result = resolveTokens('MyComponent', theme, baseTokens); expect(result).toEqual({ value: 'foo', value2: 'foobar' }); }); }); diff --git a/packages/react-theming/src/resolveTokens.ts b/packages/react-theming/src/resolveTokens.ts index c66a6a8752..93503e52b9 100644 --- a/packages/react-theming/src/resolveTokens.ts +++ b/packages/react-theming/src/resolveTokens.ts @@ -1,5 +1,6 @@ import { ITheme } from './theme.types'; +export type TokenDictShorthand = { [name: string]: any }; type TokenDict = { [name: string]: IToken }; interface IToken { @@ -13,7 +14,7 @@ class LiteralToken implements IToken { public isResolvable = true; public isResolved = true; - constructor(public name: string, public value: string | number) {} + constructor(public name: string, public value: string | number | boolean) {} resolve(theme: any): void {} } @@ -60,6 +61,7 @@ class TokenFactory { switch (typeof rawToken) { case 'string': case 'number': + case 'boolean': return new LiteralToken(name, rawToken); case 'function': return FunctionToken.fromFunction(tokens, name, rawToken); @@ -79,14 +81,12 @@ class TokenFactory { * @param sourceTokensSet * @internal */ -export const resolveTokens = (name: string | undefined, theme: ITheme, sourceTokensSet: any[]) => { +export const resolveTokens = (name: string | undefined, theme: ITheme, sourceTokens: any) => { const tokens: TokenDict = {}; - sourceTokensSet.forEach(sourceTokens => { - for (const tokenName in sourceTokens) { - tokens[tokenName] = TokenFactory.from(tokens, sourceTokens[tokenName], tokenName); - } - }); + for (const tokenName in sourceTokens) { + tokens[tokenName] = TokenFactory.from(tokens, sourceTokens[tokenName], tokenName); + } if (name && theme.components[name] && theme.components[name].tokens) { const sourceTokens = theme.components[name].tokens; diff --git a/packages/react-theming/src/variant.test.tsx b/packages/react-theming/src/variant.test.tsx new file mode 100644 index 0000000000..9e33851075 --- /dev/null +++ b/packages/react-theming/src/variant.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; + +import { _composeFactory, _tokensFromOptions } from './compose'; +import { ITheme } from './theme.types'; +import { Variant } from './variant'; + +const compose = _composeFactory(() => makeBlankTheme()); + +const reifyTheme = (partial: Partial): ITheme => { + const result = { components: {}, ...partial }; + + return result as ITheme; +}; + +const makeBlankTheme = (): ITheme => { + return reifyTheme({}); +}; + +const baseComponent = () => { + // In a function so that side effects from compose don't affect the next run + const c: React.FunctionComponent<{}> = (props: {}) => { + return
; + }; + return c; +}; + +describe('compose', () => { + describe('variants', () => { + describe('tokens', () => { + it('does not resolve tokens when variant not rendered', () => { + const myTokens = jest.fn(); + const component = compose(baseComponent(), { + variants: { test: Variant.boolean(myTokens) }, + }); + (component as any)({ test: false }); + expect(myTokens).not.toHaveBeenCalled(); + }); + + it.skip('resolves tokens', () => { + const myTokens = { test: () => jest.fn() }; + const component = compose(baseComponent(), { + variants: { test: Variant.boolean(myTokens) }, + }); + (component as any)({ test: true }); + expect(myTokens).toHaveBeenCalled(); + }); + + it.skip('renders styles with token values', () => { + let called = false; + const myStyles = (tokens: any) => { + expect(tokens).toEqual({ foo: 'bar' }); + called = true; + return {}; + }; + const myVariantTokens = { + foo: () => 'bar', + }; + const component = compose(baseComponent(), { + styles: myStyles, + tokens: { + foo: () => 'foo', + }, + variants: { test: Variant.boolean(myVariantTokens) }, + }); + (component as any)({ test: true }); + expect(called).toBeTruthy(); + }); + }); + + describe('_tokensFromOptions', () => { + it('returns all tokens from options', () => { + expect(_tokensFromOptions([{ tokens: { a: 'b' } }, { tokens: { c: 'd' } }], {})).toEqual({ + a: 'b', + c: 'd', + }); + }); + + it('does not return tokens from unused props', () => { + const options = [{ variants: { a: Variant.boolean({ x: 'x' }) } }]; + expect(_tokensFromOptions(options, { x: false })).toEqual({}); + }); + + it('returns options from used props', () => { + const options = [{ variants: { a: Variant.boolean({ x: 'x' }) } }]; + expect(_tokensFromOptions(options, { a: true })).toEqual({ x: 'x' }); + }); + + it('prefers variant tokens over plain tokens', () => { + const options = [{ tokens: { x: 'y' }, variants: { a: Variant.boolean({ x: 'x' }) } }]; + expect(_tokensFromOptions(options, { a: true })).toEqual({ x: 'x' }); + }); + + it('prefers variant tokens from earlier compositions over plain tokens from later composition', () => { + const options = [{ variants: { a: Variant.boolean({ x: 'x' }) } }, { tokens: { x: 'y' } }]; + expect(_tokensFromOptions(options, { a: true })).toEqual({ x: 'x' }); + }); + }); + }); +}); diff --git a/packages/react-theming/src/variant.ts b/packages/react-theming/src/variant.ts new file mode 100644 index 0000000000..71529494fb --- /dev/null +++ b/packages/react-theming/src/variant.ts @@ -0,0 +1,27 @@ +import { TokenDictShorthand } from './resolveTokens'; + +type TokenSet = { [tokenValue: string]: TokenDictShorthand }; + +export class Variant { + public tokenSets: TokenSet = {}; + + /** + * `value` returns the tokens that should be evaluated for the render pass of the component + * + * @param props props used to render component + */ + tokens(prop: any): any { + return this.tokenSets[JSON.stringify(prop)] || {}; + } + + static boolean(tokens: TokenDictShorthand) { + const result = new Variant(); + result.tokenSets[JSON.stringify(false)] = {}; + result.tokenSets[JSON.stringify(true)] = tokens; + return result; + } + + static identity(): Variant { + return new Variant(); + } +}