diff --git a/modules/labs-react/tsconfig.json b/modules/labs-react/tsconfig.json index e9316a76f2..fef8366275 100644 --- a/modules/labs-react/tsconfig.json +++ b/modules/labs-react/tsconfig.json @@ -6,7 +6,11 @@ { "transform": "../styling-transform/lib/styleTransform.ts", "prefix": "css", - "fallbackFiles": [] + "fallbackFiles": [ + "@workday/canvas-tokens-web/css/base/_variables.css", + "@workday/canvas-tokens-web/css/brand/_variables.css", + "@workday/canvas-tokens-web/css/system/_variables.css" + ] } ] } diff --git a/modules/preview-react/form-field/lib/FormFieldLabel.tsx b/modules/preview-react/form-field/lib/FormFieldLabel.tsx index 349370e62d..3c4d619541 100644 --- a/modules/preview-react/form-field/lib/FormFieldLabel.tsx +++ b/modules/preview-react/form-field/lib/FormFieldLabel.tsx @@ -4,7 +4,7 @@ import {createSubcomponent, ExtractProps} from '@workday/canvas-kit-react/common import {type, space} from '@workday/canvas-kit-react/tokens'; import {LabelText, Text} from '@workday/canvas-kit-react/text'; import {useFormFieldLabel, useFormFieldModel} from './hooks'; -import {createStyles, cssVar} from '@workday/canvas-kit-styling'; +import {createStyles} from '@workday/canvas-kit-styling'; import {mergeStyles} from '@workday/canvas-kit-react/layout'; import {brand} from '@workday/canvas-tokens-web'; @@ -25,7 +25,7 @@ const asteriskStyles = createStyles({ fontSize: type.properties.fontSizes[20], fontWeight: type.properties.fontWeights.regular, textDecoration: 'unset', - color: cssVar(brand.error.base, '#de2e21'), + color: brand.error.base, }); export const FormFieldLabel = createSubcomponent(LabelText)({ diff --git a/modules/preview-react/tsconfig.json b/modules/preview-react/tsconfig.json index 8e08f79fa0..4fcf6c7ad1 100644 --- a/modules/preview-react/tsconfig.json +++ b/modules/preview-react/tsconfig.json @@ -2,6 +2,16 @@ "extends": "../../tsconfig.json", "exclude": ["node_modules", "ts-tmp", "dist", "**/spec", "**/stories"], "compilerOptions": { - "plugins": [{"transform": "../modules/styling/parser/styleParser.ts", "prefix": "css"}] + "plugins": [ + { + "transform": "../modules/styling/parser/styleParser.ts", + "prefix": "css", + "fallbackFiles": [ + "@workday/canvas-tokens-web/css/base/_variables.css", + "@workday/canvas-tokens-web/css/brand/_variables.css", + "@workday/canvas-tokens-web/css/system/_variables.css" + ] + } + ] } } diff --git a/modules/react/button/lib/BaseButton.tsx b/modules/react/button/lib/BaseButton.tsx index aea2a52940..be836fb236 100644 --- a/modules/react/button/lib/BaseButton.tsx +++ b/modules/react/button/lib/BaseButton.tsx @@ -121,10 +121,10 @@ const baseButtonStyles = createStyles({ letterSpacing: '0.015rem', fontWeight: 'bold', backgroundColor: cssVar(buttonVars.default.background, 'transparent'), - color: cssVar(buttonVars.default.label, cssVar(base.blackPepper400, '#333333')), + color: cssVar(buttonVars.default.label, base.blackPepper400), borderWidth: '1px', borderStyle: 'solid', - gap: cssVar(system.space.x2, '0.5rem'), + gap: system.space.x2, borderColor: cssVar(buttonVars.default.border, 'transparent'), cursor: 'pointer', display: 'inline-flex', @@ -136,7 +136,7 @@ const baseButtonStyles = createStyles({ whiteSpace: 'nowrap', WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', - borderRadius: cssVar(buttonVars.default.borderRadius, cssVar(system.shape.round, '62.5rem')), + borderRadius: cssVar(buttonVars.default.borderRadius, system.shape.round), position: 'relative', verticalAlign: 'middle', overflow: 'hidden', @@ -149,63 +149,60 @@ const baseButtonStyles = createStyles({ }, '& span .wd-icon-fill': { transitionDuration: '40ms', - fill: cssVar(buttonVars.default.icon, cssVar(base.blackPepper400, '#333333')), + fill: cssVar(buttonVars.default.icon, base.blackPepper400), }, '.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': { - fill: cssVar(buttonVars.default.icon, cssVar(base.blackPepper400, '#333333')), + fill: cssVar(buttonVars.default.icon, base.blackPepper400), }, '&:focus-visible, &.focus': { backgroundColor: cssVar(buttonVars.focus.background, 'transparent'), borderColor: cssVar(buttonVars.focus.border, 'transparent'), - color: cssVar(buttonVars.focus.label, cssVar(base.blackPepper400, '#333333')), + color: cssVar(buttonVars.focus.label, base.blackPepper400), '& span .wd-icon-fill': { - fill: cssVar(buttonVars.focus.icon, cssVar(base.blackPepper400, '#333333')), + fill: cssVar(buttonVars.focus.icon, base.blackPepper400), }, '.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': { - fill: cssVar(buttonVars.focus.icon, cssVar(base.blackPepper400, '#333333')), + fill: cssVar(buttonVars.focus.icon, base.blackPepper400), }, ...focusRing({ width: 2, separation: 2, - innerColor: cssVar(buttonVars.focus.boxShadowInner, cssVar(base.frenchVanilla100, '#fff')), - outerColor: cssVar( - buttonVars.focus.boxShadowOuter, - cssVar(brand.primary.base, 'rgba(0,92,184,1)') - ), + innerColor: cssVar(buttonVars.focus.boxShadowInner, base.frenchVanilla100), + outerColor: cssVar(buttonVars.focus.boxShadowOuter, brand.primary.base), }), }, '&:hover, &.hover': { - backgroundColor: cssVar(buttonVars.hover.background, cssVar(base.blackPepper500, '#1e1e1e')), + backgroundColor: cssVar(buttonVars.hover.background, base.blackPepper500), borderColor: cssVar(buttonVars.hover.border, 'transparent'), - color: cssVar(buttonVars.hover.label, cssVar(base.blackPepper500, '#1e1e1e')), + color: cssVar(buttonVars.hover.label, base.blackPepper500), '& span .wd-icon-fill': { - fill: cssVar(buttonVars.hover.icon, cssVar(base.blackPepper500, '#1e1e1e')), + fill: cssVar(buttonVars.hover.icon, base.blackPepper500), }, '.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': { - fill: cssVar(buttonVars.hover.icon, cssVar(base.blackPepper500, '#1e1e1e')), + fill: cssVar(buttonVars.hover.icon, base.blackPepper500), }, }, '&:hover:active': {transitionDuration: '40ms'}, '&:active, &.active': { backgroundColor: cssVar(buttonVars.active.background, 'transparent'), borderColor: cssVar(buttonVars.active.border, 'transparent'), - color: cssVar(buttonVars.active.label, cssVar(base.blackPepper400, '#333333')), + color: cssVar(buttonVars.active.label, base.blackPepper400), '& span .wd-icon-fill': { - fill: cssVar(buttonVars.active.icon, cssVar(base.blackPepper400, '#333333')), + fill: cssVar(buttonVars.active.icon, base.blackPepper400), }, '.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': { - fill: cssVar(buttonVars.active.icon), + fill: cssVar(buttonVars.active.icon, base.blackPepper400), }, }, '&:disabled, &.disabled': { backgroundColor: cssVar(buttonVars.disabled.background, 'transparent'), borderColor: cssVar(buttonVars.disabled.border, 'transparent'), - color: cssVar(buttonVars.disabled.label, cssVar(base.blackPepper400, '#333333')), + color: cssVar(buttonVars.disabled.label, base.blackPepper400), '& span .wd-icon-fill': { - fill: cssVar(buttonVars.disabled.icon, cssVar(base.blackPepper400, '#333333')), + fill: cssVar(buttonVars.disabled.icon, base.blackPepper400), }, '.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': { - fill: cssVar(buttonVars.disabled.icon, cssVar(base.blackPepper400, '#333333')), + fill: cssVar(buttonVars.disabled.icon, base.blackPepper400), }, }, }); @@ -219,77 +216,77 @@ const baseButtonStyles = createStyles({ export const buttonModifiers = createModifiers({ size: { large: createStyles({ - fontSize: cssVar(system.space.x4, '1rem'), - lineHeight: cssVar(system.space.x6, '1.5rem'), + fontSize: system.space.x4, + lineHeight: system.space.x6, letterSpacing: '0.01rem', height: '48px', - paddingInline: cssVar(system.space.x8, '2rem'), + paddingInline: system.space.x8, minWidth: '112px', }), medium: createStyles({ fontSize: '0.875rem', letterSpacing: '0.015rem', minWidth: '96px', - paddingInline: cssVar(system.space.x6, '1.5rem'), - height: cssVar(system.space.x10, '2.5rem'), + paddingInline: system.space.x6, + height: system.space.x10, }), small: createStyles({ fontSize: '0.875rem', letterSpacing: '0.015rem', - height: cssVar(system.space.x8, '2rem'), - minWidth: cssVar(system.space.x20, '5rem'), - paddingInline: cssVar(system.space.x4, '1rem'), - gap: cssVar(system.space.x1, '0.25rem'), + height: system.space.x8, + minWidth: system.space.x20, + paddingInline: system.space.x4, + gap: system.space.x1, }), extraSmall: createStyles({ fontSize: '0.75rem', - lineHeight: cssVar(system.space.x4, '1rem'), + lineHeight: system.space.x4, letterSpacing: '0.02rem', - height: cssVar(system.space.x6, '1.5rem'), + height: system.space.x6, minWidth: 'auto', - paddingInline: cssVar(system.space.x3, '0.75rem'), - gap: cssVar(system.space.x1, '0.25rem'), + paddingInline: system.space.x3, + gap: system.space.x1, }), }, iconPosition: { largeOnly: createStyles({ padding: '0', - minWidth: `calc(${cssVar(system.space.x4, '1rem')} * 3)`, + minWidth: `calc(${system.space.x4} * 3)`, }), largeStart: createStyles({ - paddingInlineStart: cssVar(system.space.x6, '1.5rem'), - paddingInlineEnd: cssVar(system.space.x8, '2rem'), + paddingInlineStart: system.space.x6, + paddingInlineEnd: system.space.x8, }), largeEnd: createStyles({ - paddingInlineStart: cssVar(system.space.x8, '2rem'), - paddingInlineEnd: cssVar(system.space.x6, '1.5rem'), + paddingInlineStart: system.space.x8, + paddingInlineEnd: system.space.x6, }), - mediumOnly: createStyles({padding: '0', minWidth: cssVar(system.space.x10, '2.5rem')}), + mediumOnly: createStyles({padding: '0', minWidth: system.space.x10}), mediumStart: createStyles({ - paddingInlineStart: `calc(${cssVar(system.space.x1, '0.25rem')} * 5)`, - paddingInlineEnd: cssVar(system.space.x6, '1.5rem'), + paddingInlineStart: `calc(${system.space.x1} * 5)`, + paddingInlineEnd: system.space.x6, }), mediumEnd: createStyles({ - paddingInlineStart: cssVar(system.space.x6, '1.5rem'), - paddingInlineEnd: `calc(${cssVar(system.space.x1, '0.25rem')} * 5)`, + paddingInlineStart: system.space.x6, + paddingInlineEnd: `calc(${system.space.x1} * 5)`, }), - smallOnly: createStyles({padding: '0', minWidth: cssVar(system.space.x8, '2rem')}), + smallOnly: createStyles({padding: '0', minWidth: system.space.x8}), smallStart: createStyles({ - paddingInlineStart: cssVar(system.space.x3, '0.75rem'), - paddingInlineEnd: cssVar(system.space.x4, '1rem'), + paddingInlineStart: system.space.x3, + paddingInlineEnd: system.space.x4, }), smallEnd: createStyles({ - paddingInlineStart: cssVar(system.space.x4, '1rem'), - paddingInlineEnd: cssVar(system.space.x3, '0.75rem'), + paddingInlineStart: system.space.x4, + paddingInlineEnd: system.space.x3, }), - extraSmallOnly: createStyles({padding: '0', minWidth: cssVar(system.space.x6, '1.5rem')}), + extraSmallOnly: createStyles({padding: '0', minWidth: system.space.x6}), extraSmallStart: createStyles({ - paddingInlineStart: cssVar(system.space.x2, '0.5rem'), - paddingInlineEnd: cssVar(system.space.x3, '0.75rem'), + paddingInlineStart: system.space.x2, + paddingInlineEnd: system.space.x3, }), extraSmallEnd: createStyles({ - paddingInlineStart: cssVar(system.space.x3, '0.75rem'), - paddingInlineEnd: cssVar(system.space.x2, '0.5rem'), + paddingInlineStart: system.space.x3, + paddingInlineEnd: system.space.x2, }), }, }); diff --git a/modules/react/button/lib/DeleteButton.tsx b/modules/react/button/lib/DeleteButton.tsx index 56c412ee65..777e40e11d 100644 --- a/modules/react/button/lib/DeleteButton.tsx +++ b/modules/react/button/lib/DeleteButton.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import {buttonVars} from './BaseButton'; import {createComponent} from '@workday/canvas-kit-react/common'; -import {createStyles, cssVar} from '@workday/canvas-kit-styling'; +import {createStyles} from '@workday/canvas-kit-styling'; import {mergeStyles} from '@workday/canvas-kit-react/layout'; import {base, brand, system} from '@workday/canvas-tokens-web'; import {Button, ButtonProps} from './Button'; @@ -15,35 +15,35 @@ import {Button, ButtonProps} from './Button'; export interface DeleteButtonProps extends ButtonProps {} const deleteStyles = createStyles({ - [buttonVars.default.background]: cssVar(brand.error.base, '#de2e21'), + [buttonVars.default.background]: brand.error.base, [buttonVars.default.border]: 'transparent', - [buttonVars.default.borderRadius]: cssVar(system.shape.round, '62.5rem'), - [buttonVars.default.label]: cssVar(brand.error.accent, '#ffffff'), - [buttonVars.default.icon]: cssVar(brand.error.accent, '#ffffff'), + [buttonVars.default.borderRadius]: system.shape.round, + [buttonVars.default.label]: brand.error.accent, + [buttonVars.default.icon]: brand.error.accent, '&:hover, &.hover': { - [buttonVars.hover.background]: cssVar(brand.error.dark, '#a31b12'), + [buttonVars.hover.background]: brand.error.dark, [buttonVars.hover.border]: 'transparent', - [buttonVars.hover.label]: cssVar(brand.error.accent, '#ffffff'), - [buttonVars.hover.icon]: cssVar(brand.error.accent, '#ffffff'), + [buttonVars.hover.label]: brand.error.accent, + [buttonVars.hover.icon]: brand.error.accent, }, '&:focus-visible, &.focus': { - [buttonVars.focus.background]: cssVar(brand.error.base, '#de2e21'), + [buttonVars.focus.background]: brand.error.base, [buttonVars.focus.border]: 'transparent', - [buttonVars.focus.label]: cssVar(brand.error.accent, '#ffffff'), - [buttonVars.focus.icon]: cssVar(brand.error.accent, '#ffffff'), - [buttonVars.focus.boxShadowInner]: cssVar(base.frenchVanilla100, '#ffffff'), - [buttonVars.focus.boxShadowOuter]: cssVar(brand.common.focusOutline, '#0875e1'), + [buttonVars.focus.label]: brand.error.accent, + [buttonVars.focus.icon]: brand.error.accent, + [buttonVars.focus.boxShadowInner]: base.frenchVanilla100, + [buttonVars.focus.boxShadowOuter]: brand.common.focusOutline, }, '&:active, &.active': { - [buttonVars.active.background]: cssVar(brand.error.darkest, 'rgba(128,22,14,1)'), + [buttonVars.active.background]: brand.error.darkest, [buttonVars.active.border]: 'transparent', - [buttonVars.active.label]: cssVar(brand.error.accent, '#ffffff'), - [buttonVars.active.icon]: cssVar(brand.error.accent, '#ffffff'), + [buttonVars.active.label]: brand.error.accent, + [buttonVars.active.icon]: brand.error.accent, }, '&:disabled, &:active:disabled, &:focus:disabled, &:hover:disabled': { - [buttonVars.disabled.background]: cssVar(brand.error.light, '#fcc9c5'), - [buttonVars.disabled.label]: cssVar(brand.error.accent, '#ffffff'), - [buttonVars.disabled.icon]: cssVar(brand.error.accent, '#ffffff'), + [buttonVars.disabled.background]: brand.error.light, + [buttonVars.disabled.label]: brand.error.accent, + [buttonVars.disabled.icon]: brand.error.accent, opacity: 1, }, }); diff --git a/modules/react/button/lib/PrimaryButton.tsx b/modules/react/button/lib/PrimaryButton.tsx index 78c58050e3..1f5b29a642 100644 --- a/modules/react/button/lib/PrimaryButton.tsx +++ b/modules/react/button/lib/PrimaryButton.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import {buttonVars} from './BaseButton'; import {createComponent} from '@workday/canvas-kit-react/common'; import {mergeStyles} from '@workday/canvas-kit-react/layout'; -import {createStyles, cssVar, createModifiers} from '@workday/canvas-kit-styling'; +import {createStyles, createModifiers} from '@workday/canvas-kit-styling'; import {base, brand, system} from '@workday/canvas-tokens-web'; import {Button, ButtonProps} from './Button'; @@ -21,62 +21,62 @@ export interface PrimaryButtonProps extends ButtonProps { const primaryStyles = createStyles({ // Default Styles - [buttonVars.default.background]: cssVar(brand.primary.base, cssVar(base.blueberry400, '#0875e1')), + [buttonVars.default.background]: brand.primary.base, [buttonVars.default.border]: 'transparent', - [buttonVars.default.borderRadius]: cssVar(system.shape.round, '62.5rem'), - [buttonVars.default.label]: cssVar(brand.primary.accent, '#ffffff'), - [buttonVars.default.icon]: cssVar(brand.primary.accent, '#ffffff'), + [buttonVars.default.borderRadius]: system.shape.round, + [buttonVars.default.label]: brand.primary.accent, + [buttonVars.default.icon]: brand.primary.accent, // Hover Styles - [buttonVars.hover.background]: cssVar(brand.primary.dark, 'rgba(0,92,184,1)'), + [buttonVars.hover.background]: brand.primary.dark, [buttonVars.hover.border]: 'transparent', - [buttonVars.hover.label]: cssVar(brand.primary.accent, '#ffffff'), - [buttonVars.hover.icon]: cssVar(brand.primary.accent, '#ffffff'), + [buttonVars.hover.label]: brand.primary.accent, + [buttonVars.hover.icon]: brand.primary.accent, // Focus Styles - [buttonVars.focus.background]: cssVar(brand.primary.base, 'rgba(8,117,226,1)'), + [buttonVars.focus.background]: brand.primary.base, [buttonVars.focus.border]: 'transparent', - [buttonVars.focus.label]: cssVar(brand.primary.accent, '#ffffff'), - [buttonVars.focus.icon]: cssVar(brand.primary.accent, '#ffffff'), - [buttonVars.focus.boxShadowInner]: cssVar(base.frenchVanilla100, '#ffffff'), - [buttonVars.focus.boxShadowOuter]: cssVar(brand.common.focusOutline, 'rgba(8,117,226,1)'), + [buttonVars.focus.label]: brand.primary.accent, + [buttonVars.focus.icon]: brand.primary.accent, + [buttonVars.focus.boxShadowInner]: base.frenchVanilla100, + [buttonVars.focus.boxShadowOuter]: brand.common.focusOutline, // Active Styles - [buttonVars.active.background]: cssVar(brand.primary.darkest, 'rgba(0,66,133,1)'), + [buttonVars.active.background]: brand.primary.darkest, [buttonVars.active.border]: 'transparent', - [buttonVars.active.label]: cssVar(brand.primary.accent, '#ffffff'), - [buttonVars.active.icon]: cssVar(brand.primary.accent, '#ffffff'), + [buttonVars.active.label]: brand.primary.accent, + [buttonVars.active.icon]: brand.primary.accent, // Disabled Styles - [buttonVars.disabled.background]: cssVar(brand.primary.base, 'rgba(8,117,226,1)'), + [buttonVars.disabled.background]: brand.primary.base, [buttonVars.disabled.border]: 'transparent', - [buttonVars.disabled.label]: cssVar(brand.primary.accent, '#ffffff'), + [buttonVars.disabled.label]: brand.primary.accent, [buttonVars.disabled.opacity]: '0.4', - [buttonVars.disabled.icon]: cssVar(brand.primary.accent, '#ffffff'), + [buttonVars.disabled.icon]: brand.primary.accent, }); export const primaryButtonModifiers = createModifiers({ variant: { inverse: createStyles({ // Default Styles - [buttonVars.default.background]: cssVar(base.frenchVanilla100, '#ffffff'), - [buttonVars.default.borderRadius]: cssVar(system.shape.round, '62.5rem'), - [buttonVars.default.label]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.default.icon]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), + [buttonVars.default.background]: base.frenchVanilla100, + [buttonVars.default.borderRadius]: system.shape.round, + [buttonVars.default.label]: base.blackPepper400, + [buttonVars.default.icon]: base.blackPepper400, // Hover Styles - [buttonVars.hover.background]: cssVar(base.soap300, 'rgba(232,235,237,1)'), - [buttonVars.hover.label]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.hover.icon]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), + [buttonVars.hover.background]: base.soap300, + [buttonVars.hover.label]: base.blackPepper500, + [buttonVars.hover.icon]: base.blackPepper500, // Focus Styles - [buttonVars.focus.background]: cssVar(base.frenchVanilla100, '#ffffff'), - [buttonVars.focus.label]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.focus.icon]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.focus.boxShadowInner]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.focus.boxShadowOuter]: cssVar(base.frenchVanilla100, '#ffffff'), + [buttonVars.focus.background]: base.frenchVanilla100, + [buttonVars.focus.label]: base.blackPepper400, + [buttonVars.focus.icon]: base.blackPepper400, + [buttonVars.focus.boxShadowInner]: base.blackPepper400, + [buttonVars.focus.boxShadowOuter]: base.frenchVanilla100, // Active Styles - [buttonVars.active.background]: cssVar(base.soap400, 'rgba(224,227,230,1)'), - [buttonVars.active.label]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.active.icon]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), + [buttonVars.active.background]: base.soap400, + [buttonVars.active.label]: base.blackPepper500, + [buttonVars.active.icon]: base.blackPepper500, // Disabled Styles - [buttonVars.disabled.background]: cssVar(base.frenchVanilla100, '#ffffff'), - [buttonVars.disabled.label]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.disabled.icon]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), + [buttonVars.disabled.background]: base.frenchVanilla100, + [buttonVars.disabled.label]: base.blackPepper400, + [buttonVars.disabled.icon]: base.blackPepper400, }), }, }); diff --git a/modules/react/button/lib/SecondaryButton.tsx b/modules/react/button/lib/SecondaryButton.tsx index 93441b6a68..ad199a6baf 100644 --- a/modules/react/button/lib/SecondaryButton.tsx +++ b/modules/react/button/lib/SecondaryButton.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import {buttonVars} from './BaseButton'; import {createComponent} from '@workday/canvas-kit-react/common'; import {mergeStyles} from '@workday/canvas-kit-react/layout'; -import {createStyles, cssVar, createModifiers} from '@workday/canvas-kit-styling'; +import {createStyles, createModifiers} from '@workday/canvas-kit-styling'; import {base, brand, system} from '@workday/canvas-tokens-web'; import {Button, ButtonProps} from './Button'; @@ -22,33 +22,33 @@ export interface SecondaryButtonProps extends ButtonProps { const secondaryStyles = createStyles({ // Default Styles [buttonVars.default.background]: 'transparent', - [buttonVars.default.border]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.default.borderRadius]: cssVar(system.shape.round, '62.5rem'), - [buttonVars.default.label]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.default.icon]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), + [buttonVars.default.border]: base.blackPepper400, + [buttonVars.default.borderRadius]: system.shape.round, + [buttonVars.default.label]: base.blackPepper400, + [buttonVars.default.icon]: base.blackPepper400, // Hover Styles - [buttonVars.hover.background]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.hover.border]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.hover.label]: cssVar(brand.primary.accent, 'rgba(255,255,255,1)'), - [buttonVars.hover.icon]: cssVar(brand.primary.accent, 'rgba(255,255,255,1)'), + [buttonVars.hover.background]: base.blackPepper400, + [buttonVars.hover.border]: base.blackPepper400, + [buttonVars.hover.label]: brand.primary.accent, + [buttonVars.hover.icon]: brand.primary.accent, // Focus Styles [buttonVars.focus.background]: 'transparent', - [buttonVars.focus.border]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.focus.label]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.focus.icon]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.focus.boxShadowInner]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), - [buttonVars.focus.boxShadowOuter]: cssVar(brand.common.focusOutline, 'rgba(8,117,226,1)'), + [buttonVars.focus.border]: base.blackPepper400, + [buttonVars.focus.label]: base.blackPepper400, + [buttonVars.focus.icon]: base.blackPepper400, + [buttonVars.focus.boxShadowInner]: base.frenchVanilla100, + [buttonVars.focus.boxShadowOuter]: brand.common.focusOutline, // Active Styles - [buttonVars.active.background]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.active.border]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.active.label]: cssVar(brand.primary.accent, 'rgba(255,255,255,1)'), - [buttonVars.active.icon]: cssVar(brand.primary.accent, 'rgba(255,255,255,1)'), + [buttonVars.active.background]: base.blackPepper500, + [buttonVars.active.border]: base.blackPepper500, + [buttonVars.active.label]: brand.primary.accent, + [buttonVars.active.icon]: brand.primary.accent, // Disabled Styles [buttonVars.disabled.background]: 'transparent', - [buttonVars.disabled.border]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), - [buttonVars.disabled.label]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), + [buttonVars.disabled.border]: base.blackPepper400, + [buttonVars.disabled.label]: base.blackPepper400, [buttonVars.disabled.opacity]: '0.4', - [buttonVars.disabled.icon]: cssVar(base.blackPepper400, 'rgba(51,51,51,1)'), + [buttonVars.disabled.icon]: base.blackPepper400, }); export const secondaryButtonModifiers = createModifiers({ @@ -56,31 +56,31 @@ export const secondaryButtonModifiers = createModifiers({ inverse: createStyles({ // Default Styles [buttonVars.default.background]: 'transparent', - [buttonVars.default.border]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), - [buttonVars.default.label]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), - [buttonVars.default.icon]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), + [buttonVars.default.border]: base.frenchVanilla100, + [buttonVars.default.label]: base.frenchVanilla100, + [buttonVars.default.icon]: base.frenchVanilla100, // Hover Styles - [buttonVars.hover.background]: cssVar(base.soap300, 'rgba(232,235,237,1)'), - [buttonVars.hover.border]: cssVar(base.soap300, 'rgba(232,235,237,1)'), - [buttonVars.hover.label]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.hover.icon]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), + [buttonVars.hover.background]: base.soap300, + [buttonVars.hover.border]: base.soap300, + [buttonVars.hover.label]: base.blackPepper500, + [buttonVars.hover.icon]: base.blackPepper500, // Focus Styles - [buttonVars.focus.background]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), - [buttonVars.focus.border]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), - [buttonVars.focus.label]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.focus.icon]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.focus.boxShadowInner]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.focus.boxShadowOuter]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), + [buttonVars.focus.background]: base.frenchVanilla100, + [buttonVars.focus.border]: base.frenchVanilla100, + [buttonVars.focus.label]: base.blackPepper500, + [buttonVars.focus.icon]: base.blackPepper500, + [buttonVars.focus.boxShadowInner]: base.blackPepper500, + [buttonVars.focus.boxShadowOuter]: base.frenchVanilla100, // Active Styles - [buttonVars.active.background]: cssVar(base.soap400, 'rgba(224,227,230,1)'), - [buttonVars.active.border]: cssVar(base.soap400, 'rgba(224,227,230,1)'), - [buttonVars.active.label]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.active.icon]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), + [buttonVars.active.background]: base.soap400, + [buttonVars.active.border]: base.soap400, + [buttonVars.active.label]: base.blackPepper500, + [buttonVars.active.icon]: base.blackPepper500, // Disabled Styles [buttonVars.disabled.background]: 'transparent', - [buttonVars.disabled.border]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), - [buttonVars.disabled.label]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), - [buttonVars.disabled.icon]: cssVar(base.frenchVanilla100, 'rgba(255,255,255,1)'), + [buttonVars.disabled.border]: base.frenchVanilla100, + [buttonVars.disabled.label]: base.frenchVanilla100, + [buttonVars.disabled.icon]: base.frenchVanilla100, }), }, }); diff --git a/modules/react/button/lib/TertiaryButton.tsx b/modules/react/button/lib/TertiaryButton.tsx index 3dea3cb2d0..6f11d01474 100644 --- a/modules/react/button/lib/TertiaryButton.tsx +++ b/modules/react/button/lib/TertiaryButton.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import {buttonVars, getIconPosition} from './BaseButton'; import {createComponent, focusRing} from '@workday/canvas-kit-react/common'; -import {createStyles, cssVar, createModifiers} from '@workday/canvas-kit-styling'; +import {createStyles, createModifiers} from '@workday/canvas-kit-styling'; import {mergeStyles} from '@workday/canvas-kit-react/layout'; import {system, brand, base} from '@workday/canvas-tokens-web'; import {borderRadius, space} from '@workday/canvas-kit-react/tokens'; @@ -22,46 +22,46 @@ export interface TertiaryButtonProps extends ButtonProps { } const tertiaryStyles = createStyles({ - paddingInline: cssVar(system.space.x2, '0.5rem'), + paddingInline: system.space.x2, minWidth: 'auto', textDecoration: 'underline', border: 0, // Default Styles [buttonVars.default.background]: 'transparent', [buttonVars.default.border]: 'transparent', - [buttonVars.default.borderRadius]: cssVar(system.shape.x1, '0.25rem'), - [buttonVars.default.label]: cssVar(brand.primary.base, 'rgba(8,117,226,1)'), - [buttonVars.default.icon]: cssVar(base.blackPepper400, 'rgba(51, 51, 51, 1)'), + [buttonVars.default.borderRadius]: system.shape.x1, + [buttonVars.default.label]: brand.primary.base, + [buttonVars.default.icon]: base.blackPepper400, // Hover Styles - [buttonVars.hover.background]: cssVar(base.soap200, 'rgba(241, 242, 243, 1)'), + [buttonVars.hover.background]: base.soap200, [buttonVars.hover.border]: 'transparent', - [buttonVars.hover.label]: cssVar(brand.primary.dark, 'rgba(0,92,184,1)'), - [buttonVars.hover.icon]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), + [buttonVars.hover.label]: brand.primary.dark, + [buttonVars.hover.icon]: base.blackPepper500, // Focus Styles [buttonVars.focus.background]: 'transparent', [buttonVars.focus.border]: 'transparent', - [buttonVars.focus.label]: cssVar(brand.primary.base, 'rgba(8,117,226,1)'), - [buttonVars.focus.icon]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), - [buttonVars.focus.boxShadowInner]: cssVar(brand.common.focusOutline, 'rgba(8,117,226,1)'), - [buttonVars.focus.boxShadowOuter]: cssVar(brand.common.focusOutline, 'rgba(8,117,226,1)'), + [buttonVars.focus.label]: brand.primary.base, + [buttonVars.focus.icon]: base.blackPepper500, + [buttonVars.focus.boxShadowInner]: brand.common.focusOutline, + [buttonVars.focus.boxShadowOuter]: brand.common.focusOutline, // Active Styles - [buttonVars.active.background]: cssVar(base.soap300, 'rgba(232,235,237,1)'), + [buttonVars.active.background]: base.soap300, [buttonVars.active.border]: 'transparent', - [buttonVars.active.label]: cssVar(brand.primary.dark, 'rgba(0,92,184,1)'), - [buttonVars.active.icon]: cssVar(base.blackPepper500, 'rgba(31,31,31,1)'), + [buttonVars.active.label]: brand.primary.dark, + [buttonVars.active.icon]: base.blackPepper500, // Disabled Styles [buttonVars.disabled.background]: 'transparent', - [buttonVars.disabled.border]: cssVar(base.frenchVanilla100, '#fff'), - [buttonVars.disabled.label]: cssVar(brand.primary.base, 'rgba(8,117,226,1)'), - [buttonVars.disabled.icon]: cssVar(base.blackPepper400, 'rgba(51, 51, 51, 1)'), + [buttonVars.disabled.border]: base.frenchVanilla100, + [buttonVars.disabled.label]: brand.primary.base, + [buttonVars.disabled.icon]: base.blackPepper400, [buttonVars.disabled.opacity]: '0.4', '&:focus-visible, &.focus': { ...focusRing({ width: 2, separation: 0, - innerColor: cssVar(base.frenchVanilla100, cssVar(buttonVars.focus.boxShadowInner)), - outerColor: cssVar(brand.common.focusOutline, cssVar(buttonVars.focus.boxShadowOuter)), + innerColor: base.frenchVanilla100, + outerColor: brand.common.focusOutline, }), }, }); @@ -69,11 +69,11 @@ const tertiaryStyles = createStyles({ export const tertiaryButtonModifiers = createModifiers({ isThemeable: { true: createStyles({ - [buttonVars.default.icon]: cssVar(brand.primary.base, 'rgba(8,117,226,1)'), - [buttonVars.hover.icon]: cssVar(brand.primary.dark, 'rgba(0,92,184,1)'), - [buttonVars.focus.icon]: cssVar(brand.primary.base, 'rgba(8,117,226,1)'), - [buttonVars.active.icon]: cssVar(brand.primary.dark, 'rgba(0,92,184,1)'), - [buttonVars.disabled.icon]: cssVar(brand.primary.base, 'rgba(8,117,226,1)'), + [buttonVars.default.icon]: brand.primary.base, + [buttonVars.hover.icon]: brand.primary.dark, + [buttonVars.focus.icon]: brand.primary.base, + [buttonVars.active.icon]: brand.primary.dark, + [buttonVars.disabled.icon]: brand.primary.base, }), }, variant: { @@ -81,36 +81,36 @@ export const tertiaryButtonModifiers = createModifiers({ // Default Styles [buttonVars.default.background]: 'transparent', [buttonVars.default.border]: 'transparent', - [buttonVars.default.label]: cssVar(base.frenchVanilla100, '#fff'), - [buttonVars.default.icon]: cssVar(base.frenchVanilla100, '#fff'), + [buttonVars.default.label]: base.frenchVanilla100, + [buttonVars.default.icon]: base.frenchVanilla100, // Hover Styles - [buttonVars.hover.background]: cssVar(base.frenchVanilla100, '#fff'), + [buttonVars.hover.background]: base.frenchVanilla100, [buttonVars.hover.border]: 'transparent', - [buttonVars.hover.label]: cssVar(base.blackPepper400, 'rgba(51, 51, 51, 1)'), - [buttonVars.hover.icon]: cssVar(base.blackPepper400, 'rgba(51, 51, 51, 1)'), + [buttonVars.hover.label]: base.blackPepper400, + [buttonVars.hover.icon]: base.blackPepper400, // Focus Styles - [buttonVars.focus.background]: cssVar(base.frenchVanilla100, '#fff'), + [buttonVars.focus.background]: base.frenchVanilla100, [buttonVars.focus.border]: 'transparent', - [buttonVars.focus.label]: cssVar(base.blackPepper400, 'rgba(51, 51, 51, 1)'), - [buttonVars.focus.icon]: cssVar(base.blackPepper400, 'rgba(51, 51, 51, 1)'), + [buttonVars.focus.label]: base.blackPepper400, + [buttonVars.focus.icon]: base.blackPepper400, // Active Styles - [buttonVars.active.background]: cssVar(base.soap200, 'rgba(241, 242, 243, 1)'), + [buttonVars.active.background]: base.soap200, [buttonVars.active.border]: 'transparent', - [buttonVars.active.label]: cssVar(base.blackPepper400, 'rgba(51, 51, 51, 1)'), - [buttonVars.active.icon]: cssVar(base.blackPepper400, 'rgba(51, 51, 51, 1)'), + [buttonVars.active.label]: base.blackPepper400, + [buttonVars.active.icon]: base.blackPepper400, // Disabled Styles [buttonVars.disabled.background]: 'transparent', - [buttonVars.disabled.border]: cssVar(base.frenchVanilla100, '#fff'), - [buttonVars.disabled.label]: cssVar(base.frenchVanilla100, '#fff'), - [buttonVars.disabled.icon]: cssVar(base.frenchVanilla100, '#fff'), + [buttonVars.disabled.border]: base.frenchVanilla100, + [buttonVars.disabled.label]: base.frenchVanilla100, + [buttonVars.disabled.icon]: base.frenchVanilla100, '&:focus-visible, &.focus': { ...focusRing({ inset: 'inner', width: 2, separation: 2, - innerColor: cssVar(base.blackPepper400, cssVar(buttonVars.focus.boxShadowInner)), - outerColor: cssVar(base.frenchVanilla100, cssVar(buttonVars.focus.boxShadowOuter)), + innerColor: base.blackPepper400, + outerColor: base.frenchVanilla100, }), }, }), @@ -119,7 +119,7 @@ export const tertiaryButtonModifiers = createModifiers({ largeOnly: createStyles({ borderRadius: borderRadius.circle, padding: '0', - minWidth: `calc(${cssVar(system.space.x4, '1rem')} * 3)`, + minWidth: `calc(${system.space.x4} * 3)`, }), largeStart: createStyles({ paddingInlineStart: space.xxs, diff --git a/modules/react/button/lib/ToolbarDropdownButton.tsx b/modules/react/button/lib/ToolbarDropdownButton.tsx index 288c1033b6..ae84f2e561 100644 --- a/modules/react/button/lib/ToolbarDropdownButton.tsx +++ b/modules/react/button/lib/ToolbarDropdownButton.tsx @@ -9,7 +9,6 @@ import { styled, StyledType, } from '@workday/canvas-kit-react/common'; -import {cssVar} from '@workday/canvas-kit-styling'; import {ButtonColors} from './types'; import {BaseButton} from './BaseButton'; import {chevronDownSmallIcon} from '@workday/canvas-system-icons-web'; @@ -45,7 +44,7 @@ const StyledToolbarDropdownButton = styled(BaseButton)> & Pick): CSSObject { let boxShadow, innerWidth, outerWidth; + if (innerColor && innerColor.startsWith('--')) { + // eslint-disable-next-line no-param-reassign + innerColor = cssVar(innerColor); + } + if (outerColor && outerColor.startsWith('--')) { + // eslint-disable-next-line no-param-reassign + outerColor = cssVar(outerColor); + } switch (inset) { case 'outer': diff --git a/modules/react/tsconfig.json b/modules/react/tsconfig.json index e9316a76f2..fef8366275 100644 --- a/modules/react/tsconfig.json +++ b/modules/react/tsconfig.json @@ -6,7 +6,11 @@ { "transform": "../styling-transform/lib/styleTransform.ts", "prefix": "css", - "fallbackFiles": [] + "fallbackFiles": [ + "@workday/canvas-tokens-web/css/base/_variables.css", + "@workday/canvas-tokens-web/css/brand/_variables.css", + "@workday/canvas-tokens-web/css/system/_variables.css" + ] } ] } diff --git a/modules/styling-transform/lib/styleTransform.ts b/modules/styling-transform/lib/styleTransform.ts index bb52bd327b..2f67e81398 100644 --- a/modules/styling-transform/lib/styleTransform.ts +++ b/modules/styling-transform/lib/styleTransform.ts @@ -1,458 +1,81 @@ /// import ts from 'typescript'; -import {serializeStyles} from '@emotion/serialize'; -import {base, brand} from '@workday/canvas-tokens-web'; import path from 'node:path'; -import {slugify, generateUniqueId} from '@workday/canvas-kit-styling'; -import {getFallbackVariable, getVariablesFromFiles} from './getCssVariables'; - -const styleExpressionName = 'createStyles'; -const cssVarExpressionName = 'cssVar'; -const createVarExpressionName = 'createVars'; -const styleImportString = '@workday/canvas-kit-styling'; - -const vars: Record = {}; +import {getVariablesFromFiles} from './utils/getCssVariables'; +import {handleCreateVars} from './utils/handleCreateVars'; +import {handleCreateStyles} from './utils/handleCreateStyles'; +import {handleCreateStencil} from './utils/handleCreateStencil'; +import {handleCalc} from './utils/handleCalc'; +import {handlePx2Rem} from './utils/handlePx2Rem'; +import {handleFocusRing} from './utils/handleFocusRing'; +import {handleCssVar} from './utils/handleCssVar'; +import {NodeTransformer} from './utils/types'; export type NestedStyleObject = {[key: string]: string | NestedStyleObject}; -function getStyleValueFromType(node: ts.Node, type: ts.Type, checker: ts.TypeChecker) { - const value = getCSSValueAtLocation(node as ts.Expression, checker, type); - if (value) { - if (value.startsWith('--')) { - return `var(${value})`; - } - return value; - } - - const typeValue = checker.typeToString(type); - - throw new Error( - `Unknown type at: "${node.getText()}". Received "${typeValue}"\n${getErrorMessage( - node - )}\nFor static analysis of styles, please make sure all types resolve to string or numeric literals. Please use 'const' instead of 'let'. If using an object, cast using "as const" or use an interface with string or numeric literals.` - ); -} - -/** - * Util function to fix an issue with Emotion by - * appending `EmotionIssue#3066` to end of css variable - * See issue: [#3066](https://github.com/emotion-js/emotion/issues/3066) - */ -const makeEmotionSafe = (key: string): string => { - if (key.endsWith('label')) { - return `${key}-emotion-safe`; - } - return key; -}; - -/** - * A `PropertyExpression` is an expression with a dot in it. Like `a.b.c`. It may be nested. This - * function will walk the AST and create a string like `a.b.c` to be passed on to variable name - * generation. This will be used for CSS variable lookups. - */ -function getPropertyAccessExpressionText(node: ts.PropertyAccessExpression): string { - if (ts.isIdentifier(node.name)) { - if (ts.isIdentifier(node.expression)) { - return `${node.expression.text}.${node.name.text}`; - } - if (ts.isPropertyAccessExpression(node.expression)) { - return `${getPropertyAccessExpressionText(node.expression)}.${node.name.text}`; - } - } - return ''; -} - -function parseStyleObjValue( - initializer: ts.Node, - variables: Record, - checker: ts.TypeChecker -): string { - /** - * String literals like 'red' or empty Template Expressions like `red` - */ - if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) { - return initializer.text; - } - - // numeric literal values like `12` - if (ts.isNumericLiteral(initializer)) { - return `${initializer.text}px`; - } - - // The source file is using an identifier which will be known at runtime, we'll try to - // determine the type - if (ts.isIdentifier(initializer)) { - const type = checker.getTypeAtLocation(initializer); - return getStyleValueFromType(initializer, type, checker); - } - - /** - * ```ts - * PropertyAccessExpressions are dot-notation - * - * foo.bar.baz - * ``` - */ - if (ts.isPropertyAccessExpression(initializer)) { - const type = checker.getTypeAtLocation(initializer); - return getStyleValueFromType(initializer, type, checker); - } - - /** - * This will find patterns like: - * - * ```ts - * cssVar(myVars.color); - * cssVar(myVars.colors.background); - * cssVar(myVars.colors.background, 'red') - * ``` - */ - if ( - ts.isCallExpression(initializer) && - ts.isIdentifier(initializer.expression) && - initializer.expression.text === cssVarExpressionName - ) { - const value = getCSSValueAtLocation(initializer.arguments[0], checker); - const value2 = initializer.arguments[1] - ? parseStyleObjValue(initializer.arguments[1], variables, checker) - : undefined; - - // handle fallback variables - const fallbackValue = getFallbackVariable(value, variables); - if (value && (value2 || fallbackValue)) { - return `var(${value}, ${ - (value2?.startsWith('--') ? `var(${value2})` : value2) || fallbackValue - })`; - } - - if (value) { - return `var(${value})`; - } - } - - /** - * ```ts - * `border 1px ${myVars.colors.border}` - * ``` - */ - if (ts.isTemplateExpression(initializer)) { - return getStyleValueFromTemplateExpression(initializer, variables, checker); - } - - return ''; -} - -/** - * Gets a static string value from a template expression. It could recurse. - */ -function getStyleValueFromTemplateExpression( - node: ts.Node | undefined, - variables: Record, - checker: ts.TypeChecker -): string { - if (!node) { - return ''; - } - if (ts.isTemplateExpression(node)) { - return ( - getStyleValueFromTemplateExpression(node.head, variables, checker) + - node.templateSpans - .map(value => getStyleValueFromTemplateExpression(value, variables, checker)) - .join('') - ); - } - - if (ts.isTemplateHead(node) || ts.isTemplateTail(node) || ts.isTemplateMiddle(node)) { - return node.text; - } - - if (ts.isTemplateSpan(node)) { - return ( - parseStyleObjValue(node.expression, variables, checker) + - getStyleValueFromTemplateExpression(node.literal, variables, checker) - ); - } - - return ''; -} - -/** - * Gets a CSS value from an AST node - */ -function getCSSValueAtLocation( - node: ts.Expression, - checker: ts.TypeChecker, - /** - * Optional type. This works for cases where the node is a TypeNode or TypeScript infers the Type - * via a generic resolution. For example: - * ```ts - * function someFn(input: T): {fontSize: T} { - * return { fontSize: input } - * } - * - * // in styles - * ...someFn('12px') - * ``` - * - * If we don't pass a type of the property given by `type.getProperties()`, TypeScript will - * resolve the type at the value node as `T` instead of `12px`. Allowing for a type override is - * useful when the caller may have more context about the type at a given node than we do. - */ - type: ts.Type = checker.getTypeAtLocation(node) -): string { - const varsKey = getVarsKeyFromNode(node); - - if (vars[varsKey]) { - return vars[varsKey]; - } - - if (type.isStringLiteral()) { - // This isn't a component variable, it is a static CSS variable - return type.value; - } - - if (type.isNumberLiteral()) { - return `${type.value}px`; - } - - if (node && ts.isPropertyAccessExpression(node)) { - return getPropertyAccessExpressionText(node); - } - - return ''; -} - -function getModuleSpecifierFromDeclaration(node?: ts.Declaration) { - if (!node) { - return undefined; - } - if (ts.isImportSpecifier(node) && ts.isStringLiteral(node.parent.parent.parent.moduleSpecifier)) { - return node.parent.parent.parent.moduleSpecifier.text; - } - return undefined; -} - -function getStyleFromProperty( - property: ts.Node, - prefix: string, - variables: Record, - checker: ts.TypeChecker -): NestedStyleObject { - if (ts.isPropertyAssignment(property)) { - // All properties should be non-objects - // {foo: 'bar'} - if (ts.isIdentifier(property.name)) { - const value = parseStyleObjValue(property.initializer, variables, checker); - if (value) { - return {[property.name.text]: value}; - } - } - - if (ts.isComputedPropertyName(property.name)) { - if (ts.isPropertyAccessExpression(property.name.expression)) { - const value = parseStyleObjValue(property.initializer, variables, checker); - if (value) { - // test if the property is a static value - getPropertyAccessExpressionText(property.name.expression); - const type = checker.getTypeAtLocation(property.name.expression); - checker.typeToString(type); - - if (type.isStringLiteral()) { - return {[type.value]: value}; - } else { - const expressionText = getPropertyAccessExpressionText(property.name.expression); - const [id, name] = getVariableNameParts(expressionText); - return {[`--${prefix}-${slugify(id)}-${makeEmotionSafe(name)}`]: value}; - } - } - } - } - - // String literal property names are special selectors with more styles - // {'&:hover': {}} - if (ts.isStringLiteral(property.name)) { - return { - [property.name.text]: parseStyleObjFromNode( - property.initializer, - prefix, - variables, - checker - ), - }; - } - } - - /** - * A spread assignment looks like: - * - * ```ts - * { - * ...styles - * } - * ``` - * - * https://ts-ast-viewer.com/#code/MYewdgzgLgBFCmBbADjAvDA3gMxCAXDAOQBGAhgE5EC+AUKJLAigEzpYB0Xzy1QA - */ - if (ts.isSpreadAssignment(property)) { - // Detect `focusRing` calls. This is temporary until we figure out a better way to do focus - // rings that doesn't require a special entry in the transform function. - // - // TODO: implement a fully working type resolver for CSS variables or remove support for them an - // remove all uses of `focusRing` from new styling code - if ( - ts.isCallExpression(property.expression) && - ts.isIdentifier(property.expression.expression) && - property.expression.expression.text === 'focusRing' - ) { - const argumentObject = property.expression.arguments[0]; - // defaults - const defaults = { - width: '2px', - separation: '0px', - inset: undefined as undefined | string, - innerColor: `var(${base.frenchVanilla100}, rgba(255,255,255,1))`, - outerColor: `var(${brand.common.focusOutline}, rgba(8,117,225,1))`, - }; - if (argumentObject && ts.isObjectLiteralExpression(argumentObject)) { - argumentObject.properties.forEach(property => { - if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)) { - defaults[property.name.text as keyof typeof defaults] = parseStyleObjValue( - property.initializer, - variables, - checker - ); - } - }); - - let boxShadow; - switch (defaults.inset) { - case 'outer': - boxShadow = `inset 0 0 0 ${defaults.separation} ${defaults.outerColor}, inset 0 0 0 calc(${defaults.width} + ${defaults.separation}) ${defaults.innerColor}`; - break; - - case 'inner': - boxShadow = `inset 0 0 0 ${defaults.separation} ${defaults.innerColor}, 0 0 0 ${defaults.width} ${defaults.outerColor}`; - break; - - default: - boxShadow = `0 0 0 ${defaults.separation} ${defaults.innerColor}, 0 0 0 calc(${defaults.width} + ${defaults.separation}) ${defaults.outerColor}`; - break; - } - - return {boxShadow}; - } - } - - // Spread assignments are a bit complicated to use the AST to figure out, so we'll ask the - // TypeScript type checker. - const type = checker.getTypeAtLocation(property.expression); - checker.typeToString(type); //? - return parseStyleObjFromType(type, prefix, variables, checker); - } - return {}; +export interface StyleTransformerOptions { + prefix: string; + variables: Record; + fallbackFiles?: string[]; + transformers?: NodeTransformer[]; } -/** - * If we're here, we have a `ts.Type` that represents a style object. We try to parse a style object - * from the AST, but we might have something that is more complicated like a function call or an - * identifier that represents an object. It could be imported from another file. - */ -function parseStyleObjFromType( - type: ts.Type, - prefix: string, - variables: Record, - checker: ts.TypeChecker -) { - const styleObj: Record = {}; - - // Gets all the properties of the type object - return type.getProperties().reduce((result, property) => { - const declaration = property.declarations[0]; - if (declaration) { - const propType = checker.getTypeOfSymbolAtLocation(property, declaration); - return { - ...result, - [property.name]: getStyleValueFromType(declaration, propType, checker), - }; - } - return result; - }, styleObj); -} +let vars: Record = {}; +let loadedFallbacks = false; /** - * If the node is an `ObjectLiteralExpression`, we'll walk the `properties` of the AST node and - * create a style object for each property we find. + * The reset is used in tests and should not be called normally. */ -function parseStyleObjFromNode( - node: ts.Node, - prefix: string, - variables: Record, - checker: ts.TypeChecker -) { - const styleObj: Record = {}; - if (ts.isObjectLiteralExpression(node)) { - return node.properties.reduce((result, property) => { - return {...result, ...getStyleFromProperty(property, prefix, variables, checker)}; - }, styleObj); - } - - return styleObj; +export function _reset() { + vars = {}; + loadedFallbacks = false; } /** - * Creates an AST node representation of the passed in `styleObj`, but in the format of `{name: - * string, styles: serializedStyles}`. The `name` is hard-coded here to work with both server-side - * and client-side style injection. This results in a stable style key for Emotion while also - * optimizing style serialization. + * Optional list of transformers. Useful to override for tests */ -function createStyleObjectNode(styleObj: Record) { - const serialized = serializeStyles([styleObj]); - const styleText = serialized.styles; - const styleExpression = ts.factory.createStringLiteral(styleText); - - // create an emotion-optimized object: https://github.com/emotion-js/emotion/blob/f3b268f7c52103979402da919c9c0dd3f9e0e189/packages/serialize/src/index.js#L315-L322 - // Looks like: `{name: $hash, styles: $styleText }` - return ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment( - ts.factory.createIdentifier('name'), - // TODO - we may need this to be a static variable for the CSS package - ts.factory.createStringLiteral(generateUniqueId()) // We might be using values that are resolved at runtime, but should still be static. We're only supporting the `cs` function running once per file, so a stable id based on a hash is not necessary - ), - ts.factory.createPropertyAssignment( - ts.factory.createIdentifier('styles'), - styleExpression // In the future we can extract CSS from here by running the `stylis` compiler directly. Emotion does this here: https://github.com/emotion-js/emotion/blob/f3b268f7c52103979402da919c9c0dd3f9e0e189/packages/cache/src/index.js#L188-L245 - ), - ], - false - ); -} - -export interface StyleTransformerOptions { - prefix: string; - variables: Record; - fallbackFiles?: string[]; -} +const defaultTransformers = [ + handleCssVar, + handleFocusRing, + handleCalc, + handlePx2Rem, + handleCreateVars, + handleCreateStyles, + handleCreateStencil, +]; export default function styleTransformer( program: ts.Program, - {prefix = 'css', variables = {}, fallbackFiles}: Partial = { + { + prefix = 'css', + variables = {}, + fallbackFiles = [], + transformers = defaultTransformers, + }: Partial = { prefix: 'css', variables: {}, + transformers: defaultTransformers, } ): ts.TransformerFactory { - if (fallbackFiles) { + if (!loadedFallbacks) { const files = fallbackFiles .filter(file => file) // don't process empty files .map(file => { // Find the fully-qualified path name. This could error which should give "module not found" errors return file.startsWith('.') ? path.resolve(process.cwd(), file) : require.resolve(file); }) - .map(file => ts.sys.readFile(file) || ''); + .map(file => { + console.log(`Loading CSS variable fallback file: ${file}`); + return ts.sys.readFile(file) || ''; + }); + + const fallbackVars = getVariablesFromFiles(files); + console.log(`Found ${Object.keys(fallbackVars).length} variables.`); // eslint-disable-next-line no-param-reassign - variables = getVariablesFromFiles(files); + vars = {...variables, ...fallbackVars}; + loadedFallbacks = true; } const checker = program.getTypeChecker(); @@ -461,169 +84,22 @@ export default function styleTransformer( // eslint-disable-next-line no-param-reassign node = ts.visitEachChild(node, visit, context); - /** - * Check if the node is a call expression that looks like: - * - * ```ts - * createStyles({ - * // properties - * }) - * ``` - * - * It will also make sure the `createStyles` function was imported from - * `@workday/canvas-kit-styling` to ensure we don't rewrite the AST of code we don't own. - * - * This transformation will pre-serialize the style objects and turn them into strings for - * faster runtime processing in Emotion. The following is an example of the transformation. - * - * ```ts - * // before transformation - * const myStyles = createStyles({ - * fontSize: '1rem' - * }) - * - * // after transformation - * const myStyles = createStyles({ - * name: 'abc123', - * styles: 'font-size: 1rem;' - * }) - * ``` - * - * The after transformation already serialized the styles and goes through a shortcut process - * in `@emotion/css` where only the Emotion cache is checked and styles are inserted if the - * cache key wasn't found. - */ - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === styleExpressionName && - node.arguments.length > 0 - ) { - // get the declaration of the symbol of the styleExpression - const symbol = checker.getSymbolAtLocation(node.expression); - const declaration = symbol?.declarations[0]; - - if (getModuleSpecifierFromDeclaration(declaration) === styleImportString) { - const newArguments = [...node.arguments].map(arg => { - // An `ObjectLiteralExpression` is an object like `{foo:'bar'}`: - // https://ts-ast-viewer.com/#code/MYewdgzgLgBFCmBbADjAvDA3gKBjAZiCAFwwDkARgIYBOZ2AvkA - if (ts.isObjectLiteralExpression(arg)) { - const styleObj = parseStyleObjFromNode(arg, prefix, variables, checker); - - return createStyleObjectNode(styleObj); - } - // An Identifier is a variable. It could come from anywhere - imports, earlier - // assignments, etc. The easiest thing to do is to ask the TypeScript type checker what - // the type representation is and go from there. - if (ts.isIdentifier(arg)) { - const type = checker.getTypeAtLocation(arg); - - // `createStyles` accepts strings as class names. If the class name is - if (type.isStringLiteral() || type.getFlags() & ts.TypeFlags.String) { - return arg; - } - - // The type must be a object - const styleObj = parseStyleObjFromType(type, prefix, variables, checker); - - return createStyleObjectNode(styleObj); - } - return arg; - }); - - newArguments.forEach(argument => { - // TypeScript isn't expecting us to mutate arguments arguments and when emitting will - // try to do something where it checks the `parent` node of the argument. Using - // `ts.factory.create*`, the `parent` is `undefined` and this check will throw an error. - // In order to get past this error, we manually update the `parent` node of each - // argument to reference the existing call expression. This allows TypeScript to fully - // type check and/or emit. - (argument as any).parent = node; - }); - - /** - * We're not supposed to mutate arguments since it is supposed to be read-only. But, if I - * return a new callExpression, there is no parent and it is no longer linked to the - * import module. This causes incorrect code when the module export type is `commonjs`. - * For example: - * - * ```ts - * // with new callExpression - * const canvas_kit_styling_1 = require(...) - * - * createStyles({...}) - * - * // if we instead mutate arguments - * const canvas_kit_styling_1 = require(...) - * - * canvas_kit_styling_1.createStyles({...}) - * ``` - * - * My best guess as to why it fails when creating a new callExpression is the node's - * symbol declaration link gets lost. TypeScript then has no idea `createStyles` comes - * from an `ImportDeclaration` declaration node and when emitting `commonjs`, it doesn't - * prefix with the `canvas_kit_styling_1`. This is hacky, but the only thing that works - * correctly. - */ - (node.arguments as any) = newArguments; - - return node; - } - } - - /** - * This will create a variable - */ - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === createVarExpressionName - ) { - const id = slugify(getVarName(node)); - const variables = node.arguments - .map(arg => ts.isStringLiteral(arg) && arg.text) - .filter(Boolean) as string[]; - - variables.forEach(v => { - vars[`${id}-${makeEmotionSafe(v)}`] = `--${prefix}-${id}-${makeEmotionSafe(v)}`; - }); - - return ts.factory.createCallExpression( - node.expression, - [], - [ - ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment( - ts.factory.createIdentifier('id'), - ts.factory.createStringLiteral(`${prefix}-${id}`) - ), - ts.factory.createPropertyAssignment( - ts.factory.createIdentifier('args'), - ts.factory.createArrayLiteralExpression( - variables.map(val => ts.factory.createStringLiteral(val)), - false - ) - ), - ], - false - ), - ] - ); - } - - return node; + return handleTransformers(node, checker, prefix, vars)(transformers); }; return node => ts.visitNode(node, visit); }; } -// This should only be used for tests +/** + * This function is useful for tests or a custom build. The `styleTransformer` function is used by + * the https://www.npmjs.com/package/ttypescript package. + */ export function transform( program: ts.Program, fileName: string, - options?: Partial + options?: Partial, + transformers?: NodeTransformer[] ) { const source = program.getSourceFile(fileName) || ts.createSourceFile(fileName, '', ts.ScriptTarget.ES2019); @@ -632,95 +108,27 @@ export function transform( return printer.printFile( ts - .transform(source, [styleTransformer(program, options)]) - .transformed.find(s => (s.fileName = fileName)) || source + .transform(source, [styleTransformer(program, {...options, transformers})]) + .transformed.find(s => s.fileName === fileName) || source ); } -function getVarName(node: ts.Node): string { - const parent = node.parent; - - if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { - return parent.name.text; - } - - if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) { - return `${getVarName(parent.parent)}-${parent.name.text}`; - } - - return ''; -} - -/** - * Creates an error message around a node. It will look something like: - * - * ``` - * Unknown type at: "fontSize". - * File: test.ts, Line: 6, Character: 17. - * const styles = createStyles({ - * fontSize: fontSize - * ======== - * }) - * ``` - */ -function getErrorMessage(node: ts.Node) { - const sourceFile = node.getSourceFile(); - - const {line} = node.getSourceFile().getLineAndCharacterOfPosition(node.pos); - const lineStarts = sourceFile.getLineStarts(); - - const lineStartIndex = lineStarts.findIndex(s => s >= node.pos) - 1; - - // get a whole line's text given a lineStarts index - function getLine(sourceFile: ts.SourceFile, startIndex: number) { - const lineStarts = sourceFile.getLineStarts(); - - return sourceFile.text.substring( - lineStarts[Math.max(0, startIndex)], - startIndex + 1 >= lineStarts.length ? undefined : lineStarts[startIndex + 1] +const handleTransformers = + (node: ts.Node, checker: ts.TypeChecker, prefix: string, variables: Record) => + ( + transformers: (( + node: ts.Node, + checker: ts.TypeChecker, + prefix: string, + variables: Record + ) => ts.Node | void)[] + ) => { + return ( + transformers.reduce((result, transformer) => { + if (result) { + return result; + } + return transformer(node, checker, prefix, variables); + }, undefined as ts.Node | void) || node ); - } - - // Create a full context message with source code and highlighting - const lineBefore = getLine(sourceFile, lineStartIndex - 1); - const lineCurrent = getLine(sourceFile, lineStartIndex); - const lineAfter = getLine(sourceFile, lineStartIndex + 1); - const highlightedLine = - '' - .padStart(node.getStart() - lineStarts[lineStartIndex], ' ') - .padEnd(node.getStart() - lineStarts[lineStartIndex] + node.getWidth(), '=') + '\n'; - - /** This should look something like: - * ``` - * const styles = createStyles({ - * fontSize: fontSize - * ======== - * }) - * ``` - */ - const fullContext = lineBefore + lineCurrent + highlightedLine + lineAfter; - - const character = node.getStart() - lineStarts[lineStartIndex] + 1; - return `File: ${sourceFile.fileName}:${line + 1}:${character}.\n${fullContext}`; -} - -function getVariableNameParts(input: string): [string, string] { - const parts = input.split('.'); - - // grab the last item in the array. This will also mutate the array, removing the last item - const variable = parts.pop()!; - - return [parts.join('.'), variable]; -} - -function getVarsKeyFromNode(node: ts.Node): string { - if (ts.isIdentifier(node)) { - return slugify(node.text); - } - - if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) { - return `${getVarsKeyFromNode(node.expression)}-${makeEmotionSafe(node.name.text)}`; - } - - return ''; -} + }; diff --git a/modules/styling-transform/lib/utils/createStyleObjectNode.ts b/modules/styling-transform/lib/utils/createStyleObjectNode.ts new file mode 100644 index 0000000000..8fdba76930 --- /dev/null +++ b/modules/styling-transform/lib/utils/createStyleObjectNode.ts @@ -0,0 +1,35 @@ +import ts from 'typescript'; +import {serializeStyles} from '@emotion/serialize'; + +import {generateUniqueId} from '@workday/canvas-kit-styling'; + +import {NestedStyleObject} from './parseObjectToStaticValue'; + +/** + * Creates an AST node representation of the passed in `styleObj`, but in the format of `{name: + * string, styles: serializedStyles}`. The `name` is hard-coded here to work with both server-side + * and client-side style injection. This results in a stable style key for Emotion while also + * optimizing style serialization. + */ +export function createStyleObjectNode(styleObj: NestedStyleObject) { + const serialized = serializeStyles([styleObj]); + const styleText = serialized.styles; + const styleExpression = ts.factory.createStringLiteral(styleText); + + // create an emotion-optimized object: https://github.com/emotion-js/emotion/blob/f3b268f7c52103979402da919c9c0dd3f9e0e189/packages/serialize/src/index.js#L315-L322 + // Looks like: `{name: $hash, styles: $styleText }` + return ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('name'), + // TODO - we may need this to be a static variable for the CSS package + ts.factory.createStringLiteral(generateUniqueId()) // We might be using values that are resolved at runtime, but should still be static. We're only supporting the `cs` function running once per file, so a stable id based on a hash is not necessary + ), + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('styles'), + styleExpression // In the future we can extract CSS from here by running the `stylis` compiler directly. Emotion does this here: https://github.com/emotion-js/emotion/blob/f3b268f7c52103979402da919c9c0dd3f9e0e189/packages/cache/src/index.js#L188-L245 + ), + ], + false + ); +} diff --git a/modules/styling-transform/lib/getCssVariables.ts b/modules/styling-transform/lib/utils/getCssVariables.ts similarity index 54% rename from modules/styling-transform/lib/getCssVariables.ts rename to modules/styling-transform/lib/utils/getCssVariables.ts index 184f0d439c..4835a73f8d 100644 --- a/modules/styling-transform/lib/getCssVariables.ts +++ b/modules/styling-transform/lib/utils/getCssVariables.ts @@ -34,32 +34,3 @@ export function extractVariables( {...variables} ); } - -export function getFallbackVariable( - variableName: string, - variables: Record -): string | undefined { - const variable = variableName.includes('var(') ? variableName : variables[variableName]; - if (variable && variable.includes('var(')) { - return variable.replace( - /(var\(([A-Za-z0-9\-_]+)\))/, - ( - /** matched substring "var(--var-name)" */ _, - /** the full match of the first group "var(--var-name)" */ varMatch, - /** the variable name - match of the second group "--var-name" */ cssVarName, - ...args - ) => { - const value = variables[cssVarName]; - if (value && value.startsWith('var')) { - return getFallbackVariable(value, variables); - } - return value || varMatch; - } - ); - } - if (variable) { - return variable; - } - - return; -} diff --git a/modules/styling-transform/lib/utils/getErrorMessage.ts b/modules/styling-transform/lib/utils/getErrorMessage.ts new file mode 100644 index 0000000000..6f03436210 --- /dev/null +++ b/modules/styling-transform/lib/utils/getErrorMessage.ts @@ -0,0 +1,54 @@ +import ts from 'typescript'; + +/** + * Creates an error message around a node. It will look something like: + * + * ``` + * Unknown type at: "fontSize". + * File: test.ts, Line: 6, Character: 17. + * const styles = createStyles({ + * fontSize: fontSize + * ======== + * }) + * ``` + */ +export function getErrorMessage(node: ts.Node) { + const sourceFile = node.getSourceFile(); + + const {line} = node.getSourceFile().getLineAndCharacterOfPosition(node.pos); + const lineStarts = sourceFile.getLineStarts(); + + const lineStartIndex = lineStarts.findIndex(s => s >= node.pos) - 1; + + // get a whole line's text given a lineStarts index + function getLine(sourceFile: ts.SourceFile, startIndex: number) { + const lineStarts = sourceFile.getLineStarts(); + + return sourceFile.text.substring( + lineStarts[Math.max(0, startIndex)], + startIndex + 1 >= lineStarts.length ? undefined : lineStarts[startIndex + 1] + ); + } + + // Create a full context message with source code and highlighting + const lineBefore = getLine(sourceFile, lineStartIndex - 1); + const lineCurrent = getLine(sourceFile, lineStartIndex); + const lineAfter = getLine(sourceFile, lineStartIndex + 1); + const highlightedLine = + '' + .padStart(node.getStart() - lineStarts[lineStartIndex], ' ') + .padEnd(node.getStart() - lineStarts[lineStartIndex] + node.getWidth(), '=') + '\n'; + + /** This should look something like: + * ``` + * const styles = createStyles({ + * fontSize: fontSize + * ======== + * }) + * ``` + */ + const fullContext = lineBefore + lineCurrent + highlightedLine + lineAfter; + + const character = node.getStart() - lineStarts[lineStartIndex]; + return `File: ${sourceFile.fileName}:${line + 1}:${character}.\n${fullContext}`; +} diff --git a/modules/styling-transform/lib/utils/getFallbackVariable.ts b/modules/styling-transform/lib/utils/getFallbackVariable.ts new file mode 100644 index 0000000000..75fd7fcfe0 --- /dev/null +++ b/modules/styling-transform/lib/utils/getFallbackVariable.ts @@ -0,0 +1,34 @@ +/** + * Looks for a variable value that doesn't include a fallback and automatically adds one if found in + * the current cache of variables. This allows fallbacks to be automatically included in + * environments without the variables defined. This is most useful for Storybook or other sandboxes + * that may not have CSS Variables defined. The fallbacks will allow the UI to look correct without + * additional setup. Fallbacks come from the `fallbackFiles` transform configuration. + */ +export function getFallbackVariable( + variableName: string, + variables: Record +): string | undefined { + const variable = variableName.includes('var(') ? variableName : variables[variableName]; + if (variable && variable.includes('var(')) { + return variable.replace( + /(var\(([A-Za-z0-9\-_]+)\))/, + ( + /** matched substring "var(--var-name)" */ _, + /** the full match of the first group "var(--var-name)" */ varMatch, + /** the variable name - match of the second group "--var-name" */ cssVarName + ) => { + const value = variables[cssVarName]; + if (value && value.startsWith('var')) { + return getFallbackVariable(value, variables); + } + return value || varMatch; + } + ); + } + if (variable) { + return variable; + } + + return; +} diff --git a/modules/styling-transform/lib/utils/getVarName.ts b/modules/styling-transform/lib/utils/getVarName.ts new file mode 100644 index 0000000000..14137f178c --- /dev/null +++ b/modules/styling-transform/lib/utils/getVarName.ts @@ -0,0 +1,29 @@ +import ts from 'typescript'; + +/** + * This function returns a calculated name of a node by walking up the AST and looking for nodes + * with a `name` property that has an `Identifier` node type. The result is dash-case. This is + * useful for automatically generating variable names based on a TS file. + * + * In the following example, the `baz` node would have a name of `foo-bar-baz`. + * ```ts + * const foo = { + * bar: { + * baz: '' + * } + * } + * ``` + */ +export function getVarName(node: ts.Node, parts: string[] = []): string { + // base case. Join all the parts + if (!node.parent || node.kind === ts.SyntaxKind.VariableStatement) { + return parts.join('-'); + } + + // Any node with a `name` property that is an identifier can add to the var name + if ((node as any).name && ts.isIdentifier((node as any).name)) { + return getVarName(node.parent, [(node as any).name.text, ...parts]); + } + + return getVarName(node.parent, parts); +} diff --git a/modules/styling-transform/lib/utils/handleCalc.ts b/modules/styling-transform/lib/utils/handleCalc.ts new file mode 100644 index 0000000000..f6ec0e9a01 --- /dev/null +++ b/modules/styling-transform/lib/utils/handleCalc.ts @@ -0,0 +1,67 @@ +import ts from 'typescript'; + +import {isImportedFromStyling} from './isImportedFromStyling'; +import {NodeTransformer} from './types'; + +/** + * Handle all instances of `calc.*()` statically. It converts `calc.*` call expressions to template + * string literals. The transformer will then transform the template literal into static values. + * + * ```ts + * // in + * calc.add('80%', '2px') + * + * // out + * `calc(${'80%'} + ${'2px'})` + * ``` + * + * A template literal is used because the values might be Identifiers, PropertyAccessExpressions, + * etc. The transform can handle template string literals with different spans, so we'll convert to + * those as an intermediate step. + */ +export const handleCalc: NodeTransformer = (node, checker) => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'calc' && + ts.isIdentifier(node.expression.name) && + isImportedFromStyling(node.expression.expression, checker) + ) { + if (node.expression.name.text === 'add') { + return replaceWithTemplateString(node.arguments[0], node.arguments[1], ' + '); + } + if (node.expression.name.text === 'subtract') { + return replaceWithTemplateString(node.arguments[0], node.arguments[1], ' - '); + } + if (node.expression.name.text === 'multiply') { + return replaceWithTemplateString(node.arguments[0], node.arguments[1], ' * '); + } + if (node.expression.name.text === 'divide') { + return replaceWithTemplateString(node.arguments[0], node.arguments[1], ' / '); + } + if (node.expression.name.text === 'negate') { + return replaceWithTemplateString( + node.arguments[0], + ts.factory.createStringLiteral('-1'), + ' * ' + ); + } + } + + return; +}; + +/** + * Creates a template literal of the calculation result. + */ +function replaceWithTemplateString(value1: ts.Node, value2: ts.Node, binder: string) { + if (ts.isStringLiteral(value1) && ts.isStringLiteral(value2)) { + return ts.factory.createStringLiteral(`calc(${value1.text}${binder}${value2.text})`); + } + + return ts.factory.createTemplateExpression(ts.factory.createTemplateHead('calc('), [ + ts.factory.createTemplateSpan(value1 as ts.Expression, ts.factory.createTemplateMiddle(binder)), + ts.factory.createTemplateSpan(value2 as ts.Expression, ts.factory.createTemplateTail(')')), + ]); +} diff --git a/modules/styling-transform/lib/utils/handleCreateStencil.ts b/modules/styling-transform/lib/utils/handleCreateStencil.ts new file mode 100644 index 0000000000..50e6843477 --- /dev/null +++ b/modules/styling-transform/lib/utils/handleCreateStencil.ts @@ -0,0 +1,266 @@ +import ts from 'typescript'; + +import {slugify} from '@workday/canvas-kit-styling'; + +import {getVarName} from './getVarName'; +import {makeEmotionSafe} from './makeEmotionSafe'; +import {NestedStyleObject, parseObjectToStaticValue} from './parseObjectToStaticValue'; +import {createStyleObjectNode} from './createStyleObjectNode'; +import {parseNodeToStaticValue} from './parseNodeToStaticValue'; +import {NodeTransformer} from './types'; +import {isImportedFromStyling} from './isImportedFromStyling'; + +/** + * Handle all arguments of the CallExpression `createStencil()` + */ +export const handleCreateStencil: NodeTransformer = ( + node, + checker, + prefix = 'css', + variables = {} +) => { + /** + * This will match whenever a `createStencil()` call expression is encountered. It will loop + * over all the config to extract variables and styles. + * + */ + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'createStencil' && + isImportedFromStyling(node.expression, checker) + ) { + const config = node.arguments[0]; + + /** + * Stencils can define variables that are used in style object functions. Inside those + * functions, the full variable name is not used, but rather destructured. We'll create + * temporary local variables for these style object functions. + * + * For example: + * ```ts + * const myStencil = createStencil({ + * vars: { color: 'red' }, + * base: ({color}) => ({ + * color: color + * }) + * }) + * ``` + */ + const tempVariables: Record = {}; + // We need to keep track of stencil variables and values to automatically merge into the base + // styles + const stencilVariables: Record = {}; + + // Stencil name is the variable name + const stencilName = slugify(getVarName(node)).replace('-stencil', ''); + + if (ts.isObjectLiteralExpression(config)) { + // get variables first + const varsConfig = config.properties.find(property => { + return ( + property.name && + ts.isIdentifier(property.name) && + property.name.text === 'vars' && + ts.isPropertyAssignment(property) + ); + }) as ts.PropertyAssignment | undefined; + + function extractVariables(node: ts.Node): any { + if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)) { + if (ts.isObjectLiteralExpression(node.initializer)) { + return node.initializer.properties.map(extractVariables); + } + + const varName = `${stencilName}-${makeEmotionSafe(node.name.text)}`; + const varValue = `--${prefix}-${varName}`; + variables[`${varName}`] = varValue; + + variables[makeEmotionSafe(node.name.text)] = varValue; + tempVariables[makeEmotionSafe(node.name.text)] = varValue; + + // Evaluate the variable defaults + stencilVariables[varValue] = parseNodeToStaticValue( + node.initializer, + checker, + prefix, + variables + ); + } + } + + if (varsConfig && ts.isObjectLiteralExpression(varsConfig.initializer)) { + varsConfig.initializer.properties.forEach(variable => { + extractVariables(variable); + }); + } + + config.properties.forEach((property, index, properties) => { + if (property.name && ts.isIdentifier(property.name)) { + // base config object + if (property.name.text === 'base') { + const styleObj = parseStyleBlock(property, checker, prefix, variables); + + if (styleObj) { + // The `as any` are necessary because the properties are readonly, even though they + // can be changed in transformers. + const initializer = createStyleObjectNode({ + ...stencilVariables, + ...styleObj, + }); + + // We cast as any because TypeScript says these are readonly, but we're in a transform + (initializer as any).parent = property; + (properties as any)[index] = ts.factory.createPropertyAssignment( + property.name, + initializer + ); + } + } + + // modifiers config object + if ( + property.name.text === 'modifiers' && + ts.isPropertyAssignment(property) && + ts.isObjectLiteralExpression(property.initializer) + ) { + property.initializer.properties.forEach(modifierProperty => { + if ( + modifierProperty.name && + ts.isIdentifier(modifierProperty.name) && + ts.isPropertyAssignment(modifierProperty) && + ts.isObjectLiteralExpression(modifierProperty.initializer) + ) { + modifierProperty.initializer.properties.forEach((modifier, index, modifiers) => { + const styleObj = parseStyleBlock(modifier, checker, prefix, variables); + + if (styleObj && modifier.name) { + // The `as any` are necessary because the properties are readonly, even though they + // can be changed in transformers. + const initializer = createStyleObjectNode(styleObj); + + // // We cast as any because TypeScript says these are readonly, but we're in a transform + (initializer as any).parent = modifier; + (modifiers as any)[index] = ts.factory.createPropertyAssignment( + modifier.name, + initializer + ); + } + }); + } + }); + } + + // compound config array + if ( + property.name.text === 'compound' && + ts.isPropertyAssignment(property) && + ts.isArrayLiteralExpression(property.initializer) + ) { + property.initializer.elements.forEach(element => { + if (ts.isObjectLiteralExpression(element)) { + element.properties.forEach((compoundProperty, index, compoundProperties) => { + // styles key + if ( + compoundProperty.name && + ts.isIdentifier(compoundProperty.name) && + compoundProperty.name.text === 'styles' + ) { + const styleObj = parseStyleBlock(compoundProperty, checker, prefix, variables); + + if (styleObj) { + // The `as any` are necessary because the properties are readonly, even though they + // can be changed in transformers. + const initializer = createStyleObjectNode(styleObj); + + // We cast as any because TypeScript says these are readonly, but we're in a transform + (initializer as any).parent = compoundProperty; + (compoundProperties as any)[index] = ts.factory.createPropertyAssignment( + compoundProperty.name, + initializer + ); + } + } + }); + } + }); + } + } + }); + } + + // remove all our temp variables + // eslint-disable-next-line guard-for-in + for (const key in tempVariables) { + delete variables[key]; + } + + // arguments are readonly, but we're in a transform + (node.arguments[1] as any) = ts.factory.createStringLiteral(stencilName); + (node.arguments[1] as any).parent = node; + + return node; + } + + return; +}; + +/** + * A style block is a `base`, `modifier`, or `compoundModifier` style block. It could be an ObjectLiteralExpression, + * an ArrowFunction, MethodDeclaration, etc. + */ +function parseStyleBlock( + property: ts.ObjectLiteralElementLike, + checker: ts.TypeChecker, + prefix: string, + variables: Record +): NestedStyleObject | undefined { + let styleObj: NestedStyleObject | undefined; + if (ts.isPropertyAssignment(property)) { + if (ts.isObjectLiteralExpression(property.initializer)) { + styleObj = parseObjectToStaticValue(property.initializer, checker, prefix, variables); + } + + if (isFunctionLikeDeclaration(property.initializer)) { + const returnNode = getReturnStatement(property.initializer); + if (returnNode) { + styleObj = parseObjectToStaticValue(returnNode, checker, prefix, variables); + } + } + } + + if (isFunctionLikeDeclaration(property)) { + const returnNode = getReturnStatement(property); + if (returnNode) { + styleObj = parseObjectToStaticValue(returnNode, checker, prefix, variables); + } + } + + return styleObj; +} + +function getReturnStatement(node: ts.FunctionLikeDeclaration): ts.Node | undefined { + if (node.body && ts.isParenthesizedExpression(node.body)) { + return node.body.expression; + } + + if (node.body && ts.isBlock(node.body)) { + let returnNode: ts.Node | undefined; + + // look for the return statement. It must be a top-level statement in the block + node.body.statements.forEach(statement => { + // () => { return {} } + if (ts.isReturnStatement(statement)) { + returnNode = statement.expression; + } + }); + + return returnNode; + } + + return undefined; +} + +function isFunctionLikeDeclaration(node: ts.Node): node is ts.FunctionLikeDeclaration { + return (node as Object).hasOwnProperty('body'); +} diff --git a/modules/styling-transform/lib/utils/handleCreateStyles.ts b/modules/styling-transform/lib/utils/handleCreateStyles.ts new file mode 100644 index 0000000000..219f0634d9 --- /dev/null +++ b/modules/styling-transform/lib/utils/handleCreateStyles.ts @@ -0,0 +1,115 @@ +import ts from 'typescript'; + +import {parseObjectToStaticValue, parseStyleObjFromType} from './parseObjectToStaticValue'; +import {createStyleObjectNode} from './createStyleObjectNode'; +import {NodeTransformer} from './types'; +import {isImportedFromStyling} from './isImportedFromStyling'; + +export const handleCreateStyles: NodeTransformer = (node, checker, prefix, variables) => { + /** + * Check if the node is a call expression that looks like: + * + * ```ts + * createStyles({ + * // properties + * }) + * ``` + * + * It will also make sure the `createStyles` function was imported from + * `@workday/canvas-kit-styling` to ensure we don't rewrite the AST of code we don't own. + * + * This transformation will pre-serialize the style objects and turn them into strings for + * faster runtime processing in Emotion. The following is an example of the transformation. + * + * ```ts + * // before transformation + * const myStyles = createStyles({ + * fontSize: '1rem' + * }) + * + * // after transformation + * const myStyles = createStyles({ + * name: 'abc123', + * styles: 'font-size: 1rem;' + * }) + * ``` + * + * The after transformation already serialized the styles and goes through a shortcut process + * in `@emotion/css` where only the Emotion cache is checked and styles are inserted if the + * cache key wasn't found. + */ + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'createStyles' && + isImportedFromStyling(node.expression, checker) && + node.arguments.length > 0 + ) { + const newArguments = [...node.arguments].map(arg => { + // An `ObjectLiteralExpression` is an object like `{foo:'bar'}`: + // https://ts-ast-viewer.com/#code/MYewdgzgLgBFCmBbADjAvDA3gKBjAZiCAFwwDkARgIYBOZ2AvkA + if (ts.isObjectLiteralExpression(arg)) { + const styleObj = parseObjectToStaticValue(arg, checker, prefix, variables); + + return createStyleObjectNode(styleObj); + } + // An Identifier is a variable. It could come from anywhere - imports, earlier + // assignments, etc. The easiest thing to do is to ask the TypeScript type checker what + // the type representation is and go from there. + if (ts.isIdentifier(arg)) { + const type = checker.getTypeAtLocation(arg); + + // `createStyles` accepts strings as class names. If the class name is + if (type.isStringLiteral() || type.getFlags() & ts.TypeFlags.String) { + return arg; + } + + // The type must be a object + const styleObj = parseStyleObjFromType(type, checker, prefix, variables); + + return createStyleObjectNode(styleObj); + } + return arg; + }); + + newArguments.forEach(argument => { + // TypeScript isn't expecting us to mutate arguments arguments and when emitting will + // try to do something where it checks the `parent` node of the argument. Using + // `ts.factory.create*`, the `parent` is `undefined` and this check will throw an error. + // In order to get past this error, we manually update the `parent` node of each + // argument to reference the existing call expression. This allows TypeScript to fully + // type check and/or emit. + (argument as any).parent = node; + }); + + /** + * We're not supposed to mutate arguments since it is supposed to be read-only. But, if I + * return a new callExpression, there is no parent and it is no longer linked to the + * import module. This causes incorrect code when the module export type is `commonjs`. + * For example: + * + * ```ts + * // with new callExpression + * const canvas_kit_styling_1 = require(...) + * + * createStyles({...}) + * + * // if we instead mutate arguments + * const canvas_kit_styling_1 = require(...) + * + * canvas_kit_styling_1.createStyles({...}) + * ``` + * + * My best guess as to why it fails when creating a new callExpression is the node's + * symbol declaration link gets lost. TypeScript then has no idea `createStyles` comes + * from an `ImportDeclaration` declaration node and when emitting `commonjs`, it doesn't + * prefix with the `canvas_kit_styling_1`. This is hacky, but the only thing that works + * correctly. + */ + (node.arguments as any) = newArguments; + + return node; + } + + return; +}; diff --git a/modules/styling-transform/lib/utils/handleCreateVars.ts b/modules/styling-transform/lib/utils/handleCreateVars.ts new file mode 100644 index 0000000000..f5008c869d --- /dev/null +++ b/modules/styling-transform/lib/utils/handleCreateVars.ts @@ -0,0 +1,52 @@ +import ts from 'typescript'; + +import {slugify} from '@workday/canvas-kit-styling'; + +import {getVarName} from './getVarName'; +import {makeEmotionSafe} from './makeEmotionSafe'; +import {NodeTransformer} from './types'; + +export const handleCreateVars: NodeTransformer = (node, _checker, prefix, vars) => { + /** + * This will create a variable + */ + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'createVars' + ) { + const id = slugify(getVarName(node)).replace('-vars', ''); + const variables = node.arguments + .map(arg => ts.isStringLiteral(arg) && arg.text) + .filter(Boolean) as string[]; + + variables.forEach(v => { + vars[`${id}-${makeEmotionSafe(v)}`] = `--${prefix}-${id}-${makeEmotionSafe(v)}`; + }); + + return ts.factory.createCallExpression( + node.expression, + [], + [ + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('id'), + ts.factory.createStringLiteral(`${prefix}-${id}`) + ), + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('args'), + ts.factory.createArrayLiteralExpression( + variables.map(val => ts.factory.createStringLiteral(val)), + false + ) + ), + ], + false + ), + ] + ); + } + + return; +}; diff --git a/modules/styling-transform/lib/utils/handleCssVar.ts b/modules/styling-transform/lib/utils/handleCssVar.ts new file mode 100644 index 0000000000..9b5bb40c1b --- /dev/null +++ b/modules/styling-transform/lib/utils/handleCssVar.ts @@ -0,0 +1,34 @@ +import ts from 'typescript'; + +import {NodeTransformer} from './types'; + +/** + * Converts `cssVar` to a `TemplateExpression` with each argument as a span + * + * - `cssVar('--foo')` => `\`var(${'--foo'})\`` + * - `cssVar('--foo', 'fallback')` => `\`var(${'--foo'}, ${'fallback'})\`` + * + * The value parser will figure out what to do from there. We don't have access to variables at this + * point, so we forward CallExpression arguments in ways the value parser understands. + */ +export const handleCssVar: NodeTransformer = (node, checker, prefix, vars) => { + // cssVar(a) + // cssVar(a, b) + + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'cssVar' + ) { + if (node.arguments.length === 1) { + return node.arguments[0]; + } + + return ts.factory.createTemplateExpression(ts.factory.createTemplateHead('var('), [ + ts.factory.createTemplateSpan(node.arguments[0], ts.factory.createTemplateMiddle(', ')), + ts.factory.createTemplateSpan(node.arguments[1], ts.factory.createTemplateTail(')')), + ]); + } + + return; +}; diff --git a/modules/styling-transform/lib/utils/handleFocusRing.ts b/modules/styling-transform/lib/utils/handleFocusRing.ts new file mode 100644 index 0000000000..46ccaf045e --- /dev/null +++ b/modules/styling-transform/lib/utils/handleFocusRing.ts @@ -0,0 +1,130 @@ +import ts from 'typescript'; + +import {base, brand} from '@workday/canvas-tokens-web'; + +import {NodeTransformer} from './types'; +import {parseNodeToStaticValue} from './parseNodeToStaticValue'; + +export const handleFocusRing: NodeTransformer = (node, checker, prefix, vars) => { + // { ...focusRing() } + /** + * A spread assignment looks like: + * + * ```ts + * { + * ...styles + * } + * ``` + * + * https://ts-ast-viewer.com/#code/MYewdgzgLgBFCmBbADjAvDA3gMxCAXDAOQBGAhgE5EC+AUKJLAigEzpYB0Xzy1QA + */ + if (ts.isSpreadAssignment(node)) { + // Detect `focusRing` calls. This is temporary until we figure out a better way to do focus + // rings that doesn't require a special entry in the transform function. + // + // TODO: implement a fully working type resolver for CSS variables or remove support for them an + // remove all uses of `focusRing` from new styling code + if ( + ts.isCallExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'focusRing' + ) { + const argumentObject = node.expression.arguments[0]; + // defaults + const defaults = { + width: ts.factory.createStringLiteral('2px') as ts.Expression, + separation: ts.factory.createStringLiteral('0px') as ts.Expression, + inset: ts.factory.createIdentifier('undefined') as ts.Expression, + innerColor: ts.factory.createStringLiteral(base.frenchVanilla100) as ts.Expression, + outerColor: ts.factory.createStringLiteral(brand.common.focusOutline) as ts.Expression, + }; + if (argumentObject && ts.isObjectLiteralExpression(argumentObject)) { + argumentObject.properties.forEach(property => { + if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)) { + defaults[property.name.text as keyof typeof defaults] = property.initializer; + } + }); + } + + const inset = parseNodeToStaticValue(defaults.inset, checker, prefix, vars); + + let boxShadow: ts.TemplateExpression; + switch (inset) { + case 'outer': + boxShadow = createTemplateExpression( + 'inset 0 0 0 ', + defaults.separation, + ' ', + defaults.outerColor, + ' inset 0 0 0 calc(', + defaults.width, + ' + ', + defaults.separation, + ') ', + defaults.innerColor + ); + break; + + case 'inner': + boxShadow = createTemplateExpression( + 'inset 0 0 0 ', + defaults.separation, + ' ', + defaults.innerColor, + ', 0 0 0 ', + defaults.width, + ' ', + defaults.outerColor + ); + break; + + default: + boxShadow = createTemplateExpression( + '0 0 0 ', + defaults.separation, + ' ', + defaults.innerColor, + ', 0 0 0 calc(', + defaults.width, + ' + ', + defaults.separation, + ') ', + defaults.outerColor + ); + break; + } + + return ts.factory.createSpreadAssignment( + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('boxShadow'), + boxShadow + ), + ], + false + ) + ); + } + } + + return; +}; + +function createTemplateExpression(head: string, ...args: (string | ts.Expression)[]) { + const headNode = ts.factory.createTemplateHead(head); + const spanNodes = args.reduce((result, value, index) => { + if (typeof value !== 'string') { + const literal = + index === args.length - 1 + ? ts.factory.createTemplateTail('') + : index === args.length - 2 + ? ts.factory.createTemplateTail(args[index + 1] as string) + : ts.factory.createTemplateMiddle(args[index + 1] as string); + result.push(ts.factory.createTemplateSpan(value, literal)); + } + return result; + }, [] as ts.TemplateSpan[]); + + return ts.factory.createTemplateExpression(headNode, spanNodes); +} diff --git a/modules/styling-transform/lib/utils/handlePx2Rem.ts b/modules/styling-transform/lib/utils/handlePx2Rem.ts new file mode 100644 index 0000000000..3fe3f5a6e5 --- /dev/null +++ b/modules/styling-transform/lib/utils/handlePx2Rem.ts @@ -0,0 +1,27 @@ +import ts from 'typescript'; + +import {isImportedFromStyling} from './isImportedFromStyling'; +import {NodeTransformer} from './types'; + +/** + * Handle the CallExpression `px2rem` to do static conversion and remove the CallExpression. + */ +export const handlePx2Rem: NodeTransformer = (node, checker) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'px2rem' && + isImportedFromStyling(node.expression, checker) + ) { + const [pxArgument, baseArgument] = node.arguments; + const base = + baseArgument && ts.isNumericLiteral(baseArgument) ? parseFloat(baseArgument.text) : 16; + + if (ts.isNumericLiteral(pxArgument)) { + const px = parseFloat(pxArgument.text); + return ts.factory.createStringLiteral(`${px / base}rem`); + } + } + + return; +}; diff --git a/modules/styling-transform/lib/utils/isImportedFromStyling.ts b/modules/styling-transform/lib/utils/isImportedFromStyling.ts new file mode 100644 index 0000000000..5ea980e5d5 --- /dev/null +++ b/modules/styling-transform/lib/utils/isImportedFromStyling.ts @@ -0,0 +1,21 @@ +import ts from 'typescript'; + +/** + * Checks if the node was imported from '@workday/canvas-kit-styling'. This is useful if you want to + * limit transformation to only styling imports. + */ +export function isImportedFromStyling(node: ts.Node, checker: ts.TypeChecker) { + const symbol = checker.getSymbolAtLocation(node); + const declaration = symbol?.valueDeclaration || symbol?.declarations[0]; + + if ( + declaration && + ts.isImportSpecifier(declaration) && + ts.isStringLiteral(declaration.parent.parent.parent.moduleSpecifier) && + declaration.parent.parent.parent.moduleSpecifier.text === '@workday/canvas-kit-styling' + ) { + return true; + } + + return false; +} diff --git a/modules/styling-transform/lib/utils/makeEmotionSafe.ts b/modules/styling-transform/lib/utils/makeEmotionSafe.ts new file mode 100644 index 0000000000..0d18ccbcdb --- /dev/null +++ b/modules/styling-transform/lib/utils/makeEmotionSafe.ts @@ -0,0 +1,11 @@ +/** + * Util function to fix an issue with Emotion by + * appending `EmotionIssue#3066` to end of css variable + * See issue: [#3066](https://github.com/emotion-js/emotion/issues/3066) + */ +export function makeEmotionSafe(key: string): string { + if (key.endsWith('label')) { + return `${key}-emotion-safe`; + } + return key; +} diff --git a/modules/styling-transform/lib/utils/parseNodeToStaticValue.ts b/modules/styling-transform/lib/utils/parseNodeToStaticValue.ts new file mode 100644 index 0000000000..51b1309d85 --- /dev/null +++ b/modules/styling-transform/lib/utils/parseNodeToStaticValue.ts @@ -0,0 +1,178 @@ +import ts from 'typescript'; + +import {slugify} from '@workday/canvas-kit-styling'; + +import {makeEmotionSafe} from './makeEmotionSafe'; +import {getErrorMessage} from './getErrorMessage'; + +/** + * This is the workhorse of statically analyzing style values + */ +export function parseNodeToStaticValue( + node: ts.Node, + checker: ts.TypeChecker, + prefix: string = 'css', + variables: Record = {} +): string { + /** + * String literals like 'red' or empty Template Expressions like `red` + */ + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + + // 12 + if (ts.isNumericLiteral(node)) { + return `${node.text}px`; + } + + // undefined + if (ts.isIdentifier(node) && node.text === 'undefined') { + return 'undefined'; + } + + // a.b + if (ts.isPropertyAccessExpression(node)) { + const varName = getCSSVariableKey(getPropertyAccessExpressionText(node)); + + if (variables[varName]) { + return variables[varName]; + } + } + + // [a.b] + if (ts.isComputedPropertyName(node) && ts.isPropertyAccessExpression(node.expression)) { + const varName = getCSSVariableKey(getPropertyAccessExpressionText(node.expression)); + + if (variables[varName]) { + return variables[varName]; + } + } + + /** + * ```ts + * `border 1px ${myVars.colors.border}` + * ``` + */ + if (ts.isTemplateExpression(node)) { + return getStyleValueFromTemplateExpression(node, checker, prefix, variables); + } + + /** + * An Identifier is a simple variable. It may represent a variable, so we'll check it before + * moving on. This typically happens in stencils. + */ + if (ts.isIdentifier(node)) { + if (variables[node.text]) { + return variables[node.text]; + } + } + + // If we got here, we cannot statically analyze by the AST alone. We have to check the type of the + // correct AST Node + + if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) { + const value = parseTypeToStaticValue(checker.getTypeAtLocation(node)); + if (value) { + return value; + } + } + + if (ts.isComputedPropertyName(node)) { + const value = parseTypeToStaticValue(checker.getTypeAtLocation(node.expression)); + if (value) { + return value; + } + } + + // we don't know what this is, we need to throw an error + const type = checker.getTypeAtLocation(node); + + const typeValue = checker.typeToString(type); + + throw new Error( + `Unknown type at: "${node.getText()}". Received "${typeValue}"\n${getErrorMessage( + node + )}\nFor static analysis of styles, please make sure all types resolve to string or numeric literals. Please use 'const' instead of 'let'. If using an object, cast using "as const" or use an interface with string or numeric literals. Variables: ${JSON.stringify( + variables, + null, + ' ' + )}` + ); +} + +function parseTypeToStaticValue(type: ts.Type): string | void { + if (type.isStringLiteral()) { + return type.value; + } + + if (type.isNumberLiteral()) { + return `${type.value}px`; + } +} + +function getCSSVariableKey(text: string): string { + const [id, name] = getVariableNameParts(text); + return `${slugify(id)}-${makeEmotionSafe(name)}`; +} + +/** + * A `PropertyExpression` is an expression with a dot in it. Like `a.b.c`. It may be nested. This + * function will walk the AST and create a string like `a.b.c` to be passed on to variable name + * generation. This will be used for CSS variable lookups. + */ +function getPropertyAccessExpressionText(node: ts.PropertyAccessExpression): string { + if (ts.isIdentifier(node.name)) { + if (ts.isIdentifier(node.expression)) { + return `${node.expression.text}.${node.name.text}`; + } + if (ts.isPropertyAccessExpression(node.expression)) { + return `${getPropertyAccessExpressionText(node.expression)}.${node.name.text}`; + } + } + return ''; +} + +function getVariableNameParts(input: string): [string, string] { + const parts = input.split('.'); + + // grab the last item in the array. This will also mutate the array, removing the last item + const variable = parts.pop()!; + + return [parts.join('.').replace(/(vars|stencil|styles)/i, ''), variable]; +} + +/** + * Gets a static string value from a template expression. It could recurse. + */ +function getStyleValueFromTemplateExpression( + node: ts.Node | undefined, + checker: ts.TypeChecker, + prefix = 'css', + variables: Record = {} +): string { + if (!node) { + return ''; + } + if (ts.isTemplateExpression(node)) { + return ( + getStyleValueFromTemplateExpression(node.head, checker, prefix, variables) + + node.templateSpans + .map(value => getStyleValueFromTemplateExpression(value, checker, prefix, variables)) + .join('') + ); + } + + if (ts.isTemplateHead(node) || ts.isTemplateTail(node) || ts.isTemplateMiddle(node)) { + return node.text; + } + + if (ts.isTemplateSpan(node)) { + return ( + parseNodeToStaticValue(node.expression, checker, prefix, variables) + + getStyleValueFromTemplateExpression(node.literal, checker, prefix, variables) + ); + } + + return ''; +} diff --git a/modules/styling-transform/lib/utils/parseObjectToStaticValue.ts b/modules/styling-transform/lib/utils/parseObjectToStaticValue.ts new file mode 100644 index 0000000000..6f339c67b7 --- /dev/null +++ b/modules/styling-transform/lib/utils/parseObjectToStaticValue.ts @@ -0,0 +1,151 @@ +import ts from 'typescript'; +import {getFallbackVariable} from './getFallbackVariable'; + +import {parseNodeToStaticValue} from './parseNodeToStaticValue'; + +export type NestedStyleObject = {[key: string]: string | NestedStyleObject}; + +export function parseObjectToStaticValue( + node: ts.Node, + checker: ts.TypeChecker, + prefix = 'css', + variables: Record = {} +): NestedStyleObject { + let styleObj: NestedStyleObject = {}; + + if (ts.isObjectLiteralExpression(node)) { + node.properties.forEach(property => { + styleObj = {...styleObj, ...parsePropertyToStaticValue(property, checker, prefix, variables)}; + }); + } + + return styleObj; +} + +function parsePropertyToStaticValue( + node: ts.Node, + checker: ts.TypeChecker, + prefix = 'css', + variables: Record = {} +): NestedStyleObject { + const styleObj: NestedStyleObject = {}; + + // { name: value } + if (ts.isPropertyAssignment(node)) { + const key = ts.isIdentifier(node.name) + ? node.name.text + : parseNodeToStaticValue(node.name, checker, prefix, variables); + if (key) { + if (ts.isObjectLiteralExpression(node.initializer)) { + // nested + styleObj[key] = parseObjectToStaticValue(node.initializer, checker, prefix, variables); + } else { + styleObj[key] = maybeWrapCSSVariables( + parseNodeToStaticValue(node.initializer, checker, prefix, variables), + variables + ); + parseNodeToStaticValue(node.initializer, checker, prefix, variables); + } + + return styleObj; + } + } + + // { name: value } (types) + if (ts.isPropertySignature(node)) { + const key = ts.isIdentifier(node.name) + ? node.name.text + : parseNodeToStaticValue(node.name, checker, prefix, variables); + if (key) { + if (key.includes('&') || key.startsWith(':')) { + // nested + styleObj[key] = parseObjectToStaticValue(node.type!, checker, prefix, variables); + } else { + styleObj[key] = + maybeWrapCSSVariables( + parseNodeToStaticValue(node.type!, checker, prefix, variables), + variables + ) || ''; + } + + return styleObj; + } + } + + // {...{key: 'value'}} + if (ts.isSpreadAssignment(node) && ts.isObjectLiteralExpression(node.expression)) { + // recurse to parse a nested ObjectLiteralExpression + return parseObjectToStaticValue(node.expression, checker, prefix, variables); + } + + // { ...value } + if (ts.isSpreadAssignment(node)) { + // Spread assignments are a bit complicated to use the AST to figure out, so we'll ask the + // TypeScript type checker. + const type = checker.getTypeAtLocation(node.expression); + checker.typeToString(type); + return parseStyleObjFromType(type, checker, prefix, variables); + } + + return styleObj; +} + +/** + * If we're here, we have a `ts.Type` that represents a style object. We try to parse a style object + * from the AST, but we might have something that is more complicated like a function call or an + * identifier that represents an object. It could be imported from another file. + */ +export function parseStyleObjFromType( + type: ts.Type, + checker: ts.TypeChecker, + prefix: string, + variables: Record +) { + const styleObj: Record = {}; + + // Gets all the properties of the type object + return type.getProperties().reduce((result, property) => { + const declaration = property.valueDeclaration; + + // we might have generics, so we'll use the type of the symbol instead of the type at the + // declaration. This resolves generics like `T` into literal values if they exist. + const propType = checker.getTypeOfSymbolAtLocation(property, declaration); + + if (propType.isStringLiteral()) { + // This isn't a component variable, it is a static CSS variable + result[property.name] = propType.value; + return result; + } + + if (propType.isNumberLiteral()) { + result[property.name] = `${propType.value}px`; + return result; + } + return { + ...result, + ...parsePropertyToStaticValue(declaration, checker, prefix, variables), + }; + }, styleObj); +} + +/** + * Wrap all unwrapped CSS Variables. For example, `{padding: '--foo'}` will be replaced with + * `{padding: 'var(--foo)'}`. It also works on variables in the middle of the property. + */ +function maybeWrapCSSVariables(input: string, variables: Record): string { + // matches an string starting with `--` that isn't already wrapped in a `var()`. It tries to match + // any character that isn't a valid separator in CSS + return input.replace( + /([a-z]*[ (]*)(--[^\s;,'})]+)/gi, + (match: string, prefix: string, variable: string) => { + if (prefix.startsWith('var(')) { + return match; + } + const fallbackVariable = getFallbackVariable(variable, variables); + const fallback = fallbackVariable + ? `, ${maybeWrapCSSVariables(fallbackVariable, variables)}` + : ''; + return `${prefix}var(${variable}${fallback})`; + } + ); +} diff --git a/modules/styling-transform/lib/utils/types.ts b/modules/styling-transform/lib/utils/types.ts new file mode 100644 index 0000000000..12bf67d817 --- /dev/null +++ b/modules/styling-transform/lib/utils/types.ts @@ -0,0 +1,15 @@ +import ts from 'typescript'; + +/** + * Transformer function type. A transformer will be called by the TypeScript AST transformer visitor + * from the bottom of the tree to the top (inside-out/leaf first, root last). If a transformer knows + * how to handle the AST node, a node should be returned. Even if no transformation is desired, + * returning a node shortcuts processing. The visitor will call all NodeTransformers until a match + * is met. + */ +export type NodeTransformer = ( + node: ts.Node, + checker: ts.TypeChecker, + prefix: string, + variables: Record +) => ts.Node | void; diff --git a/modules/styling-transform/package.json b/modules/styling-transform/package.json index ef0f69960c..0d046119e6 100644 --- a/modules/styling-transform/package.json +++ b/modules/styling-transform/package.json @@ -45,5 +45,8 @@ "@workday/canvas-tokens-web": "^1.0.0", "stylis": "4.0.13", "typescript": "4.2" + }, + "devDependencies": { + "common-tags": "^1.8.0" } } diff --git a/modules/styling-transform/spec/createProgramFromSource.ts b/modules/styling-transform/spec/createProgramFromSource.ts index bdb3c3f97c..5f58a0db2d 100644 --- a/modules/styling-transform/spec/createProgramFromSource.ts +++ b/modules/styling-transform/spec/createProgramFromSource.ts @@ -1,6 +1,8 @@ import * as ts from 'typescript'; import path from 'path'; +import {stripIndent} from 'common-tags'; + function getConfig() { const tsconfigPath = ts.findConfigFile('.', ts.sys.fileExists) || ''; @@ -12,6 +14,9 @@ function getConfig() { return options; } +// keep this around for speed. A ts program can take a previous program and share caching +let program: ts.Program; + const styleSource = ` export type CsVarsMap = [ID] extends [never] ? Record @@ -44,60 +49,64 @@ export function createProgramFromSource(...args: any[]) { } const sourceFiles = sources.map(({filename, source}) => { - return ts.createSourceFile(filename, source, ts.ScriptTarget.Latest); + return ts.createSourceFile(filename, stripIndent(source), ts.ScriptTarget.Latest); }); - const config = getConfig(); + sourceFiles.push( + ts.createSourceFile( + 'node_modules/@workday/canvas-kit-styling/index.ts', + styleSource, + ts.ScriptTarget.Latest + ) + ); - const defaultCompilerHost = ts.createCompilerHost(config); + const config = getConfig(); const customCompilerHost: ts.CompilerHost = { getSourceFile: (name, languageVersion) => { // Get the file from our mock list, but read source lib files - return ( - sourceFiles.find(s => s.fileName === name) || - // defaultCompilerHost.getSourceFile(name, languageVersion) - (name.startsWith('lib') - ? ts.createSourceFile( - name, - ts.sys.readFile(`node_modules/typescript/lib/${name}`), - languageVersion - ) - : name === 'node_modules/@workday/canvas-kit-styling/index.ts' - ? ts.createSourceFile(name, styleSource, languageVersion) - : name === 'node_modules/react.ts' - ? ts.createSourceFile( - name, - ts.sys.readFile(`node_modules/@types/react/index.d.ts`), - languageVersion - ) - : defaultCompilerHost.getSourceFile(name, languageVersion)) - ); + const mockedFile = sourceFiles.find(s => s.fileName === name); + if (mockedFile) { + return mockedFile; + } + + if (name.startsWith('lib')) { + return ts.createSourceFile( + name, + ts.sys.readFile(`node_modules/typescript/lib/${name}`), + languageVersion + ); + } + + const fileContents = ts.sys.readFile(name); + if (fileContents) { + return ts.createSourceFile(name, fileContents, languageVersion); + } + + return undefined; }, // eslint-disable-next-line no-empty-function writeFile: () => {}, getDefaultLibFileName: () => 'lib.d.ts', - useCaseSensitiveFileNames: () => false, - getCanonicalFileName: filename => filename, - getCurrentDirectory: () => '', - getNewLine: () => '\n', - getDirectories: () => [], + useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, + getCanonicalFileName: fileName => + ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(), + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + getNewLine: () => ts.sys.newLine, + getDirectories: path => ts.sys.getDirectories(path), // This should be kept up to date with getSourceFile() fileExists: fileName => { - return ( - fileName.startsWith('lib') || - fileName === 'node_modules/react.ts' || - fileName === 'node_modules/@types/react/index.d.ts' || - fileName === 'node_modules/@workday/canvas-kit-styling/index.ts' || - !!sourceFiles.find(s => s.fileName === fileName) - ); + return !!sourceFiles.find(s => s.fileName === fileName) || ts.sys.fileExists(fileName); }, - readFile: () => '', + readFile: fileName => ts.sys.readFile(fileName), }; - return ts.createProgram( + program = ts.createProgram( sources.map(s => s.filename), config, - customCompilerHost + customCompilerHost, + program ); + + return program; } diff --git a/modules/styling-transform/spec/findNodes.ts b/modules/styling-transform/spec/findNodes.ts new file mode 100644 index 0000000000..57c4f2d5d4 --- /dev/null +++ b/modules/styling-transform/spec/findNodes.ts @@ -0,0 +1,32 @@ +import ts from 'typescript'; + +function matchesName(node: ts.Node, name: string) { + return ( + (ts.isIdentifier(node) && node.text === name) || + (node as any)?.name?.text === name || // declarations + (node as any)?.expression?.text // expressions + ); +} + +export function findNodes boolean>( + node: ts.Node, + name: string = '', + predicate?: N +): (N extends (node: any) => node is infer S ? S[] : ts.Node[]) | undefined { + const nodes: ts.Node[] = []; + if (!node) { + return nodes as any; + } + + if ((predicate ? predicate(node) : true) && (name ? matchesName(node, name) : true)) { + nodes.push(node); + } + + node.forEachChild(child => { + // The AST doesn't have parent nodes, but they are required for testing purposes. + (child as any).parent = node; + nodes.push(...findNodes(child, name, predicate)); + }); + + return nodes as any; +} diff --git a/modules/styling-transform/spec/getCssVariables.spec.ts b/modules/styling-transform/spec/getCssVariables.spec.ts deleted file mode 100644 index 3cb0b1556f..0000000000 --- a/modules/styling-transform/spec/getCssVariables.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import {getVariablesFromFiles, getFallbackVariable} from '../lib/getCssVariables'; - -describe('getVariablesFromFiles', () => { - it('should get a variable from a single file', () => { - const actual = getVariablesFromFiles([ - `:root { - --var-1: red - }`, - ]); - - expect(actual).toEqual({ - '--var-1': 'red', - }); - }); - - it('should get variables from multiple files', () => { - const actual = getVariablesFromFiles([ - `:root { - --var-1: red - }`, - `:root { - --var-2: blue - }`, - ]); - - expect(actual).toEqual({ - '--var-1': 'red', - '--var-2': 'blue', - }); - }); -}); - -describe('getFallbackVariable', () => { - it('should find the fallback variable of a single variable', () => { - const variables = getVariablesFromFiles([ - `:root { - --var-1: red - }`, - ]); - const actual = getFallbackVariable('--var-1', variables); - - expect(actual).toEqual('red'); - }); - - it('should recurse a single level to find a value', () => { - const variables = getVariablesFromFiles([ - `:root { - --var-1: var(--var-2); - --var-2: red - }`, - ]); - const actual = getFallbackVariable('--var-1', variables); - - expect(actual).toEqual('red'); - }); - - it('should recurse many levels to find a value', () => { - const variables = getVariablesFromFiles([ - `:root { - --var-1: var(--var-2); - --var-2: var(--var-3); - --var-3: red - }`, - ]); - const actual = getFallbackVariable('--var-1', variables); - - expect(actual).toEqual('red'); - }); -}); diff --git a/modules/styling-transform/spec/utils/createStyleObjectNode.spec.ts b/modules/styling-transform/spec/utils/createStyleObjectNode.spec.ts new file mode 100644 index 0000000000..fbac1e01ec --- /dev/null +++ b/modules/styling-transform/spec/utils/createStyleObjectNode.spec.ts @@ -0,0 +1,18 @@ +import ts from 'typescript'; + +import {createStyleObjectNode} from '../../lib/utils/createStyleObjectNode'; + +describe('createStyleObjectNode', () => { + it('should serialize styles with a hard-coded name and styles', () => { + const styleObj = { + padding: '12px', + }; + + const node = createStyleObjectNode(styleObj); + + const printer = ts.createPrinter(); + const output = printer.printNode(ts.EmitHint.Unspecified, node, {} as any); + + expect(output).toMatch(/{ name: "[a-z0-9]+", styles: "padding:12px;" }/); + }); +}); diff --git a/modules/styling-transform/spec/utils/getCssVariables.spec.ts b/modules/styling-transform/spec/utils/getCssVariables.spec.ts new file mode 100644 index 0000000000..04c637c404 --- /dev/null +++ b/modules/styling-transform/spec/utils/getCssVariables.spec.ts @@ -0,0 +1,31 @@ +import {getVariablesFromFiles} from '../../lib/utils/getCssVariables'; + +describe('getVariablesFromFiles', () => { + it('should get a variable from a single file', () => { + const actual = getVariablesFromFiles([ + `:root { + --var-1: red + }`, + ]); + + expect(actual).toEqual({ + '--var-1': 'red', + }); + }); + + it('should get variables from multiple files', () => { + const actual = getVariablesFromFiles([ + `:root { + --var-1: red + }`, + `:root { + --var-2: blue + }`, + ]); + + expect(actual).toEqual({ + '--var-1': 'red', + '--var-2': 'blue', + }); + }); +}); diff --git a/modules/styling-transform/spec/utils/getErrorMessage.spec.ts b/modules/styling-transform/spec/utils/getErrorMessage.spec.ts new file mode 100644 index 0000000000..4a1e06b3c9 --- /dev/null +++ b/modules/styling-transform/spec/utils/getErrorMessage.spec.ts @@ -0,0 +1,25 @@ +import ts from 'typescript'; + +import {findNodes} from '../findNodes'; +import {createProgramFromSource} from '../createProgramFromSource'; + +import {getErrorMessage} from '../../lib/utils/getErrorMessage'; + +describe('getErrorMessage', () => { + it('should return a message with fileName, line, character, and underline the correct characters', () => { + const program = createProgramFromSource(` + const foo = { + bar: baz + }; + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, 'baz', ts.isIdentifier)[0]; + + expect(getErrorMessage(node)).toContain('File: test.ts:2:7'); + expect(getErrorMessage(node)).toContain('const foo = {'); + expect(getErrorMessage(node)).toContain(' bar: baz'); + expect(getErrorMessage(node)).toContain(' ==='); + expect(getErrorMessage(node)).toContain('};'); + }); +}); diff --git a/modules/styling-transform/spec/utils/getFallbackVariables.spec.ts b/modules/styling-transform/spec/utils/getFallbackVariables.spec.ts new file mode 100644 index 0000000000..637a0ad367 --- /dev/null +++ b/modules/styling-transform/spec/utils/getFallbackVariables.spec.ts @@ -0,0 +1,33 @@ +import {getFallbackVariable} from '../../lib/utils/getFallbackVariable'; + +describe('getFallbackVariable', () => { + it('should find the fallback variable of a single variable', () => { + const variables = { + '--var-1': 'red', + }; + const actual = getFallbackVariable('--var-1', variables); + + expect(actual).toEqual('red'); + }); + + it('should recurse a single level to find a value', () => { + const variables = { + '--var-1': 'var(--var-2)', + '--var-2': 'red', + }; + const actual = getFallbackVariable('--var-1', variables); + + expect(actual).toEqual('red'); + }); + + it('should recurse many levels to find a value', () => { + const variables = { + '--var-1': 'var(--var-2)', + '--var-2': 'var(--var-3)', + '--var-3': 'red', + }; + const actual = getFallbackVariable('--var-1', variables); + + expect(actual).toEqual('red'); + }); +}); diff --git a/modules/styling-transform/spec/utils/getVarName.spec.ts b/modules/styling-transform/spec/utils/getVarName.spec.ts new file mode 100644 index 0000000000..eb055d6f8c --- /dev/null +++ b/modules/styling-transform/spec/utils/getVarName.spec.ts @@ -0,0 +1,54 @@ +import ts from 'typescript'; + +import {findNodes} from '../findNodes'; +import {createProgramFromSource} from '../createProgramFromSource'; + +import {getVarName} from '../../lib/utils/getVarName'; + +describe('getVarName', () => { + it('should get the correct CSS variable name of a single VariableDeclaration', () => { + const program = createProgramFromSource(` + const foo = 'bar'; + `); + + const sourceFile = program.getSourceFile('test.ts'); + + const node = findNodes(sourceFile, 'foo', ts.isVariableDeclaration)[0]; + + expect(getVarName(node)).toEqual('foo'); + }); + + it('should get the correct CSS variable name of a nested PropertyAssignment', () => { + const program = createProgramFromSource(` + import React from 'react'; + const foo = { + bar: { + baz: '1' + } + }; + `); + + const sourceFile = program.getSourceFile('test.ts'); + + const node = findNodes(sourceFile, 'baz', ts.isPropertyAssignment)[0]; + + expect(getVarName(node)).toEqual('foo-bar-baz'); + }); + + it('should get the correct CSS variable name of a nested PropertyAssignment with functions', () => { + const program = createProgramFromSource(` + import React from 'react'; + const foo = () => ({ + bar: () => ({ + baz: '1' + }) + }); + `); + + const sourceFile = program.getSourceFile('test.ts'); + + const node = findNodes(sourceFile, 'baz', ts.isPropertyAssignment)[0]; + + expect(getVarName(node)).toEqual('foo-bar-baz'); + }); +}); diff --git a/modules/styling-transform/spec/utils/handleCalc.spec.ts b/modules/styling-transform/spec/utils/handleCalc.spec.ts new file mode 100644 index 0000000000..832d1f9a19 --- /dev/null +++ b/modules/styling-transform/spec/utils/handleCalc.spec.ts @@ -0,0 +1,132 @@ +import {createProgramFromSource} from '../createProgramFromSource'; + +import {transform} from '../../lib/styleTransform'; + +describe('handleCalc', () => { + it('should handle calc.add on two StringLiteral values', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const styles = { padding: calc.add('20px', '2rem') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('padding: "calc(20px + 2rem)"'); + }); + + it('should handle calc.add with non StringLiterals', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const foo = '20px' + + const styles = { padding: calc.add(foo, '2rem') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain("padding: `calc(${foo} + ${'2rem'})`"); + }); + it('should handle calc.subtract on two StringLiteral values', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const styles = { padding: calc.subtract('20px', '2rem') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('padding: "calc(20px - 2rem)"'); + }); + + it('should handle calc.subtract with non StringLiterals', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const foo = '20px' + + const styles = { padding: calc.subtract(foo, '2rem') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain("padding: `calc(${foo} - ${'2rem'})`"); + }); + + it('should handle calc.multiply on two StringLiteral values', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const styles = { padding: calc.multiply('20px', '2rem') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('padding: "calc(20px * 2rem)"'); + }); + + it('should handle calc.multiply with non StringLiterals', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const foo = '20px' + + const styles = { padding: calc.multiply(foo, '2rem') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain("padding: `calc(${foo} * ${'2rem'})`"); + }); + it('should handle calc.divide on two StringLiteral values', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const styles = { padding: calc.divide('20px', '2rem') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('padding: "calc(20px / 2rem)"'); + }); + + it('should handle calc.divide with non StringLiterals', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const foo = '20px' + + const styles = { padding: calc.divide(foo, '2rem') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain("padding: `calc(${foo} / ${'2rem'})`"); + }); + it('should handle calc.negate on two StringLiteral values', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const styles = { padding: calc.negate('20px') } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('padding: "calc(20px * -1)"'); + }); + + it('should handle calc.negate with non StringLiterals', () => { + const program = createProgramFromSource(` + import {calc} from '@workday/canvas-kit-styling'; + + const foo = '20px' + + const styles = { padding: calc.negate(foo) } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('padding: `calc(${foo} * ${"-1"})`'); + }); +}); diff --git a/modules/styling-transform/spec/utils/handleCreateStencil.spec.ts b/modules/styling-transform/spec/utils/handleCreateStencil.spec.ts new file mode 100644 index 0000000000..cbe0c39a95 --- /dev/null +++ b/modules/styling-transform/spec/utils/handleCreateStencil.spec.ts @@ -0,0 +1,202 @@ +import ts from 'typescript'; + +import {findNodes} from '../findNodes'; +import {createProgramFromSource} from '../createProgramFromSource'; + +import {handleCreateStencil} from '../../lib/utils/handleCreateStencil'; +import {transform} from '../../lib/styleTransform'; + +describe('handleCreateStencil', () => { + it('should add a variable to the cache when the arguments are strings', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + base: {} + }) + `); + + const sourceFile = program.getSourceFile('test.ts'); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('}, "button")'); + }); + + it('should add a variable to the cache when the arguments are strings', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + vars: { + color: 'red' + } + }) + `); + + const sourceFile = program.getSourceFile('test.ts'); + const vars: Record = {}; + + const node = findNodes(sourceFile, 'createStencil', ts.isCallExpression)[0]; + + handleCreateStencil(node, program.getTypeChecker(), 'css', vars); + + expect(vars).toHaveProperty('button-color', '--css-button-color'); + }); + + it('should parse base styles into statically optimized versions', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + base: { + padding: 10 + } + }) + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('styles: "padding:10px;"'); + }); + + it('should handle parsing variables in base styles', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + vars: { + color: 'red' + }, + base: {padding: 12} + }) + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('styles: "--css-button-color:red;padding:12px;"'); + }); + + it('should handle parsing variables in base styles via an ArrowFunction and ParenthesizedExpression', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + vars: { + color: 'red' + }, + base: ({color}) => ({ + color: color, + padding: 12 + }) + }) + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('--css-button-color:red;'); + expect(result).toContain('color:var(--css-button-color);'); + expect(result).toContain('padding:12px;'); + }); + + it('should handle parsing variables in base styles via an ArrowFunction and ReturnStatement', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + vars: { + color: 'red' + }, + base: ({color}) => { + return { + color: color, + padding: 12 + } + } + }) + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('--css-button-color:red;'); + expect(result).toContain('color:var(--css-button-color);'); + expect(result).toContain('padding:12px;'); + }); + + it('should handle parsing variables in base styles via a MethodDeclaration', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + vars: { + color: 'red' + }, + base({color}) { + return { + color: color, + padding: 12 + } + } + }) + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('--css-button-color:red;'); + expect(result).toContain('color:var(--css-button-color);'); + expect(result).toContain('padding:12px;'); + }); + + it('should handle parsing modifiers with ObjectLiteralExpressions', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + vars: { + color: 'red' + }, + base: {}, + modifiers: { + size: { + large: {padding: 20}, + small: {padding: 10} + } + } + }) + `); + + const result = transform(program, 'test.ts'); + + expect(result).toMatch(/large: { name: "[0-9a-z]+", styles: "padding:20px;" }/); + expect(result).toMatch(/small: { name: "[0-9a-z]+", styles: "padding:10px;" }/); + }); + + it('should handle parsing modifiers with ObjectLiteralExpressions', () => { + const program = createProgramFromSource(` + import {createStencil} from '@workday/canvas-kit-styling'; + + const buttonStencil = createStencil({ + vars: { + color: 'red' + }, + base: {}, + modifiers: { + size: { + large: {}, + small: {} + } + }, + compound: [ + { + modifiers: { size: 'large' }, + styles: { padding: 20 } + } + ] + }) + `); + + const result = transform(program, 'test.ts'); + + expect(result).toMatch(/styles: { name: "[0-9a-z]+", styles: "padding:20px;" }/); + }); +}); diff --git a/modules/styling-transform/spec/styleTransform.spec.ts b/modules/styling-transform/spec/utils/handleCreateStyles.spec.ts similarity index 84% rename from modules/styling-transform/spec/styleTransform.spec.ts rename to modules/styling-transform/spec/utils/handleCreateStyles.spec.ts index dfd5ab7885..bdeca5a6a1 100644 --- a/modules/styling-transform/spec/styleTransform.spec.ts +++ b/modules/styling-transform/spec/utils/handleCreateStyles.spec.ts @@ -1,7 +1,11 @@ -import {transform} from '../lib/styleTransform'; -import {createProgramFromSource} from './createProgramFromSource'; +import {transform, _reset} from '../../lib/styleTransform'; +import {createProgramFromSource} from '../createProgramFromSource'; + +describe('createStyles', () => { + beforeEach(() => { + _reset(); + }); -describe('styleParser', () => { it('should parse string literals, passing them through', () => { const program = createProgramFromSource(` import {createStyles} from '@workday/canvas-kit-styling'; @@ -101,6 +105,22 @@ describe('styleParser', () => { expect(result).toContain('&:hover{background-color:red;}'); }); + it('should handle nested selectors without an &', () => { + const program = createProgramFromSource(` + import {createStyles} from '@workday/canvas-kit-styling'; + + const styles = createStyles({ + '.wd-icon': { + backgroundColor: 'red' + } + }) + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('.wd-icon{background-color:red;}'); + }); + it('should parse properties that are identifiers that have statically extractable types', () => { const program = createProgramFromSource(` import {createStyles} from '@workday/canvas-kit-styling'; @@ -182,6 +202,8 @@ describe('styleParser', () => { width: 2, separation: 2, innerColor: cssVar(myVars.boxShadowInner, cssVar('--test-fallback-inner', '#fff')), + // innerColor: cssVar('--test-fallback-inner', '#fff'), + // innerColor: cssVar('--test-fallback-inner'), outerColor: cssVar( myVars.boxShadowOuter, cssVar('--test-fallback-outer', 'rgba(0,92,184,1)') @@ -193,7 +215,32 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); expect(result).toContain( - 'box-shadow:0 0 0 2px var(--css-my-vars-boxShadowInner, var(--test-fallback-inner, #fff)), 0 0 0 calc(2px + 2px) var(--css-my-vars-boxShadowOuter, var(--test-fallback-outer, rgba(0,92,184,1)));' + 'box-shadow:0 0 0 2px var(--css-my-boxShadowInner, var(--test-fallback-inner, #fff)), 0 0 0 calc(2px + 2px) var(--css-my-boxShadowOuter, var(--test-fallback-outer, rgba(0,92,184,1)));' + ); + }); + + it('should handle spread operator with a "focusRing" call with CSS Variables', () => { + const program = createProgramFromSource(` + import {createStyles, cssVar} from '@workday/canvas-kit-styling'; + + const myVars = createVars('boxShadowInner', 'boxShadowOuter'); + + const styles = createStyles({ + ...focusRing({ + width: 2, + separation: 2, + innerColor: '--test-fallback-inner', + outerColor: '--test-fallback-outer', + }) + }) + `); + + const result = transform(program, 'test.ts', { + variables: {'--test-fallback-inner': 'red', '--test-fallback-outer': 'blue'}, + }); + + expect(result).toContain( + 'box-shadow:0 0 0 2px var(--test-fallback-inner, red), 0 0 0 calc(2px + 2px) var(--test-fallback-outer, blue);' ); }); @@ -221,14 +268,14 @@ describe('styleParser', () => { const program = createProgramFromSource(` import {createStyles} from '@workday/canvas-kit-styling'; - const space = { - small: 12 - } as const + const space = { + small: 12 + } as const - const styles = createStyles({ - padding: space.small - }) - `); + const styles = createStyles({ + padding: space.small + }) + `); const result = transform(program, 'test.ts'); @@ -265,7 +312,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain('background-color:var(--css-my-vars-color);'); + expect(result).toContain('background-color:var(--css-my-color);'); }); it('should handle cssVar call expressions referencing nested variables', () => { @@ -283,7 +330,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain('background-color:var(--css-my-vars-colors-background);'); + expect(result).toContain('background-color:var(--css-my-colors-background);'); }); it('should handle css vars even without the cssVar call expressions referencing static variables', () => { @@ -317,12 +364,12 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain('background-color:var(--css-my-vars-colors-background);'); + expect(result).toContain('background-color:var(--css-my-colors-background);'); }); it('should handle ComputedPropertyName that are static', () => { const program = createProgramFromSource(` - import {createStyles, CsVars, createVars} from '@workday/canvas-kit-styling'; + import {createStyles, CsVars} from '@workday/canvas-kit-styling'; const myVars = { color: '--color' @@ -351,7 +398,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain('--css-my-vars-color:red;'); + expect(result).toContain('--css-my-color:red;'); }); it('should slugify ComputedPropertyName with capital letters that is a variable created with createVars', () => { @@ -367,7 +414,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain('--css-my-vars-hoverColor:red;'); + expect(result).toContain('--css-my-hoverColor:red;'); }); it('should slugify cssVars with capital letters that is a variable created with createVars', () => { @@ -383,7 +430,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain('background-color:var(--css-my-vars-hoverColor);'); + expect(result).toContain('background-color:var(--css-my-hoverColor);'); }); it('should handle ComputedPropertyName that is a variable created with createVars inside an object', () => { @@ -401,7 +448,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain('--css-my-vars-hover-color:red;'); + expect(result).toContain('--css-my-hover-color:red;'); }); it('should handle fallback call expressions referencing static variables', () => { @@ -417,7 +464,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain('background-color:var(--css-my-vars-color, red);'); + expect(result).toContain('background-color:var(--css-my-color, red);'); }); it('should handle fallback call expressions referencing other variables', () => { @@ -433,9 +480,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); - expect(result).toContain( - 'background-color:var(--css-my-vars-color, var(--css-my-vars-background));' - ); + expect(result).toContain('background-color:var(--css-my-color, var(--css-my-background));'); }); it('should handle fallback variables if provided', () => { @@ -514,7 +559,7 @@ describe('styleParser', () => { const result = transform(program, 'test.ts'); expect(result).toContain('css-12345'); - expect(result).toContain('border:1px solid var(--css-my-vars-borderColor);'); + expect(result).toContain('border:1px solid var(--css-my-borderColor);'); }); it('should handle template stings with multiple spans', () => { @@ -532,7 +577,7 @@ describe('styleParser', () => { expect(result).toContain('css-12345'); expect(result).toContain( - 'box-shadow:var(--css-my-vars-boxShadowInner) 0px 0px 0px 2px, var(--css-my-vars-boxShadowOuter) 0px 0px 0px 4px' + 'box-shadow:var(--css-my-boxShadowInner) 0px 0px 0px 2px, var(--css-my-boxShadowOuter) 0px 0px 0px 4px' ); }); @@ -547,9 +592,9 @@ describe('styleParser', () => { }) `); - const result = transform(program, 'test.ts'); //? + const result = transform(program, 'test.ts'); - expect(result).toContain('color:var(--css-my-vars-label-emotion-safe)'); + expect(result).toContain('color:var(--css-my-label-emotion-safe)'); }); it('should handle multiple arguments with an identifier of a type of an object literal', () => { @@ -570,40 +615,11 @@ describe('styleParser', () => { expect(result).toContain('color:blue;'); }); - it('should handle multiple arguments with an identifier of a type of an object literal', () => { - const program = createProgramFromSource([ - { - filename: 'styles.ts', - source: ` - import {createStyles} from '@workday/canvas-kit-styling'; - `, - }, - { - filename: 'test.ts', - source: ` - import {createStyles} from '@workday/canvas-kit-styling'; - - const obj = { - color: 'blue' - } as const - - const styles = createStyles(obj, { - backgroundColor: 'red' - }) - `, - }, - ]); - - const result = transform(program, 'test.ts'); - - expect(result).toContain('color:blue;'); - }); - it('should output an error with the matching line, character, and underlined text', () => { const program = createProgramFromSource(` import {createStyles} from '@workday/canvas-kit-styling'; - const color: string = 'red + const color: string = 'red' const styles = createStyles({ backgroundColor: color @@ -611,6 +627,6 @@ describe('styleParser', () => { `); expect(() => transform(program, 'test.ts')).toThrow(/Unknown type at: "color"/); - expect(() => transform(program, 'test.ts')).toThrow(/File: test.ts:7:26/); + expect(() => transform(program, 'test.ts')).toThrow(/File: test.ts:6:19/); }); }); diff --git a/modules/styling-transform/spec/utils/handleCreateVars.spec.ts b/modules/styling-transform/spec/utils/handleCreateVars.spec.ts new file mode 100644 index 0000000000..8c7319d4e0 --- /dev/null +++ b/modules/styling-transform/spec/utils/handleCreateVars.spec.ts @@ -0,0 +1,41 @@ +import ts from 'typescript'; + +import {findNodes} from '../findNodes'; +import {createProgramFromSource} from '../createProgramFromSource'; + +import {handleCreateVars} from '../../lib/utils/handleCreateVars'; + +describe('handleCreateVars', () => { + it('should add a variable to the cache when the arguments are strings', () => { + const program = createProgramFromSource(` + const myVars = createVars('color', 'background') + `); + + const sourceFile = program.getSourceFile('test.ts'); + const vars: Record = {}; + + const node = findNodes(sourceFile, 'createVars', ts.isCallExpression)[0]; + + handleCreateVars(node, program.getTypeChecker(), 'css', vars); + + expect(vars).toHaveProperty('my-color', '--css-my-color'); + expect(vars).toHaveProperty('my-background', '--css-my-background'); + }); + + it('should add nested variables to the cache when the arguments are strings', () => { + const program = createProgramFromSource(` + const myVars = { + foo: createVars('color') + } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const vars: Record = {}; + + const node = findNodes(sourceFile, 'createVars', ts.isCallExpression)[0]; + + handleCreateVars(node, program.getTypeChecker(), 'css', vars); + + expect(vars).toHaveProperty('my-foo-color', '--css-my-foo-color'); + }); +}); diff --git a/modules/styling-transform/spec/utils/handleCssVar.spec.ts b/modules/styling-transform/spec/utils/handleCssVar.spec.ts new file mode 100644 index 0000000000..5d18de550a --- /dev/null +++ b/modules/styling-transform/spec/utils/handleCssVar.spec.ts @@ -0,0 +1,61 @@ +import {transform, _reset} from '../../lib/styleTransform'; +import {createProgramFromSource} from '../createProgramFromSource'; +import {handleCssVar} from '../../lib/utils/handleCssVar'; + +describe('handleCssVar', () => { + beforeEach(() => { + _reset(); + }); + + it('should rewrite cssVar expressions with a single string literal argument to a TemplateExpression', () => { + const program = createProgramFromSource(` + import {cssVar} from '@workday/canvas-kit-styling'; + + const styles = cssVar('--some-var') + `); + + const result = transform(program, 'test.ts', {transformers: [handleCssVar]}); + + expect(result).toContain("styles = '--some-var'"); + }); + + it('should rewrite cssVar expressions with a single identifier argument to a TemplateExpression', () => { + const program = createProgramFromSource(` + import {cssVar} from '@workday/canvas-kit-styling'; + + const someVar = '--some-var' + + const styles = cssVar(someVar) + `); + + const result = transform(program, 'test.ts', {transformers: [handleCssVar]}); + + expect(result).toContain('styles = someVar'); + }); + + it('should rewrite cssVar expression with two string literals to a TemplateExpression', () => { + const program = createProgramFromSource(` + import {cssVar} from '@workday/canvas-kit-styling'; + + const styles = cssVar('--some-var', '--fallback') + `); + + const result = transform(program, 'test.ts', {transformers: [handleCssVar]}); + + expect(result).toContain("styles = `var(${'--some-var'}, ${'--fallback'})`"); + }); + + it('should rewrite cssVar nested expressions to nested TemplateExpressions', () => { + const program = createProgramFromSource(` + import {cssVar} from '@workday/canvas-kit-styling'; + + const styles = cssVar('--some-var', cssVar('--fallback', 'red')) + `); + + const result = transform(program, 'test.ts', {transformers: [handleCssVar]}); + + expect(result).toContain( + "styles = `var(${'--some-var'}, ${`var(${'--fallback'}, ${'red'})`})`" + ); + }); +}); diff --git a/modules/styling-transform/spec/utils/handleFocusRing.spec.ts b/modules/styling-transform/spec/utils/handleFocusRing.spec.ts new file mode 100644 index 0000000000..ee760fc7a2 --- /dev/null +++ b/modules/styling-transform/spec/utils/handleFocusRing.spec.ts @@ -0,0 +1,74 @@ +import {transform, _reset} from '../../lib/styleTransform'; +import {createProgramFromSource} from '../createProgramFromSource'; +import {handleFocusRing} from '../../lib/utils/handleFocusRing'; + +describe('handleFocusRing', () => { + beforeEach(() => { + _reset(); + }); + + it('should rewrite focusRing with no arguments', () => { + const program = createProgramFromSource(` + const styles = {...focusRing()} + `); + + const result = transform(program, 'test.ts', {transformers: [handleFocusRing]}); + + expect(result).toContain( + 'styles = { ...{ boxShadow: `0 0 0 ${"0px"} ${"--cnvs-base-palette-french-vanilla-100"}, 0 0 0 calc(${"2px"} + ${"0px"}) ${"--cnvs-brand-common-focus-outline"}` } }' + ); + }); + + it('should rewrite focusRing with arguments', () => { + const program = createProgramFromSource(` + const styles = {...focusRing({ + width: '10px', + separation: '1px', + innerColor: myVars.boxShadowInner, + outerColor: 'red' + })} + `); + + const result = transform(program, 'test.ts', {transformers: [handleFocusRing]}); + + expect(result).toContain( + "const styles = { ...{ boxShadow: `0 0 0 ${'1px'} ${myVars.boxShadowInner}, 0 0 0 calc(${'10px'} + ${'1px'}) ${'red'}` } }" + ); + }); + + it('should rewrite focusRing with inset "inner"', () => { + const program = createProgramFromSource(` + const styles = {...focusRing({ + width: '10px', + separation: '1px', + innerColor: 'blue', + outerColor: 'red', + inset: 'inner' + })} + `); + + const result = transform(program, 'test.ts', {transformers: [handleFocusRing]}); + + expect(result).toContain( + "const styles = { ...{ boxShadow: `inset 0 0 0 ${'1px'} ${'blue'}, 0 0 0 ${'10px'} ${'red'}` } }" + ); + }); + + it('should rewrite focusRing with inset "outer"', () => { + const program = createProgramFromSource(` + const styles = {...focusRing({ + width: '10px', + separation: '1px', + innerColor: 'blue', + outerColor: 'red', + inset: 'outer' + })} + `); + + const result = transform(program, 'test.ts', {transformers: [handleFocusRing]}); + + expect(result).toContain( + "const styles = { ...{ boxShadow: `inset 0 0 0 ${'1px'} ${'red'} inset 0 0 0 calc(${'10px'} + ${'1px'}) ${'blue'}` } }" + ); + }); +}); diff --git a/modules/styling-transform/spec/utils/handlePx2Rem.spec.ts b/modules/styling-transform/spec/utils/handlePx2Rem.spec.ts new file mode 100644 index 0000000000..2546b6184d --- /dev/null +++ b/modules/styling-transform/spec/utils/handlePx2Rem.spec.ts @@ -0,0 +1,29 @@ +import {createProgramFromSource} from '../createProgramFromSource'; + +import {transform} from '../../lib/styleTransform'; + +describe('handlePx2Rem', () => { + it('should handle px2rem without a base', () => { + const program = createProgramFromSource(` + import {px2rem} from '@workday/canvas-kit-styling'; + + const styles = { padding: px2rem(20) } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('padding: "1.25rem"'); + }); + + it('should handle px2rem with a base', () => { + const program = createProgramFromSource(` + import {px2rem} from '@workday/canvas-kit-styling'; + + const styles = { padding: px2rem(20, 10) } + `); + + const result = transform(program, 'test.ts'); + + expect(result).toContain('padding: "2rem"'); + }); +}); diff --git a/modules/styling-transform/spec/utils/parseNodeToStaticValue.spec.ts b/modules/styling-transform/spec/utils/parseNodeToStaticValue.spec.ts new file mode 100644 index 0000000000..f1d3f70ff9 --- /dev/null +++ b/modules/styling-transform/spec/utils/parseNodeToStaticValue.spec.ts @@ -0,0 +1,125 @@ +import ts from 'typescript'; + +import {findNodes} from '../findNodes'; +import {createProgramFromSource} from '../createProgramFromSource'; + +import {parseNodeToStaticValue} from '../../lib/utils/parseNodeToStaticValue'; + +describe('parseNodeToStaticValue', () => { + it('should return the string value of a StringLiteral', () => { + const program = createProgramFromSource(` + 'foo' + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isStringLiteral)[0]; + + expect(parseNodeToStaticValue(node, program.getTypeChecker())).toEqual('foo'); + }); + + it('should return the string value of a NumericLiteral', () => { + const program = createProgramFromSource(` + 12 + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isNumericLiteral)[0]; + + expect(parseNodeToStaticValue(node, program.getTypeChecker())).toEqual('12px'); + }); + + it('should return the string value of a string Identifier', () => { + const program = createProgramFromSource(` + const foo = 'bar'; + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isIdentifier)[0]; + + expect(parseNodeToStaticValue(node, program.getTypeChecker())).toEqual('bar'); + }); + + it('should return the string value of a numerical Identifier', () => { + const program = createProgramFromSource(` + const foo = 12; + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isIdentifier)[0]; + + expect(parseNodeToStaticValue(node, program.getTypeChecker())).toEqual('12px'); + }); + + it('should return the string value of a PropertyAccessExpression', () => { + const program = createProgramFromSource(` + const foo = { bar: 'baz' } as const; + + foo.bar + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isPropertyAccessExpression)[0]; + + expect(parseNodeToStaticValue(node, program.getTypeChecker())).toEqual('baz'); + }); + + it('should return the string value of a PropertyAccessExpression', () => { + const program = createProgramFromSource(` + const foo = { bar: {baz: 12} } as const; + + foo.bar.baz + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isPropertyAccessExpression)[0]; + + expect(parseNodeToStaticValue(node, program.getTypeChecker())).toEqual('12px'); + }); + + it('should return the string value of a PropertyAccessExpression of a variable', () => { + const program = createProgramFromSource(` + foo.bar + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isPropertyAccessExpression)[0]; + + expect( + parseNodeToStaticValue(node, program.getTypeChecker(), 'css', { + 'foo-bar': '--css-foo-bar', + }) + ).toEqual('--css-foo-bar'); + }); + + it('should return the string value of a ComputedPropertyName of a variable', () => { + const program = createProgramFromSource(` + const temp = { + [foo.bar]: 'baz' + } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isComputedPropertyName)[0]; + + expect( + parseNodeToStaticValue(node, program.getTypeChecker(), 'css', { + 'foo-bar': '--css-foo-bar', + }) + ).toEqual('--css-foo-bar'); + }); + + it('should return the string value of a ComputedPropertyName of a variable', () => { + const program = createProgramFromSource(` + const foo = '--bar'; + + const temp = { + [foo]: 'baz' + } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isComputedPropertyName)[0]; + + expect(parseNodeToStaticValue(node, program.getTypeChecker())).toEqual('--bar'); + }); +}); diff --git a/modules/styling-transform/spec/utils/parseObjectToStaticValue.spec.ts b/modules/styling-transform/spec/utils/parseObjectToStaticValue.spec.ts new file mode 100644 index 0000000000..bcb8777eb7 --- /dev/null +++ b/modules/styling-transform/spec/utils/parseObjectToStaticValue.spec.ts @@ -0,0 +1,143 @@ +import ts from 'typescript'; + +import {findNodes} from '../findNodes'; +import {createProgramFromSource} from '../createProgramFromSource'; + +import {parseObjectToStaticValue} from '../../lib/utils/parseObjectToStaticValue'; + +describe('parseObjectToStaticValue', () => { + it('should return the string value of a StringLiteral', () => { + const program = createProgramFromSource(` + const foo = { + bar: '12px' + } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isObjectLiteralExpression)[0]; + + expect(parseObjectToStaticValue(node, program.getTypeChecker())).toEqual({ + bar: '12px', + }); + }); + + it('should return the nested value of an ObjectLiteralExpression', () => { + const program = createProgramFromSource(` + const foo = { + '&:hover': { + padding: '12px' + } + } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isObjectLiteralExpression)[0]; + + expect(parseObjectToStaticValue(node, program.getTypeChecker())).toEqual({ + '&:hover': { + padding: '12px', + }, + }); + }); + + it('should return the nested value of a SpreadAssignment', () => { + const program = createProgramFromSource(` + const foo = { + '&:hover': { + padding: '12px' + } + } + + const bar = { ...foo } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isObjectLiteralExpression)[2]; + + expect(parseObjectToStaticValue(node, program.getTypeChecker())).toEqual({ + '&:hover': { + padding: '12px', + }, + }); + }); + + it('should return the nested value of a SpreadAssignment', () => { + const program = createProgramFromSource(` + const foo = { + '&:hover': { + padding: '12px' + } + } + + const bar = { ...foo } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isObjectLiteralExpression)[2]; + + expect(parseObjectToStaticValue(node, program.getTypeChecker())).toEqual({ + '&:hover': { + padding: '12px', + }, + }); + }); + + it('should return the result of the spread operator with a call expression that can return a statically analyzed return', () => { + const program = createProgramFromSource(` + const makeFontSize = (input: T): { fontSize: T} => { + return { + fontSize: input + } + } + + const styles = { + ...makeFontSize('12px') + } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isObjectLiteralExpression)[1]; + + expect(parseObjectToStaticValue(node, program.getTypeChecker())).toEqual({ + fontSize: '12px', + }); + }); + + it('should handle fallback variables automatically for StringLiteral', () => { + const program = createProgramFromSource(` + const styles = { + padding: '--fallback' + } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isObjectLiteralExpression)[0]; + + expect( + parseObjectToStaticValue(node, program.getTypeChecker(), 'css', { + '--fallback': '12px', + }) + ).toEqual({ + padding: 'var(--fallback, 12px)', + }); + }); + + it('should handle nested fallbacks fallback variables automatically for StringLiteral', () => { + const program = createProgramFromSource(` + const styles = { + padding: \`var(${'--foobar'}, ${'--fallback'})\` + } + `); + + const sourceFile = program.getSourceFile('test.ts'); + const node = findNodes(sourceFile, '', ts.isObjectLiteralExpression)[0]; + + expect( + parseObjectToStaticValue(node, program.getTypeChecker(), 'css', { + '--fallback': '12px', + }) + ).toEqual({ + padding: 'var(--foobar, var(--fallback, 12px))', + }); + }); +}); diff --git a/modules/styling-transform/tsconfig.json b/modules/styling-transform/tsconfig.json index 8e08f79fa0..1812987819 100644 --- a/modules/styling-transform/tsconfig.json +++ b/modules/styling-transform/tsconfig.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.json", "exclude": ["node_modules", "ts-tmp", "dist", "**/spec", "**/stories"], "compilerOptions": { - "plugins": [{"transform": "../modules/styling/parser/styleParser.ts", "prefix": "css"}] + "plugins": [ + {"transform": "../modules/styling/parser/styleParser.ts", "prefix": "css", "variables": {}} + ] } } diff --git a/modules/styling/lib/cs.ts b/modules/styling/lib/cs.ts index ac5548860c..ec74757203 100644 --- a/modules/styling/lib/cs.ts +++ b/modules/styling/lib/cs.ts @@ -30,11 +30,29 @@ export type StyleProps = // We can remove this when CSSType supports CSS custom properties type CastStyleProps = Exclude | CSSObject; +/** + * Wrap all unwrapped CSS Variables. For example, `{padding: '--foo'}` will be replaced with + * `{padding: 'var(--foo)'}`. It also works on variables in the middle of the property. + */ +function maybeWrapCSSVariables(input: string): string { + // matches an string starting with `--` that isn't already wrapped in a `var()`. It tries to match + // any character that isn't a valid separator in CSS + return input.replace( + /([a-z]*[ (]*)(--[^\s;,'})]+)/gi, + (match: string, prefix: string, variable: string) => { + if (prefix === 'var(') { + return match; + } + return `${prefix}var(${variable})`; + } + ); +} + function convertProperty(value: T): T { // Handle the case where the value is a variable without the `var()` wrapping function. It happens // enough that it makes sense to automatically wrap. - if (typeof value === 'string' && value.startsWith('--')) { - return `var(${value})` as any as T; + if (typeof value === 'string') { + return maybeWrapCSSVariables(value) as any as T; } return value; } diff --git a/tsconfig.json b/tsconfig.json index eaa4a00539..15355f3733 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,11 @@ { "transform": "./modules/styling-transform/lib/styleTransform.ts", "prefix": "css", - "fallbackFiles": [] + "fallbackFiles": [ + "@workday/canvas-tokens-web/css/base/_variables.css", + "@workday/canvas-tokens-web/css/brand/_variables.css", + "@workday/canvas-tokens-web/css/system/_variables.css" + ] } ] },