From 4a2174c5ff9d4d411c559d252b0f4e248b32a0eb Mon Sep 17 00:00:00 2001 From: Liam Ma Date: Tue, 29 Aug 2023 16:22:50 +1000 Subject: [PATCH] CSS Map alternative compilation approach (#1496) * Add CSS Map * Update packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts Co-authored-by: Jake Lane * Update packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts Co-authored-by: Jake Lane * Remove console.log * Update function name * Add CSS Map to Parcel example * Refactor error handling * Add doc to cssMap * Regenerate cssMap Flow types * Update test * Update error messages * Implement alternative compilation method * Update packages/babel-plugin/src/types.ts Co-authored-by: Jake Lane * Add integration tests, storybooks, and vr tests for CSS Map * Add changeset --------- Co-authored-by: Jake Lane --- .changeset/friendly-sloths-jam.md | 8 + ...rome_laptop_css_map_Conditional_Styles.png | Bin 0 -> 1292 bytes .../chrome_laptop_css_map_Dynamic_Variant.png | Bin 0 -> 4089 bytes .../chrome_laptop_css_map_Merge_Styles.png | Bin 0 -> 1354 bytes .../chrome_laptop_css_map_Variant_As_Prop.png | Bin 0 -> 1622 bytes examples/parcel/src/app.jsx | 2 + examples/parcel/src/ui/css-map.jsx | 12 ++ examples/webpack/src/app.jsx | 2 + examples/webpack/src/ui/css-map.jsx | 12 ++ packages/babel-plugin/src/babel-plugin.ts | 10 +- .../src/css-map/__tests__/index.test.ts | 135 ++++++++++++++++ packages/babel-plugin/src/css-map/index.ts | 153 ++++++++++++++++++ .../src/css-prop/__tests__/css-map.test.ts | 85 ++++++++++ packages/babel-plugin/src/types.ts | 6 + .../babel-plugin/src/utils/css-builders.ts | 38 +++++ .../babel-plugin/src/utils/is-compiled.ts | 15 ++ .../src/utils/transform-css-items.ts | 6 + packages/babel-plugin/src/utils/types.ts | 15 +- .../src/css-map/__tests__/index.test.tsx | 52 ++++++ packages/react/src/css-map/index.js.flow | 25 +++ packages/react/src/css-map/index.ts | 25 +++ packages/react/src/index.js.flow | 1 + packages/react/src/index.ts | 1 + stories/css-map.tsx | 62 +++++++ 24 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 .changeset/friendly-sloths-jam.md create mode 100644 .loki/reference/chrome_laptop_css_map_Conditional_Styles.png create mode 100644 .loki/reference/chrome_laptop_css_map_Dynamic_Variant.png create mode 100644 .loki/reference/chrome_laptop_css_map_Merge_Styles.png create mode 100644 .loki/reference/chrome_laptop_css_map_Variant_As_Prop.png create mode 100644 examples/parcel/src/ui/css-map.jsx create mode 100644 examples/webpack/src/ui/css-map.jsx create mode 100644 packages/babel-plugin/src/css-map/__tests__/index.test.ts create mode 100644 packages/babel-plugin/src/css-map/index.ts create mode 100644 packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts create mode 100644 packages/react/src/css-map/__tests__/index.test.tsx create mode 100644 packages/react/src/css-map/index.js.flow create mode 100644 packages/react/src/css-map/index.ts create mode 100644 stories/css-map.tsx diff --git a/.changeset/friendly-sloths-jam.md b/.changeset/friendly-sloths-jam.md new file mode 100644 index 000000000..a3bad3007 --- /dev/null +++ b/.changeset/friendly-sloths-jam.md @@ -0,0 +1,8 @@ +--- +'@compiled/babel-plugin': minor +'@compiled/webpack-app': minor +'@compiled/parcel-app': minor +'@compiled/react': minor +--- + +Implement the `cssMap` API to enable library users to dynamically choose a varied set of CSS rules. diff --git a/.loki/reference/chrome_laptop_css_map_Conditional_Styles.png b/.loki/reference/chrome_laptop_css_map_Conditional_Styles.png new file mode 100644 index 0000000000000000000000000000000000000000..85163862e3701666531212b73826bdd4eaecc998 GIT binary patch literal 1292 zcmXYwdsNbA7{{qaxty!n&6ITAWJYEg>Pf@YobZE6L-7)sB`(AZt|-h@>@wSIQ!p=- zCg~DuUh^Ozppwg=NVJ#Gd!{g4~ zG-y)NGzrD4x>jY_T zRZz{yU5Zw6wXHuR9d+NEC@c^_m1<6^a)bU2 zhNO>wtnH*YS{JS+;CZMD?*)zKNi_fl8>h`@boUpLrk=8rG-6kgF;O5;SQmQvo{k1! z9CyZnK2n!J1S|At)V_(WCa}Hu5f;>&na&8zVnora{dcpMtr%;0r)RH$HF7iAd8S5% zV&Y_;NpfuASl?J#aWOVFc0Q91>11&0!j!F?l)6Sra=f>;(rZAUYQGG;{1YMYU7+ zH(2*znX}&tFP>0~YDvIgnCPG!^qMt_p*jVJSTc-_OADXSh!k}p*M^A%31x}_r!)n? zYj#UBm^*;lpqF`6UL_N1(7@z1KM_0;+i*C5PX?6jfQXuOxxRd}Z|l5CS_7&>WcFw8 zefV%~WoX_eTs)dMw8dKBSk(C{kF2IU_#jgk+b}i^ zjo$-b(JMvVgSFe;Tz=KQTD2VD=itBh0fNXVx+jp*r<(p`VOPNnJzXQz)TqTzTDR-Z z(X0j1U6;dspvd5}?>?LMl*PrL)Vsrf=$u?^fB7l#brDLTKfC`S>b@YK4=!t9zze$J zX{glQJ+y4;AK&obn$(?rp;knHy;)Zg7fY~AzDc_CpA4PvtI=}554+`l{e9IsKlJ9PE&KL2`jl(Cxv2CPNe?#NimnM!UK;EquuDDI zUi-2p-`+iJB+DPUJtH6`(M{_W9Vyht6D#d83|C?Ftc1=dBx_#D85IQS)XDJD(hZ+| zSuv0LZY1Gha+Yr!9*UrCuVUf}4O24&kM9BksR(N8;DOAYtJTS0zOGI6Df&>=;4t$v zrA;uz&xxl%a+!qOrf9x~nb0BR&Yo^LSUWmbSX``-(v1PXuLTGa)!4HTBtM%oT}ZQL ziC^_rc!qYJ|5Kr8)#d{wBV$?Ij+9kmFME7g$859k1$Eo#NFwsRFj&9y-%;;mL2AdD zganMZ2v1-=t}u99m&-@Xdz#T1r1C>I@5Rihb~%T^wzZDawRs&)^f5^I#(}6kOKKg6{kGrRju$FT YUUj9x)?Z{{SH4t$9}h*gM;yQOKja}+G5`Po literal 0 HcmV?d00001 diff --git a/.loki/reference/chrome_laptop_css_map_Dynamic_Variant.png b/.loki/reference/chrome_laptop_css_map_Dynamic_Variant.png new file mode 100644 index 0000000000000000000000000000000000000000..d7102c15626bec6803e93b6abcaf0a548bdb8ff0 GIT binary patch literal 4089 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 literal 0 HcmV?d00001 diff --git a/.loki/reference/chrome_laptop_css_map_Variant_As_Prop.png b/.loki/reference/chrome_laptop_css_map_Variant_As_Prop.png new file mode 100644 index 0000000000000000000000000000000000000000..34213399ae5530b51e6f6054afa4b06f7a199a59 GIT binary patch literal 1622 zcmY*adpHwn7~kg7!bmGc3(4iABPTT0T52Y3+9VyhQ;e3RqRgd+$}O$OB@4A3my&3h zE($3uN@!wk&0I2Axd%o}Yyzlq^zV}N$N^pRv=%@ey00{Ol z<`@8=1ee?E!OHUaqEZW>SJB8_CCpECL=Lr^KaCb;UGv;r8Kowp@bzN*5c$^mcxt^E5k_1Mt%m=+hk z{q}7v*oW8V3V!c}MyaMScHyOrD$1ygGZjr|+O~G)7dQT%H1xTEVR54<9<%7cy52YHz~q-5x-j%r&YeACK=*lT^`t>L+_ne^He{# z4Y4cR{bl9p6f5TotoZJxIsz^RR}wiUyyxhCNt^LedObhhKOIA>M zb(Hn!p3x`FwS3LMk?t*|6f0o4aOzvnh0|+_CY^xgGlQ3Na1;mumoO^F`f3^`lJK7v#ac!5KMM* zlS2vQjK-eE%O>FSBIkT?+PGWTX9-S2izsQD|%aiBPQ~o3eV-D7qw( zZ*YCY_1?c{lyN1FFOjly4|#}N>5tc|%xzfz3e{<_^q?Yl#Mf+gwBuEgCNwFTzH9ZB zga=m#`deC?(cYrgSVoL_wJneBJ1>$D#XFjUwFiQ1BCm+uWbDatw;?)udS0-*bgq0S zGuI+qrD>9CKbG1C!Ls&J@(UkdU$7ARcc*#R)Xs&d)At{S;scC{tg7(cNR6pN`SIG3 zXiI$(KtqijTaUU?+V>(w-it0WMX;Udoy@3bwb3+TOt^HrAT`HM9=@W&K`I^fVf-T} zpeWE`jA#_3*^QvmmZvRpmn*X603US`xf2nDqUU*8H>$N*b3Fq$!zgt7Q)dm>{0%Q2 zAx-uqF2?=LbfryqP-)~KJ`(mA)wl#*KwMq2(STe{BD{t3`)Dg=lt5MG*VX*2Xf^$g pCk@>fS7~#}1`c2C2mdpd6&m*Qww0Q7`^oVWR!w{{aWi3zq-@ literal 0 HcmV?d00001 diff --git a/examples/parcel/src/app.jsx b/examples/parcel/src/app.jsx index 518bfba66..64c80178e 100644 --- a/examples/parcel/src/app.jsx +++ b/examples/parcel/src/app.jsx @@ -9,6 +9,7 @@ import '@compiled/react'; import { primary } from './constants'; import Annotated from './ui/annotated'; +import CSSMap from './ui/css-map'; import { CustomFileExtensionStyled, customFileExtensionCss, @@ -29,5 +30,6 @@ export const App = () => ( Loading...}> + CSS Map ); diff --git a/examples/parcel/src/ui/css-map.jsx b/examples/parcel/src/ui/css-map.jsx new file mode 100644 index 000000000..1c7f3bbc1 --- /dev/null +++ b/examples/parcel/src/ui/css-map.jsx @@ -0,0 +1,12 @@ +import { css, cssMap } from '@compiled/react'; + +const styles = cssMap({ + danger: { + color: 'red', + }, + success: { + color: 'green', + }, +}); + +export default ({ variant, children }) =>
{children}
; diff --git a/examples/webpack/src/app.jsx b/examples/webpack/src/app.jsx index 3933d1664..9e40ec9ee 100644 --- a/examples/webpack/src/app.jsx +++ b/examples/webpack/src/app.jsx @@ -4,6 +4,7 @@ import { Suspense, lazy } from 'react'; import { primary } from './common/constants'; import Annotated from './ui/annotated'; +import CSSMap from './ui/css-map'; import { CustomFileExtensionStyled, customFileExtensionCss, @@ -23,5 +24,6 @@ export const App = () => ( Custom File Extension Styled
Custom File Extension CSS
+ CSS Map ); diff --git a/examples/webpack/src/ui/css-map.jsx b/examples/webpack/src/ui/css-map.jsx new file mode 100644 index 000000000..1c7f3bbc1 --- /dev/null +++ b/examples/webpack/src/ui/css-map.jsx @@ -0,0 +1,12 @@ +import { css, cssMap } from '@compiled/react'; + +const styles = cssMap({ + danger: { + color: 'red', + }, + success: { + color: 'green', + }, +}); + +export default ({ variant, children }) =>
{children}
; diff --git a/packages/babel-plugin/src/babel-plugin.ts b/packages/babel-plugin/src/babel-plugin.ts index 3e4635315..10d2e0a1c 100644 --- a/packages/babel-plugin/src/babel-plugin.ts +++ b/packages/babel-plugin/src/babel-plugin.ts @@ -8,6 +8,7 @@ import * as t from '@babel/types'; import { unique, preserveLeadingComments } from '@compiled/utils'; import { visitClassNamesPath } from './class-names'; +import { visitCssMapPath } from './css-map'; import { visitCssPropPath } from './css-prop'; import { visitStyledPath } from './styled'; import type { State } from './types'; @@ -20,6 +21,7 @@ import { isCompiledKeyframesTaggedTemplateExpression, isCompiledStyledCallExpression, isCompiledStyledTaggedTemplateExpression, + isCompiledCSSMapCallExpression, } from './utils/is-compiled'; import { normalizePropsUsage } from './utils/normalize-props-usage'; @@ -39,6 +41,7 @@ export default declare((api) => { inherits: jsxSyntax, pre() { this.sheets = {}; + this.cssMap = {}; let cache: Cache; if (this.opts.cache === true) { @@ -150,7 +153,7 @@ export default declare((api) => { return; } - (['styled', 'ClassNames', 'css', 'keyframes'] as const).forEach((apiName) => { + (['styled', 'ClassNames', 'css', 'keyframes', 'cssMap'] as const).forEach((apiName) => { if ( state.compiledImports && t.isIdentifier(specifier.node?.imported) && @@ -171,6 +174,11 @@ export default declare((api) => { path: NodePath | NodePath, state: State ) { + if (isCompiledCSSMapCallExpression(path.node, state)) { + visitCssMapPath(path, { context: 'root', state, parentPath: path }); + return; + } + const hasStyles = isCompiledCSSTaggedTemplateExpression(path.node, state) || isCompiledStyledTaggedTemplateExpression(path.node, state) || diff --git a/packages/babel-plugin/src/css-map/__tests__/index.test.ts b/packages/babel-plugin/src/css-map/__tests__/index.test.ts new file mode 100644 index 000000000..d93df96f9 --- /dev/null +++ b/packages/babel-plugin/src/css-map/__tests__/index.test.ts @@ -0,0 +1,135 @@ +import type { TransformOptions } from '../../test-utils'; +import { transform as transformCode } from '../../test-utils'; +import { ErrorMessages } from '../index'; + +describe('css map', () => { + const transform = (code: string, opts: TransformOptions = {}) => + transformCode(code, { pretty: false, ...opts }); + + const styles = `{ + danger: { + color: 'red', + backgroundColor: 'red' + }, + success: { + color: 'green', + backgroundColor: 'green' + } + }`; + + it('should transform css map', () => { + const actual = transform(` + import { cssMap } from '@compiled/react'; + + const styles = cssMap(${styles}); + `); + + expect(actual).toInclude( + 'const styles={danger:"_syaz5scu _bfhk5scu",success:"_syazbf54 _bfhkbf54"};' + ); + }); + + it('should error out if variants are not defined at the top-most scope of the module.', () => { + expect(() => { + transform(` + import { cssMap } from '@compiled/react'; + + const styles = { + map1: cssMap(${styles}), + } + `); + }).toThrow(ErrorMessages.DEFINE_MAP); + + expect(() => { + transform(` + import { cssMap } from '@compiled/react'; + + const styles = () => cssMap(${styles}) + `); + }).toThrow(ErrorMessages.DEFINE_MAP); + }); + + it('should error out if cssMap receives more than one argument', () => { + expect(() => { + transform(` + import { cssMap } from '@compiled/react'; + + const styles = cssMap(${styles}, ${styles}) + `); + }).toThrow(ErrorMessages.NUMBER_OF_ARGUMENT); + }); + + it('should error out if cssMap does not receive an object', () => { + expect(() => { + transform(` + import { cssMap } from '@compiled/react'; + + const styles = cssMap('color: red') + `); + }).toThrow(ErrorMessages.ARGUMENT_TYPE); + }); + + it('should error out if spread element is used', () => { + expect(() => { + transform(` + import { css, cssMap } from '@compiled/react'; + + const styles = cssMap({ + ...base + }); + `); + }).toThrow(ErrorMessages.NO_SPREAD_ELEMENT); + }); + + it('should error out if object method is used', () => { + expect(() => { + transform(` + import { css, cssMap } from '@compiled/react'; + + const styles = cssMap({ + danger() {} + }); + `); + }).toThrow(ErrorMessages.NO_OBJECT_METHOD); + }); + + it('should error out if variant object is dynamic', () => { + expect(() => { + transform(` + import { css, cssMap } from '@compiled/react'; + + const styles = cssMap({ + danger: otherStyles + }); + `); + }).toThrow(ErrorMessages.STATIC_VARIANT_OBJECT); + }); + + it('should error out if styles include runtime variables', () => { + expect(() => { + transform(` + import { css, cssMap } from '@compiled/react'; + + const styles = cssMap({ + danger: { + color: canNotBeStaticallyEvulated + } + }); + `); + }).toThrow(ErrorMessages.STATIC_VARIANT_OBJECT); + }); + + it('should error out if styles include conditional CSS', () => { + expect(() => { + transform(` + import { css, cssMap } from '@compiled/react'; + + const styles = cssMap({ + danger: { + color: canNotBeStaticallyEvulated ? 'red' : 'blue' + } + }); + `); + }).toThrow(ErrorMessages.STATIC_VARIANT_OBJECT); + }); +}); diff --git a/packages/babel-plugin/src/css-map/index.ts b/packages/babel-plugin/src/css-map/index.ts new file mode 100644 index 000000000..21857c7c4 --- /dev/null +++ b/packages/babel-plugin/src/css-map/index.ts @@ -0,0 +1,153 @@ +import type { NodePath } from '@babel/core'; +import * as t from '@babel/types'; + +import type { Metadata } from '../types'; +import { buildCodeFrameError } from '../utils/ast'; +import { buildCss } from '../utils/css-builders'; +import { transformCssItems } from '../utils/transform-css-items'; + +// The messages are exported for testing. +export enum ErrorMessages { + NO_TAGGED_TEMPLATE = 'cssMap function cannot be used as a tagged template expression.', + NUMBER_OF_ARGUMENT = 'cssMap function can only receive one argument.', + ARGUMENT_TYPE = 'cssMap function can only receive an object.', + DEFINE_MAP = 'CSS Map must be declared at the top-most scope of the module.', + NO_SPREAD_ELEMENT = 'Spread element is not supported in CSS Map.', + NO_OBJECT_METHOD = 'Object method is not supported in CSS Map.', + STATIC_VARIANT_OBJECT = 'The variant object must be statically defined.', +} + +const createErrorMessage = (message: string): string => { + return ` +${message} +To correctly implement a CSS Map, follow the syntax below: + +\`\`\` +import { css, cssMap } from '@compiled/react'; +const borderStyleMap = cssMap({ + none: { borderStyle: 'none' }, + solid: { borderStyle: 'solid' }, +}); +const Component = ({ borderStyle }) =>
+\`\`\` + `; +}; + +/** + * Takes `cssMap` function expression and then transforms it to a record of class names and sheets. + * + * For example: + * ``` + * const styles = cssMap({ + * none: { color: 'red' }, + * solid: { color: 'green' }, + * }); + * ``` + * gets transformed to + * ``` + * const styles = { + * danger: "_syaz5scu", + * success: "_syazbf54", + * }; + * ``` + * + * @param path {NodePath} The path to be evaluated. + * @param meta {Metadata} Useful metadata that can be used during the transformation + */ +export const visitCssMapPath = ( + path: NodePath | NodePath, + meta: Metadata +): void => { + // We don't support tagged template expressions. + if (t.isTaggedTemplateExpression(path.node)) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.DEFINE_MAP), + path.node, + meta.parentPath + ); + } + + // We need to ensure CSS Map is declared at the top-most scope of the module. + if (!t.isVariableDeclarator(path.parent) || !t.isIdentifier(path.parent.id)) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.DEFINE_MAP), + path.node, + meta.parentPath + ); + } + + // We need to ensure cssMap receives only one argument. + if (path.node.arguments.length !== 1) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.NUMBER_OF_ARGUMENT), + path.node, + meta.parentPath + ); + } + + // We need to ensure the argument is an objectExpression. + if (!t.isObjectExpression(path.node.arguments[0])) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.ARGUMENT_TYPE), + path.node, + meta.parentPath + ); + } + + const totalSheets: string[] = []; + path.replaceWith( + t.objectExpression( + path.node.arguments[0].properties.map((property) => { + if (t.isSpreadElement(property)) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.NO_SPREAD_ELEMENT), + property.argument, + meta.parentPath + ); + } + + if (t.isObjectMethod(property)) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.NO_OBJECT_METHOD), + property.key, + meta.parentPath + ); + } + + if (!t.isObjectExpression(property.value)) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.STATIC_VARIANT_OBJECT), + property.value, + meta.parentPath + ); + } + + const { css, variables } = buildCss(property.value, meta); + + if (variables.length) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.STATIC_VARIANT_OBJECT), + property.value, + meta.parentPath + ); + } + + const { sheets, classNames } = transformCssItems(css, meta); + totalSheets.push(...sheets); + + if (classNames.length !== 1) { + throw buildCodeFrameError( + createErrorMessage(ErrorMessages.STATIC_VARIANT_OBJECT), + property, + meta.parentPath + ); + } + + return t.objectProperty(property.key, classNames[0]); + }) + ) + ); + + // We store sheets in the meta state so that we can use it later to generate Compiled component. + meta.state.cssMap[path.parent.id.name] = totalSheets; +}; diff --git a/packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts b/packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts new file mode 100644 index 000000000..638a62d12 --- /dev/null +++ b/packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts @@ -0,0 +1,85 @@ +import type { TransformOptions } from '../../test-utils'; +import { transform as transformCode } from '../../test-utils'; + +describe('css map behaviour', () => { + beforeAll(() => { + process.env.AUTOPREFIXER = 'off'; + }); + + afterAll(() => { + delete process.env.AUTOPREFIXER; + }); + + const transform = (code: string, opts: TransformOptions = {}) => + transformCode(code, { pretty: false, ...opts }); + + const styles = ` + import { css, cssMap } from '@compiled/react'; + + const styles = cssMap({ + danger: { + color: 'red', + backgroundColor: 'red' + }, + success: { + color: 'green', + backgroundColor: 'green' + } + }); + `; + + it('should evaluate css map with various syntactic patterns', () => { + const actual = transform( + ` + ${styles} +
; + `, + { pretty: true } + ); + + expect(actual).toMatchInlineSnapshot(` + "import * as React from "react"; + import { ax, ix, CC, CS } from "@compiled/react/runtime"; + const _5 = "._syaz13q2{color:blue}"; + const _4 = "._bfhkbf54{background-color:green}"; + const _3 = "._syazbf54{color:green}"; + const _2 = "._bfhk5scu{background-color:red}"; + const _ = "._syaz5scu{color:red}"; + const styles = { + danger: "_syaz5scu _bfhk5scu", + success: "_syazbf54 _bfhkbf54", + }; + + {[_, _2, _3, _4, _5]} + { +
+ } + ; + " + `); + }); +}); diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts index 0a6b2a95a..a796da317 100644 --- a/packages/babel-plugin/src/types.ts +++ b/packages/babel-plugin/src/types.ts @@ -98,6 +98,7 @@ export interface State extends PluginPass { css?: string; keyframes?: string; styled?: string; + cssMap?: string; }; importedCompiledImports?: { @@ -141,6 +142,11 @@ export interface State extends PluginPass { * Files that have been included in this pass. */ includedFiles: string[]; + + /** + * Holds a record of currently evaluated CSS Map and its sheets in the module. + */ + cssMap: Record; } interface CommonMetadata { diff --git a/packages/babel-plugin/src/utils/css-builders.ts b/packages/babel-plugin/src/utils/css-builders.ts index 062f6d7e5..0691ffe79 100644 --- a/packages/babel-plugin/src/utils/css-builders.ts +++ b/packages/babel-plugin/src/utils/css-builders.ts @@ -28,9 +28,33 @@ import type { CssItem, LogicalCssItem, SheetCssItem, + CssMapItem, PartialBindingWithMeta, } from './types'; +/** + * Retrieves the leftmost identity from a given expression. + * + * For example: + * Given a member expression "colors.primary.500", the function will return "colors". + * + * @param expression The expression to be evaluated. + * @returns {string} The leftmost identity in the expression. + */ +const findBindingIdentifier = ( + expression: t.Expression | t.V8IntrinsicIdentifier +): t.Identifier | undefined => { + if (t.isIdentifier(expression)) { + return expression; + } else if (t.isCallExpression(expression)) { + return findBindingIdentifier(expression.callee); + } else if (t.isMemberExpression(expression)) { + return findBindingIdentifier(expression.object); + } + + return undefined; +}; + /** * Will normalize the value of a `content` CSS property to ensure it has quotations around it. * This is done to replicate both how Styled Components behaves, @@ -804,6 +828,13 @@ export const buildCss = (node: t.Expression | t.Expression[], meta: Metadata): C } if (t.isMemberExpression(node)) { + const bindingIdentifier = findBindingIdentifier(node); + if (bindingIdentifier && meta.state.cssMap[bindingIdentifier.name]) { + return { + css: [{ type: 'map', expression: node, name: bindingIdentifier.name, css: '' }], + variables: [], + }; + } const { value, meta: updatedMeta } = evaluateExpression(node, meta); return buildCss(value, updatedMeta); } @@ -877,6 +908,13 @@ export const buildCss = (node: t.Expression | t.Expression[], meta: Metadata): C }; } + if (item.type === 'map') { + return { + ...item, + expression: t.logicalExpression(node.operator, expression, item.expression), + } as CssMapItem; + } + const logicalItem: LogicalCssItem = { type: 'logical', css: getItemCss(item), diff --git a/packages/babel-plugin/src/utils/is-compiled.ts b/packages/babel-plugin/src/utils/is-compiled.ts index 65a7ecf34..ddc022018 100644 --- a/packages/babel-plugin/src/utils/is-compiled.ts +++ b/packages/babel-plugin/src/utils/is-compiled.ts @@ -47,6 +47,21 @@ export const isCompiledKeyframesCallExpression = ( t.isIdentifier(node.callee) && node.callee.name === state.compiledImports?.keyframes; +/** + * Returns `true` if the node is using `cssMap` from `@compiled/react` as a call expression + * + * @param node {t.Node} The node that is being checked + * @param state {State} Plugin state + * @returns {boolean} Whether the node is a compiled cssMap + */ +export const isCompiledCSSMapCallExpression = ( + node: t.Node, + state: State +): node is t.CallExpression => + t.isCallExpression(node) && + t.isIdentifier(node.callee) && + node.callee.name === state.compiledImports?.cssMap; + /** * Returns `true` if the node is using `keyframes` from `@compiled/react` as a tagged template expression * diff --git a/packages/babel-plugin/src/utils/transform-css-items.ts b/packages/babel-plugin/src/utils/transform-css-items.ts index 02a1cc131..196a3e199 100644 --- a/packages/babel-plugin/src/utils/transform-css-items.ts +++ b/packages/babel-plugin/src/utils/transform-css-items.ts @@ -74,6 +74,12 @@ const transformCssItem = ( ), }; + case 'map': + return { + sheets: meta.state.cssMap[item.name], + classExpression: item.expression, + }; + default: const css = transformCss(getItemCss(item), meta.state.opts); const className = compressClassNamesForRuntime( diff --git a/packages/babel-plugin/src/utils/types.ts b/packages/babel-plugin/src/utils/types.ts index 0bbe4e1ef..f753012f3 100644 --- a/packages/babel-plugin/src/utils/types.ts +++ b/packages/babel-plugin/src/utils/types.ts @@ -27,7 +27,20 @@ export interface SheetCssItem { css: string; } -export type CssItem = UnconditionalCssItem | ConditionalCssItem | LogicalCssItem | SheetCssItem; +export interface CssMapItem { + name: string; + type: 'map'; + expression: t.Expression; + // We can remove this once we local transform other CssItem types + css: string; +} + +export type CssItem = + | UnconditionalCssItem + | ConditionalCssItem + | LogicalCssItem + | SheetCssItem + | CssMapItem; export type Variable = { name: string; diff --git a/packages/react/src/css-map/__tests__/index.test.tsx b/packages/react/src/css-map/__tests__/index.test.tsx new file mode 100644 index 000000000..34e4cd48c --- /dev/null +++ b/packages/react/src/css-map/__tests__/index.test.tsx @@ -0,0 +1,52 @@ +/** @jsxImportSource @compiled/react */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { css, cssMap } from '@compiled/react'; +import { render } from '@testing-library/react'; + +describe('css map', () => { + const styles = cssMap({ + danger: { + color: 'red', + }, + success: { + color: 'green', + }, + }); + + it('should generate css based on the selected variant', () => { + const Foo = ({ variant }: { variant: keyof typeof styles }) => ( +
hello world
+ ); + const { getByText, rerender } = render(); + + expect(getByText('hello world')).toHaveCompiledCss('color', 'red'); + + rerender(); + expect(getByText('hello world')).toHaveCompiledCss('color', 'green'); + }); + + it('should statically access a variant', () => { + const Foo = () =>
hello world
; + const { getByText } = render(); + + expect(getByText('hello world')).toHaveCompiledCss('color', 'red'); + }); + + it('should merge styles', () => { + const hover = css({ ':hover': { color: 'red' } }); + const Foo = () =>
hello world
; + const { getByText } = render(); + + expect(getByText('hello world')).toHaveCompiledCss('color', 'green'); + expect(getByText('hello world')).toHaveCompiledCss('color', 'red', { target: ':hover' }); + }); + + it('should conditionally apply variant', () => { + const Foo = ({ isDanger }: { isDanger: boolean }) => ( +
hello world
+ ); + const { getByText } = render(); + + expect(getByText('hello world')).toHaveCompiledCss('color', 'red'); + }); +}); diff --git a/packages/react/src/css-map/index.js.flow b/packages/react/src/css-map/index.js.flow new file mode 100644 index 000000000..3569d3776 --- /dev/null +++ b/packages/react/src/css-map/index.js.flow @@ -0,0 +1,25 @@ +/** + * Flowtype definitions for index + * Generated by Flowgen from a Typescript Definition + * Flowgen v1.20.1 + * @flow + */ +import type { CSSProps, CssObject } from '../types'; +/** + * ## cssMap + * + * Creates a collection of named CSS rules that are statically typed and useable with other Compiled APIs. + * For further details [read the documentation](https://compiledcssinjs.com/docs/api-cssmap). + * @example ``` + * const borderStyleMap = cssMap({ + * none: { borderStyle: 'none' }, + * solid: { borderStyle: 'solid' }, + * }); + * const Component = ({ borderStyle }) =>
+ * + * + * ``` + */ +declare export default function cssMap(_styles: { + [key: T]: CssObject | CssObject[], +}): { [key: T]: CSSProps }; diff --git a/packages/react/src/css-map/index.ts b/packages/react/src/css-map/index.ts new file mode 100644 index 000000000..357e41457 --- /dev/null +++ b/packages/react/src/css-map/index.ts @@ -0,0 +1,25 @@ +import type { CSSProps, CssObject } from '../types'; +import { createSetupError } from '../utils/error'; + +/** + * ## cssMap + * + * Creates a collection of named CSS rules that are statically typed and useable with other Compiled APIs. + * For further details [read the documentation](https://compiledcssinjs.com/docs/api-cssmap). + * + * @example + * ``` + * const borderStyleMap = cssMap({ + * none: { borderStyle: 'none' }, + * solid: { borderStyle: 'solid' }, + * }); + * const Component = ({ borderStyle }) =>
+ * + * + * ``` + */ +export default function cssMap( + _styles: Record | CssObject[]> +): Record> { + throw createSetupError(); +} diff --git a/packages/react/src/index.js.flow b/packages/react/src/index.js.flow index d6183f0b2..1937810a7 100644 --- a/packages/react/src/index.js.flow +++ b/packages/react/src/index.js.flow @@ -10,3 +10,4 @@ declare export { keyframes } from './keyframes'; declare export { styled } from './styled'; declare export { ClassNames } from './class-names'; declare export { default as css } from './css'; +declare export { default as cssMap } from './css-map'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7c038675f..8b114db02 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -9,6 +9,7 @@ export { keyframes } from './keyframes'; export { styled } from './styled'; export { ClassNames } from './class-names'; export { default as css } from './css'; +export { default as cssMap } from './css-map'; // Pass through the (classic) jsx runtime. // Compiled currently doesn't define its own and uses this purely to enable a local jsx namespace. diff --git a/stories/css-map.tsx b/stories/css-map.tsx new file mode 100644 index 000000000..8559d1f11 --- /dev/null +++ b/stories/css-map.tsx @@ -0,0 +1,62 @@ +import { cssMap } from '@compiled/react'; +import { useState } from 'react'; + +export default { + title: 'css map', +}; + +const styles = cssMap({ + success: { + color: 'green', + ':hover': { + color: 'DarkGreen', + }, + '@media (max-width: 800px)': { + color: 'SpringGreen', + }, + }, + danger: { + color: 'red', + ':hover': { + color: 'DarkRed', + }, + '@media (max-width: 800px)': { + color: 'Crimson', + }, + }, +}); + +export const DynamicVariant = (): JSX.Element => { + const [variant, setVariant] = useState('success'); + + return ( + <> +
*': { + margin: '5px', + }, + }}> + + +
hello world
+
+ + ); +}; + +export const VariantAsProp = (): JSX.Element => { + const Component = ({ variant }: { variant: keyof typeof styles }) => ( +
hello world
+ ); + return ; +}; + +export const MergeStyles = (): JSX.Element => { + return
hello world
; +}; + +export const ConditionalStyles = (): JSX.Element => { + const isDanger = true; + return
hello world
; +};