diff --git a/.bin/build-dirs.mjs b/.bin/build-dirs.mjs deleted file mode 100644 index 4557b016..00000000 --- a/.bin/build-dirs.mjs +++ /dev/null @@ -1,11 +0,0 @@ -/** Root directory. */ -export const rootUrl = new URL('../', import.meta.url) - -/** Packages directory. */ -export const packagesUrl = new URL('packages/', rootUrl) - -/** Core package directory. */ -export const corePackageUrl = new URL('core/', packagesUrl) - -/** React package directory. */ -export const reactPackageUrl = new URL('react/', packagesUrl) diff --git a/.bin/build-package.mjs b/.bin/build-package.js similarity index 97% rename from .bin/build-package.mjs rename to .bin/build-package.js index b2057685..da77609c 100644 --- a/.bin/build-package.mjs +++ b/.bin/build-package.js @@ -1,8 +1,8 @@ import esbuild from 'esbuild' -import fs from './fs.mjs' +import fs from './internal/fs.js' import zlib from 'zlib' import { minify } from 'terser' -import { bold, underline } from './color.mjs' +import { bold, underline } from './color.js' const variants = { esm: { diff --git a/.bin/build-shared.mjs b/.bin/build-shared.js similarity index 85% rename from .bin/build-shared.mjs rename to .bin/build-shared.js index 1a0c73c2..4f2ed106 100644 --- a/.bin/build-shared.mjs +++ b/.bin/build-shared.js @@ -1,6 +1,6 @@ -import fs from './fs.mjs' -import { corePackageUrl, reactPackageUrl, rootUrl } from './build-dirs.mjs' -import { bold, dim, underline } from './color.mjs' +import fs from './internal/fs.js' +import { corePackageUrl, reactPackageUrl, rootUrl } from './internal/dirs.js' +import { bold, dim, underline } from './color.js' async function buildShared(packageUrl) { const sharedUrl = new URL('shared/', rootUrl) diff --git a/.bin/build-watch.mjs b/.bin/build-watch.js similarity index 100% rename from .bin/build-watch.mjs rename to .bin/build-watch.js diff --git a/.bin/build.mjs b/.bin/build.js similarity index 72% rename from .bin/build.mjs rename to .bin/build.js index 7bd3cb90..7b5591b0 100644 --- a/.bin/build.mjs +++ b/.bin/build.js @@ -1,7 +1,7 @@ -import buildPackage from './build-package.mjs' -import buildShared from './build-shared.mjs' -import fs from './fs.mjs' -import { corePackageUrl, reactPackageUrl } from './build-dirs.mjs' +import buildPackage from './build-package.js' +import buildShared from './build-shared.js' +import fs from './internal/fs.js' +import { corePackageUrl, reactPackageUrl } from './internal/dirs.js' async function build() { console.log() diff --git a/.bin/color.mjs b/.bin/color.js similarity index 87% rename from .bin/color.mjs rename to .bin/color.js index 25497bb5..d8115666 100644 --- a/.bin/color.mjs +++ b/.bin/color.js @@ -1,5 +1,5 @@ export const set = (id) => `\x1b[${id}m` -export const color = (string, id) => set(id) + string.replaceAll(set(0), set(0) + set(id)) + set(0) +export const color = (string, id) => set(id) + string.replace(RegExp(set(0).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'), 'g'), set(0) + set(id)) + set(0) export const bold = (string) => color(string, 1) export const dim = (string) => color(string, 2) diff --git a/.bin/internal/dirs.js b/.bin/internal/dirs.js new file mode 100644 index 00000000..7a77fa34 --- /dev/null +++ b/.bin/internal/dirs.js @@ -0,0 +1,22 @@ +import process from 'node:process' + +/** Root directory. */ +export const rootUrl = new URL('../../', import.meta.url) + +/** Packages directory. */ +export const packagesUrl = new URL('packages/', rootUrl) + +/** Core package directory. */ +export const corePackageUrl = new URL('core/', packagesUrl) + +/** Core tests directory. */ +export const coreTestsUrl = new URL('tests/', corePackageUrl) + +/** React package directory. */ +export const reactPackageUrl = new URL('react/', packagesUrl) + +/** React tests directory. */ +export const reactTestsUrl = new URL('tests/', reactPackageUrl) + +/** Current file href. */ +export const argv1Url = new URL(process.argv[1], 'file:').href diff --git a/.bin/internal/expect.js b/.bin/internal/expect.js new file mode 100644 index 00000000..3af8e71b --- /dev/null +++ b/.bin/internal/expect.js @@ -0,0 +1,89 @@ +import { deepEqual as toEqual, equal as toBe, notDeepEqual as toNotEqual, notEqual as toNotBe } from 'node:assert/strict' + +export default function expect(actual) { + return { + /** Tests for strict equality between the actual and expected parameters. */ + toBe: toBe.bind(this, actual), + /** Tests that the actual object is an instance of the expected class. */ + toBeInstanceOf: toBeInstanceOf.bind(this, actual), + /** Tests for deep equality between the actual and expected parameters. */ + toEqual: toEqual.bind(this, actual), + /** Tests that the actual function does throw when it is called. */ + toThrow: toThrow.bind(this, actual), + + /** Tests for strict inequality between the actual and expected parameters. */ + toNotBe: toNotBe.bind(this, actual), + /** Tests that the actual object is not an instance of the expected class. */ + toNotBeInstanceOf: toNotBeInstanceOf.bind(this, actual), + /** Tests for deep inequality between the actual and expected parameters. */ + toNotEqual: toNotEqual.bind(this, actual), + /** Tests that the actual function does not throw when it is called. */ + toNotThrow: toNotThrow.bind(this, actual), + } +} + +/** Tests that the actual object is an instance of the expected class. */ +function toBeInstanceOf(actual, expected) { + if (!(actual instanceof expected)) { + throw new AssertionError({ + message: 'Expected value to be instance:', + operator: 'instanceOf', + actual, + expected, + stackStartFn: toBeInstanceOf, + }) + } +} + +/** Tests that the actual object is not an instance of the expected class. */ +function toNotBeInstanceOf(actual, expected) { + if (actual instanceof expected) { + throw new AssertionError({ + message: 'Expected value to be instance:', + operator: 'instanceOf', + actual, + expected, + stackStartFn: toNotBeInstanceOf, + }) + } +} + +/** Tests that the actual function does throw when it is called. */ +async function toThrow(actualFunction, expected) { + let actual = undefined + + try { + actual = await actualFunction() + } catch (error) { + // do nothing and continue + return + } + + throw new AssertionError({ + message: 'Expected exception:', + operator: 'throws', + stackStartFn: toThrow, + actual, + expected, + }) +} + +/** Tests that the actual function does not throw when it is called. */ +async function toNotThrow(actualFunction, expected) { + let actual = undefined + + try { + actual = await actualFunction() + + // do nothing and continue + return + } catch (error) { + throw new AssertionError({ + message: 'Unexpected exception:', + operator: 'doesNotThrow', + stackStartFn: toThrow, + actual, + expected, + }) + } +} diff --git a/.bin/fs.mjs b/.bin/internal/fs.js similarity index 100% rename from .bin/fs.mjs rename to .bin/internal/fs.js diff --git a/.bin/test-coverage.mjs b/.bin/test-coverage.js similarity index 98% rename from .bin/test-coverage.mjs rename to .bin/test-coverage.js index def3bc6f..7dd93ce1 100644 --- a/.bin/test-coverage.mjs +++ b/.bin/test-coverage.js @@ -1,8 +1,8 @@ import cp from 'child_process' import fp from 'path' -import fs from './fs.mjs' +import fs from './fs.js' import os from 'os' -import { bold, dim, green, red } from './color.mjs' +import { bold, dim, green, red } from './color.js' // Creates a unique temporary directory !(async () => { diff --git a/.bin/test-lint.mjs b/.bin/test-lint.js similarity index 100% rename from .bin/test-lint.mjs rename to .bin/test-lint.js diff --git a/.bin/test-watch.mjs b/.bin/test-watch.js similarity index 79% rename from .bin/test-watch.mjs rename to .bin/test-watch.js index d92bce2e..3fd6c8bb 100644 --- a/.bin/test-watch.mjs +++ b/.bin/test-watch.js @@ -7,7 +7,7 @@ nodemon( `--watch packages/core/tests`, `--watch packages/react/src`, `--watch packages/react/tests`, - `--exec "clear; ${['node', '.bin/test.mjs'].concat(process.argv.slice(2)).join(' ')}"`, + `--exec "clear; ${['node', '.bin/test.js'].concat(process.argv.slice(2)).join(' ')}"`, ].join(' '), ) .on('start', () => { diff --git a/.bin/test.mjs b/.bin/test.js similarity index 80% rename from .bin/test.mjs rename to .bin/test.js index 2a2aba99..9bfd2f77 100644 --- a/.bin/test.mjs +++ b/.bin/test.js @@ -1,5 +1,5 @@ -import fs from './fs.mjs' -import { deepEqual, equal, notDeepEqual, notEqual, AssertionError } from 'assert/strict' +import fs from './internal/fs.js' +import expect from './internal/expect.js' const test = (root) => Promise.all( @@ -9,32 +9,7 @@ const test = (root) => new URL('./packages/react/tests/', root), ].map(async (dir) => { // bootstrap the expect api - globalThis.expect = (foo) => ({ - toEqual: (bar) => deepEqual(foo, bar), - toBe: (bar) => equal(foo, bar), - toBeInstanceOf(bar) { - if (!(foo instanceof bar)) - throw new AssertionError({ - message: `Expected value to be instance:`, - operator: 'instanceOf', - actual: foo, - expected: bar, - }) - }, - not: { - toEqual: (bar) => notDeepEqual(foo, bar), - toBe: (bar) => notEqual(foo, bar), - toBeInstanceOf(bar) { - if (!(foo instanceof bar)) - throw new AssertionError({ - message: `Expected value to not be instance:`, - operator: 'notInstanceOf', - actual: foo, - expected: bar, - }) - }, - }, - }) + globalThis.expect = expect // internal strings and symbols used for reporting const passIcon = '\x1b[32m✔\x1b[0m' diff --git a/package.json b/package.json index 6be82fb3..1b56cfd4 100644 --- a/package.json +++ b/package.json @@ -6,42 +6,41 @@ "license": "MIT", "scripts": { "bootstrap": "lerna bootstrap --use-workspaces", - "build": "node .bin/build.mjs", - "build:watch": "node .bin/build-watch.mjs", + "build": "node .bin/build.js", + "build:watch": "node .bin/build-watch.js", "prerelease": "npm run build && npm run test", "release": "lerna publish", "release:canary": "npm run prerelease && lerna publish --dist-tag canary", "release:pack": "npm run prerelease && lerna exec -- npm pack", "postinstall": "run-s bootstrap", - "test": "node .bin/test.mjs", - "test:coverage": "node .bin/test-coverage.mjs .bin/test.mjs", - "test:lint": "node .bin/test-lint.mjs", - "test:watch": "node .bin/test-watch.mjs" + "test": "node .bin/test.js", + "test:coverage": "node .bin/test-coverage.js .bin/test.js", + "test:lint": "node .bin/test-lint.js", + "test:watch": "node .bin/test-watch.js" }, "workspaces": [ "packages/*", "play" ], "dependencies": { - "@types/react": "17.0.2", + "@types/react": "17.0.3", "@types/react-dom": "17.0.1", "@types/react-test-renderer": "17.0.1", - "@typescript-eslint/eslint-plugin": "^4.15.1", - "@typescript-eslint/parser": "^4.15.1", - "baretest": "2.0.0", - "esbuild": "0.8.46", - "eslint": "^7.20.0", - "lerna": "3.22.1", + "@typescript-eslint/eslint-plugin": "^4.16.1", + "@typescript-eslint/parser": "^4.16.1", + "esbuild": "0.8.57", + "eslint": "^7.21.0", + "lerna": "4.0.0", + "linkedom": "^0.5.5", "magic-string": "0.25.7", "merge-source-map": "1.1.0", - "microbundle": "0.13.0", "nodemon": "2.0.7", "npm-run-all": "4.1.5", "prettier": "^2.2.1", "react": "^17.0.1", "react-test-renderer": "17.0.1", "terser": "5.6.0", - "typescript": "4.1.5" + "typescript": "4.2.3" }, "browserslist": [ "last 1 chrome versions", @@ -50,6 +49,14 @@ "maintained node versions" ], "eslintConfig": { + "env": { + "browser": true, + "es6": true, + "node": true + }, + "extends": [ + "plugin:@typescript-eslint/recommended" + ], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2020, @@ -63,9 +70,6 @@ "version": "detect" } }, - "extends": [ - "plugin:@typescript-eslint/recommended" - ], "rules": { "@typescript-eslint/ban-types": [ "error", diff --git a/packages/core/src/CssSet.js b/packages/core/src/CssSet.js deleted file mode 100644 index 7cec6f78..00000000 --- a/packages/core/src/CssSet.js +++ /dev/null @@ -1,30 +0,0 @@ -import { from } from './Array.js' - -/** Collection of unique CSS values and other sets of CSS. */ -class CssSet extends Set { - constructor(onChange) { - super().onChange = onChange - } - - /** Returns a combined string of all the elements of the set, conditionally running an onChange event. */ - addCss(item) { - if ( - // if the item has yet to be added; and, - !this.has(item) && - // if an onchange event exists on the set; and, - this.add(item).onChange && - // if the item contributed a meaningful string - String(item) - ) - this.onChange(this) - - return this - } - - /** Returns a combined string of all the elements of the set. */ - toString() { - return from(this).join('') - } -} - -export default CssSet diff --git a/packages/core/src/Object.js b/packages/core/src/Object.js index 7f0d8618..71664c81 100644 --- a/packages/core/src/Object.js +++ b/packages/core/src/Object.js @@ -1,5 +1,13 @@ -export default Object +import { toPrimitive } from './Symbol.js' export const { assign, create, defineProperties, getOwnPropertyDescriptors } = Object -export const define = (target, append) => defineProperties(target, getOwnPropertyDescriptors(append)) +export const createComponent = (base, prop, props) => + assign(defineProperties(base, getOwnPropertyDescriptors(props)), { + [toPrimitive]() { + return base[prop] + }, + toString() { + return base[prop] + }, + }) diff --git a/packages/core/src/Reflect.js b/packages/core/src/Reflect.js new file mode 100644 index 00000000..fe083e2a --- /dev/null +++ b/packages/core/src/Reflect.js @@ -0,0 +1 @@ +export const { ownKeys } = Reflect diff --git a/packages/core/src/StringArray.js b/packages/core/src/StringArray.js new file mode 100644 index 00000000..05a8cb3e --- /dev/null +++ b/packages/core/src/StringArray.js @@ -0,0 +1,15 @@ +import { toPrimitive } from './Symbol.js' + +export default class StringArray extends Array { + toString() { + return this.join('') + } + + get hasChanged() { + const cssText = String(this) + + return () => cssText !== String(this) + } +} + +StringArray.prototype[toPrimitive] = StringArray.prototype.toString diff --git a/packages/core/src/StringSet.js b/packages/core/src/StringSet.js new file mode 100644 index 00000000..0d539b2e --- /dev/null +++ b/packages/core/src/StringSet.js @@ -0,0 +1,16 @@ +import { toPrimitive } from './Symbol.js' +import { from } from './Array.js' + +export default class StringSet extends Set { + toString() { + return from(this).join('') + } + + get hasChanged() { + const { size } = this + + return () => size < this.size + } +} + +StringSet.prototype[toPrimitive] = StringSet.prototype.toString diff --git a/packages/core/src/Symbol.js b/packages/core/src/Symbol.js new file mode 100644 index 00000000..e6fb66a3 --- /dev/null +++ b/packages/core/src/Symbol.js @@ -0,0 +1,3 @@ +export const { toPrimitive } = Symbol + +export const $$composers = Symbol.for('sxs.composers') diff --git a/packages/core/src/index.js b/packages/core/src/index.js index a212547d..23c4781c 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1,49 +1,82 @@ +import { assign, create, createComponent, defineProperties, getOwnPropertyDescriptors } from './Object.js' import { from } from './Array.js' -import Object, { assign, create, define } from './Object.js' -import CssSet from './CssSet.js' -import ThemeToken from './ThemeToken.js' +import { ownKeys } from './Reflect.js' +import StringSet from './StringSet.js' import createGetComputedCss from './createGetComputedCss.js' import defaultThemeMap from './defaultThemeMap.js' import getCustomProperties from './getCustomProperties.js' import getHashString from './getHashString.js' +import ThemeToken from './ThemeToken.js' +import { $$composers } from './Symbol.js' +import StringArray from './StringArray.js' /** Returns a new styled sheet and accompanying API. */ const createCss = (init) => { init = Object(init) - const config = { - /** Named conditions (media and support queries). */ - conditions: assign({ initial: '@media all' }, init.conditions), + /** Named conditions (media and support queries). */ + const conditions = assign({ initial: '@media all' }, init.conditions) - /** Theme tokens enabled by default on the styled sheet. */ - theme: Object(init.theme), + /** Theme tokens enabled by default on the styled sheet. */ + const themeInit = Object(init.theme) - themeMap: Object(init.themeMap || defaultThemeMap), + const themeMap = Object(init.themeMap || defaultThemeMap) - /** Properties corresponding to functions that take in CSS values and return aliased CSS declarations. */ - utils: assign(create(null), init.utils), - } + /** Properties corresponding to functions that take in CSS values and return aliased CSS declarations. */ + const utils = Object(init.utils) + + /** Names of variants passed through to props. */ + const passThru = new Set([].concat(init.passthru || ['as', 'className'])) /** Prefix added before all generated class names. */ const prefix = init.prefix || 'sx' - /** Attribute class names are set to on props. */ - const classProp = init.classProp || 'className' + const emptyClassName = '03kze' + + const config = { + theme: themeInit, + conditions, + prefix, + themeMap, + utils, + } /** Returns a string of unnested CSS from an object of nestable CSS. */ const getComputedCss = createGetComputedCss(config) /** Collection of `@import` CSS rules. */ - const importRules = new CssSet(init.onImport) - - /** Collection of global CSS rules. */ - const globalRules = new CssSet(init.onGlobal) + const importCss = new StringSet() /** Collection of theming CSS rules. */ - const themedRules = new CssSet(init.onThemed) + const themedCss = new StringSet() + + /** Collection of global CSS rules. */ + const globalCss = new StringSet() /** Collection of component CSS rules. */ - const styledRules = new CssSet(init.onStyled) + const styledCss = new StringSet() + + const unitedCss = new StringSet([importCss, themedCss, globalCss, styledCss]) + + let currentCssText = '' + let currentCssHead = null + let currentCssNode = null + + const update = () => { + const nextUpdate = from(unitedCss).join('') + + if (currentCssText !== nextUpdate) { + currentCssText = nextUpdate + + if (typeof document === 'object') { + if (!currentCssHead) currentCssHead = document.head || document.documentElement + if (!currentCssNode) currentCssNode = document.getElementById('stitches') || assign(document.createElement('style'), { id: 'stitches' }) + if (!currentCssNode.parentNode) currentCssHead.prepend(currentCssNode) + + currentCssNode.textContent = nextUpdate + } + } + } /** Prepares global CSS and returns a function that enables the styles on the styled sheet. */ const theme = ( @@ -68,306 +101,352 @@ const createCss = (init) => { const selector = className.replace(/^\w/, '.$&') /** Computed CSS */ - const cssText = getComputedCss({ [selector]: customPropertyStyles }) - - /** Themed Rule that activates styles on the styled sheet. */ - const expressThemedRule = define(() => { - themedRules.addCss(cssText) + const cssText = className === prefix + emptyClassName ? '' : getComputedCss({ [selector]: customPropertyStyles }) - return expressThemedRule - }, { - toString() { - expressThemedRule() - return className - }, - get className() { - expressThemedRule() - return className - }, - get selector() { - expressThemedRule() - return selector - }, + const expression = createComponent(create(null), 'className', { + className, + selector, }) for (const scale in theme) { - expressThemedRule[scale] = create(null) + expression[scale] = create(null) for (const token in theme[scale]) { - expressThemedRule[scale][token] = new ThemeToken(theme[scale][token], token, scale) + expression[scale][token] = new ThemeToken(theme[scale][token], token, scale) } } - return expressThemedRule + return createComponent(expression, 'className', { + get className() { + const { hasChanged } = themedCss + + themedCss.add(cssText) + + if (hasChanged()) { + update() + } + + return className + }, + selector, + }) } /** Returns a function that enables the styles on the styled sheet. */ const global = ( /** Styles representing global CSS. */ - initStyles, - /** Value returned by toString */ - displayName = '', + style, ) => { /** List of global import styles. */ - const localImportRules = [] + const localImportCss = new StringSet() /** List of global styles. */ - const localGlobalRules = [] + const localGlobalCss = new StringSet() - for (const name in initStyles) { - const cssText = getComputedCss({ [name]: initStyles[name] }) + for (const name in style) { + if (style[name] !== Object(style[name]) || ownKeys(style[name]).length) { + const cssText = getComputedCss({ [name]: style[name] }) - ;(name === '@import' ? localImportRules : localGlobalRules).push(cssText) + ;(name === '@import' ? localImportCss : localGlobalCss).add(cssText) + } } - const express = () => { - localImportRules.forEach(importRules.addCss, importRules) - localGlobalRules.forEach(globalRules.addCss, globalRules) + const expression = createComponent(create(null), 'displayName', { + displayName: '', + }) + + return createComponent( + () => { + let hasImportChanged = importCss.hasChanged + let hasGlobalChanged = globalCss.hasChanged - return displayName - } + localImportCss.forEach((localImportCss) => { + importCss.add(localImportCss) + }) + + localGlobalCss.forEach((localGlobalCss) => { + globalCss.add(localGlobalCss) + }) + + if (hasImportChanged() || hasGlobalChanged()) { + update() + } - return assign(express, { - displayName, - toString() { - return String(express()) + return expression }, - }) + 'displayName', + expression, + ) } /** Returns a function that enables the keyframe styles on the styled sheet. */ const keyframes = ( /** Styles representing global CSS. */ - initStyles, + style, ) => { /** Unique name representing the current keyframes rule. */ - const keyframeRuleName = getHashString(prefix, initStyles) + const displayName = getHashString(prefix, style) - return global({ ['@keyframes ' + keyframeRuleName]: initStyles }, keyframeRuleName) + return assign(global({ ['@keyframes ' + displayName]: style }), { displayName }) } - /** Prepares a component of css and returns a function that activates the css on the current styled sheet. */ - const css = ( - /** Styles for the current component, or the component to be extended. */ - initStyle, - /** Styles for the current component, when extending another component. */ - extendedStyle, - ) => { - const { variants: variantsStyle, compoundVariants, defaultVariants, ...style } = Object(extendedStyle || initStyle) + const createComposer = (initStyle) => { + const primalCss = new StringSet() + const variedCss = new StringArray() + const inlineCss = new StringSet() - /** Composing rule, if present, otherwise an empty object. */ - const composer = Object(extendedStyle && initStyle) + const unitedCss = new StringSet([primalCss, variedCss, inlineCss]) - /** Unique class name for the current component. */ - const className = getHashString(prefix, style) + let { variants: singularVariants, compoundVariants, defaultVariants, ...style } = initStyle - /** Unique css selector for the current component. */ + defaultVariants = Object(defaultVariants) + + const className = getHashString(prefix, initStyle) const selector = '.' + className + const cssText = className === prefix + emptyClassName ? '' : getComputedCss({ [selector]: style }) + + styledCss.add(unitedCss) - /** CSS styles representing the current component. */ - const cssText = getComputedCss({ [selector]: style }) + const variantProps = create(null) + const variants = [] + const compounds = [] - /** Change event registered with updates to the primary, variant, or inlined rules of the component. */ - const onChange = styledRules.onChange && (() => styledRules.onChange(styledRules)) + for (const key in singularVariants) { + for (const value in singularVariants[key]) { + const css = singularVariants[key][value] + + compounds.push({ + [key]: value, + css, + }) + } + } - const primaryRules = new CssSet(onChange) - const variantRules = new CssSet(onChange) - const combineRules = new CssSet(onChange) - const inlinedRules = new CssSet(onChange) + compounds.push(...(compoundVariants || [])) - /** Map of variant groups containing variant class names and styled rules. */ - const variants = assign(create(null), composer.variants) + for (const index in compounds) { + const { css, ...variantConfig } = compounds[index] - for (const name in variantsStyle) { - variants[name] = assign(create(null), variants[name]) + const variantConfigKeys = ownKeys(variantConfig) + const variantConfigIndex = variantConfigKeys.length - for (const value in variantsStyle[name]) { - const variantStyle = variantsStyle[name][value] - const variantClassName = className + getHashString('', variantStyle) + '--' + name + '-' + value - const variantSelector = '.' + variantClassName - const variantCssText = getComputedCss({ [variantSelector]: variantStyle }) + for (const variantKey of variantConfigKeys) { + variantProps[variantKey] = variantProps[variantKey] || create(null) - const conditionVariants = create(null) + variantProps[variantKey][variantConfig[variantKey]] = true + } - const compose = variants[name][value] + const applyVariant = (variantInput, defaultVariants) => { + variantInput = { ...variantInput } - variants[name][value] = (condition) => { - const classNames = (compose ? compose(condition) : []).concat(condition ? [] : variantClassName) + for (const defaultVariantName in defaultVariants) { + if (variantInput[defaultVariantName] === undefined && !variantProps[defaultVariantName][variantInput[defaultVariantName]]) { + variantInput[defaultVariantName] = defaultVariants[defaultVariantName] + } + } - if (condition != null) { - if (!conditionVariants[condition]) { - const conditionalVariantClassName = variantClassName + '--' + getHashString('', condition) - const conditionalVariantCssText = getComputedCss({ [condition]: { ['.' + conditionalVariantClassName]: variantStyle } }) + const variantConditions = new Set() - conditionVariants[condition] = [conditionalVariantCssText, conditionalVariantClassName] + if ( + variantConfigKeys.length && + variantConfigKeys.every((key) => { + const value = variantInput[key] + const compareValue = String(variantConfig[key]) + if (compareValue === String(value)) return true + if (value === Object(value)) { + for (const condition in value) { + if (compareValue == String(value[condition])) { + variantConditions.add(condition) + return true + } + } } + }) + ) { + let conditionedCss = Object(css) - variantRules.addCss(conditionVariants[condition][0]) - classNames.push(conditionVariants[condition][1]) - } else { - variantRules.addCss(variantCssText) + for (const variantCondition of variantConditions) { + conditionedCss = { [variantCondition in conditions ? conditions[variantCondition] : variantCondition]: conditionedCss } } - return classNames + const variantClassName = className + getHashString('', conditionedCss) + '--' + (variantConfigIndex === 1 ? variantConfigKeys[0] + '-' + variantConfig[variantConfigKeys[0]] : 'c' + variantConfigIndex) + const variantSelector = '.' + variantClassName + const variantCssText = getComputedCss({ [variantSelector]: conditionedCss }) + const variantCssByIndex = variedCss[variantConfigIndex - 1] || (variedCss[variantConfigIndex - 1] = new StringSet()) + + variantCssByIndex.add(variantCssText) + + return variantClassName } } + + variants.push(applyVariant) } - styledRules.addCss(primaryRules).addCss(variantRules).addCss(combineRules).addCss(inlinedRules) + return { + apply(props, classNames, defaultVariants) { + const hasPrimalChanged = primalCss.hasChanged + const hasVariedChanged = variedCss.hasChanged - function classNames() { - const classNames = (composer.classNames ? composer.classNames() : []).concat(className) + primalCss.add(cssText) - primaryRules.addCss(cssText) + if (props) { + classNames.add(className) - return classNames - } + for (const variant of variants) { + const variantClassName = variant(props, defaultVariants) + + if (variantClassName) { + classNames.add(variantClassName) + } + } + } - /** Returns an expression of the current styled rule. */ - const express = function ( - /** Props used to determine the expression of the current styled rule. */ - initProps, - ) { - const { css: inlineStyle, ...props } = Object(initProps) + if (hasPrimalChanged() || hasVariedChanged()) { + styledCss.add(unitedCss) - let expressClassNames = new Set(classNames()) + return true + } + }, + inline(css, classNames) { + const inlineSuffix = getHashString('-', css) + const inlineSelector = selector + inlineSuffix + const inlineCssText = className === '-' + inlineSuffix ? '' : getComputedCss({ [inlineSelector]: css }) - for (const propName in defaultVariants) { - if (!(propName in props) && propName in variants) { - props[propName] = defaultVariants[propName] + classNames.add(className + inlineSuffix) + + const { hasChanged } = inlineCss + + if (inlineCssText) { + inlineCss.add(inlineCssText) } - } - if (classProp in props) { - String(props[classProp]).split(/\s+/).forEach(expressClassNames.add, expressClassNames) + return hasChanged() + }, + className, + defaultVariants, + selector, + variantProps, + } + } - delete props[classProp] + const css = (...inits) => { + let type + let composers = [] + let composer + let defaultVariants = create(null) + + for (const init of inits) { + if ($$composers in Object(init)) { + type = init.type || type + for (const composer of init[$$composers]) { + composers.push(composer) + assign(defaultVariants, composer.defaultVariants) + } + } else if (init && typeof init === 'object' && !('type' in init)) { + composers.push((composer = createComposer(init))) + assign(defaultVariants, composer.defaultVariants) + } else { + type = ('type' in Object(init) ? init.type : init) || type } + } - for (const compound of [].concat(compoundVariants || [])) { - const { css: compoundStyle, ...compounders } = Object(compound) + composer = composer || createComposer({}) - let appliedCompoundStyle = compoundStyle + return createComponent( + (initProps) => { + const { css, ...props } = Object(initProps) - if ( - Object.keys(compounders).every((name) => { - if (name in props) { - const propValue = props[name] - const compounderValue = String(compounders[name]) - if (compounderValue == String(propValue)) return true - if (propValue === Object(propValue)) { - for (const innerName in propValue) { - const innerValue = String(propValue[innerName]) - const condition = config.conditions[innerName] || innerName - if (compounderValue == innerValue) { - appliedCompoundStyle = { [condition]: appliedCompoundStyle } - } - } - return true - } - } - }) - ) { - const compoundClassName = className + getHashString('', appliedCompoundStyle) + '--comp' - const compoundCssText = getComputedCss({ ['.' + compoundClassName]: appliedCompoundStyle }) + const classNames = new Set() - combineRules.addCss(compoundCssText) - expressClassNames.add(compoundClassName) + let hasComposerChanged = false + + for (const composer of composers) { + hasComposerChanged = composer.apply(props, classNames, defaultVariants) || hasComposerChanged } - } - for (const propName in props) { - if (propName in variants) { - const variant = variants[propName] - const propValue = props[propName] === undefined && !(undefined in variant) ? Object(defaultVariants)[propName] : props[propName] + let hasInlineChanged - if (propName !== 'as') delete props[propName] + if (css === Object(css)) { + hasInlineChanged = composer.inline(css, classNames) + } - // apply any matching variant - if (propValue in variant) { - variant[propValue]().forEach(expressClassNames.add, expressClassNames) - } else { - // conditionally apply any matched conditional variants - for (const innerName in propValue) { - const innerValue = propValue[innerName] - const condition = config.conditions[innerName] || innerName + if (hasComposerChanged || hasInlineChanged) { + update() + } - if (innerValue in variant) { - variant[innerValue](condition).forEach(expressClassNames.add, expressClassNames) - } - } + for (const variantName in composer.variantProps) { + if (!passThru.has(variantName)) { + delete props[variantName] } } - } - if (inlineStyle) { - const inlineRuleClassName = className + getHashString('', inlineStyle) + '--css' - const inlineRuleSelector = '.' + inlineRuleClassName - const inlineRuleCssText = getComputedCss({ [inlineRuleSelector]: inlineStyle }) - - inlinedRules.addCss(inlineRuleCssText) + if ('className' in props) { + String(props.className).split(/\s+/).forEach(classNames.add, classNames) + } - expressClassNames.add(inlineRuleClassName) - } + const classNameSetArray = from(classNames) - expressClassNames = from(expressClassNames) + props.className = classNameSetArray.join(' ') - const expressClassName = (props[classProp] = expressClassNames.join(' ')) + return createComponent(create(null), 'className', { + get [$$composers]() { + return composers + }, + className: props.className, + props, + selector: composer.selector, + }) + }, + 'className', + { + get [$$composers]() { + return composers + }, + /** Applies the primary composer and returns the class name. */ + get className() { + if (composer.apply()) { + update() + } - return { - toString() { - return expressClassName + return composer.className }, - className: expressClassName, - selector: '.' + expressClassNames.join('.'), - props, - } - } + selector: composer.selector, + type, + }, + ) + } - return define(express, { - toString() { - express() - return className + const defaultTheme = theme(':root', themeInit) + + const sheet = createComponent( + { + css, + config, + global, + keyframes, + prefix, + reset() { + importCss.clear() + themedCss.clear() + globalCss.clear() + styledCss.clear() + defaultTheme.className + return sheet }, - get className() { - express() - return className + theme: assign(theme, defaultTheme), + get cssText() { + return currentCssText }, - get selector() { - express() - return selector + getCssString() { + return currentCssText }, - classNames, - variants, - }) - } - - assign(theme, theme(':root', config.theme)).toString() - - const getCssString = () => importRules + themedRules + globalRules + styledRules - - return { - config: init, - getCssString, - global, - keyframes, - css, - theme, - /** Clears all rules, conditionally runs any `onResets` callbacks, and then restores the initial theme. */ - reset() { - importRules.clear() - themedRules.clear() - globalRules.clear() - styledRules.clear() - - init.onResets && init.onResets.call(this) - - theme.toString() - - return this }, - toString: getCssString, - } + 'cssText', + {}, + ) + + return sheet } const getReusableSheet = () => getReusableSheet.config || (getReusableSheet.config = createCss()) diff --git a/packages/core/src/isDeclaration.js b/packages/core/src/isDeclaration.js index 9524b622..93c5818f 100644 --- a/packages/core/src/isDeclaration.js +++ b/packages/core/src/isDeclaration.js @@ -1,5 +1,3 @@ -import Object from './Object.js' - /** Returns whether the current source contains a declaration. */ const isDeclaration = (value) => value !== Object(value) || !(value.constructor === Object || value.constructor == null) diff --git a/packages/core/tests/component-composition.js b/packages/core/tests/component-composition.js new file mode 100644 index 00000000..8291012f --- /dev/null +++ b/packages/core/tests/component-composition.js @@ -0,0 +1,25 @@ +import createCss from '../src/index.js' + +describe('Composition', () => { + test('Renders a component as the final composition by default', () => { + const { css, toString } = createCss() + const red = css({ color: 'red' }) + const size14 = css({ fontSize: '14px' }) + const bold = css({ fontWeight: 'bold' }) + const title = css(red, size14, bold, { fontFamily: 'monospace' }) + + expect(title.className).toBe('sxlktsn') + expect(toString()).toBe('.sxlktsn{font-family:monospace;}') + }) + + test('Renders a component with all compositions', () => { + const { css, toString } = createCss() + const red = css({ color: 'red' }) + const size14 = css({ fontSize: '14px' }) + const bold = css({ fontWeight: 'bold' }) + const title = css(red, size14, bold, { fontFamily: 'monospace' }) + + expect(title().className).toBe('sx3ye05 sxgsv3w sxsfl0j sxlktsn') + expect(toString()).toBe('.sx3ye05{color:red;}.sxgsv3w{font-size:14px;}.sxsfl0j{font-weight:bold;}.sxlktsn{font-family:monospace;}') + }) +}) diff --git a/packages/core/tests/component-support-as-property.js b/packages/core/tests/component-support-as-property.js index 8c5aed75..7f487258 100644 --- a/packages/core/tests/component-support-as-property.js +++ b/packages/core/tests/component-support-as-property.js @@ -16,12 +16,12 @@ describe('As prop', () => { }, }) - expect(component({ as: 'button' }).className).toBe(['sx03kze', 'sx03kzer9r9e--as-button'].join(' ')) + expect(component({ as: 'button' }).className).toBe(`sx1hhcn sx1hhcnr9r9e--as-button`) expect(component({ as: 'button' }).props.as).toBe('button') - expect(toString()).toBe(['.sx03kzer9r9e--as-button{color:dodgerblue;}'].join('')) + expect(toString()).toBe(['.sx1hhcnr9r9e--as-button{color:dodgerblue;}'].join('')) - expect(component({ as: 'a' }).className).toBe(['sx03kze', 'sx03kzea4ldn--as-a'].join(' ')) + expect(component({ as: 'a' }).className).toBe(['sx1hhcn', 'sx1hhcna4ldn--as-a'].join(' ')) expect(component({ as: 'a' }).props.as).toBe('a') - expect(toString()).toBe(['.sx03kzer9r9e--as-button{color:dodgerblue;}', '.sx03kzea4ldn--as-a{color:tomato;}'].join('')) + expect(toString()).toBe(['.sx1hhcnr9r9e--as-button{color:dodgerblue;}', '.sx1hhcna4ldn--as-a{color:tomato;}'].join('')) }) }) diff --git a/packages/core/tests/component-variants.js b/packages/core/tests/component-variants.js index bd76a4bc..e84a5a1e 100644 --- a/packages/core/tests/component-variants.js +++ b/packages/core/tests/component-variants.js @@ -46,7 +46,7 @@ describe('Variants', () => { const component = css(componentConfig) const expression = component() - expect(expression.className).toBe('sx03kze') + expect(expression.className).toBe('sx1alao') expect(toString()).toBe('') }) @@ -55,16 +55,16 @@ describe('Variants', () => { const component = css(componentConfig) const expression1 = component({ size: 'small' }) - const expression1CssText = '.sx03kzetmy8g--size-small{font-size:16px;}' + const expression1CssText = '.sx1alaotmy8g--size-small{font-size:16px;}' - expect(expression1.className).toBe('sx03kze sx03kzetmy8g--size-small') + expect(expression1.className).toBe('sx1alao sx1alaotmy8g--size-small') expect(toString()).toBe(expression1CssText) const expression2 = component({ color: 'blue' }) - const expression2CssText = '.sx03kze4wpam--color-blue{background-color:dodgerblue;color:white;}' + const expression2CssText = '.sx1alao4wpam--color-blue{background-color:dodgerblue;color:white;}' - expect(expression2.className).toBe('sx03kze sx03kze4wpam--color-blue') + expect(expression2.className).toBe('sx1alao sx1alao4wpam--color-blue') expect(toString()).toBe(expression1CssText + expression2CssText) }) @@ -73,10 +73,10 @@ describe('Variants', () => { const component = css(componentConfig) const expression = component({ size: 'small', level: 1 }) - expect(expression.className).toBe('sx03kze sx03kzetmy8g--size-small sx03kzehmqox--level-1') + expect(expression.className).toBe('sx1alao sx1alaotmy8g--size-small sx1alaohmqox--level-1') - const expressionSizeSmallCssText = '.sx03kzetmy8g--size-small{font-size:16px;}' - const expressionLevel1CssText = '.sx03kzehmqox--level-1{padding:0.5em;}' + const expressionSizeSmallCssText = '.sx1alaotmy8g--size-small{font-size:16px;}' + const expressionLevel1CssText = '.sx1alaohmqox--level-1{padding:0.5em;}' expect(toString()).toBe(expressionSizeSmallCssText + expressionLevel1CssText) }) @@ -86,12 +86,12 @@ describe('Variants', () => { const component = css(componentConfig) const expression = component({ size: 'small', color: 'blue' }) - const expressionSizeSmallCssText = '.sx03kzetmy8g--size-small{font-size:16px;}' - const expressionColorBlueCssText = '.sx03kze4wpam--color-blue{background-color:dodgerblue;color:white;}' - const expressionCompoundCssText = '.sx03kzeif1wl--comp{transform:scale(1.2);}' + const expressionColorBlueCssText = '.sx1alao4wpam--color-blue{background-color:dodgerblue;color:white;}' + const expressionSizeSmallCssText = '.sx1alaotmy8g--size-small{font-size:16px;}' + const expressionCompoundCssText = '.sx1alaoif1wl--c2{transform:scale(1.2);}' - expect(expression.className).toBe('sx03kze sx03kzeif1wl--comp sx03kzetmy8g--size-small sx03kze4wpam--color-blue') - expect(toString()).toBe(expressionSizeSmallCssText + expressionColorBlueCssText + expressionCompoundCssText) + expect(expression.className).toBe('sx1alao sx1alao4wpam--color-blue sx1alaotmy8g--size-small sx1alaoif1wl--c2') + expect(toString()).toBe(expressionColorBlueCssText + expressionSizeSmallCssText + expressionCompoundCssText) }) }) @@ -144,8 +144,8 @@ describe('Variants with defaults', () => { const component = css(componentConfig) const expression = component() - expect(expression.className).toBe('sx03kze sx03kzetmy8g--size-small') - expect(toString()).toBe('.sx03kzetmy8g--size-small{font-size:16px;}') + expect(expression.className).toBe('sx84yep sx84yeptmy8g--size-small') + expect(toString()).toBe('.sx84yeptmy8g--size-small{font-size:16px;}') }) test('Renders a component with the default variant explicitly applied', () => { @@ -153,8 +153,8 @@ describe('Variants with defaults', () => { const component = css(componentConfig) const expression = component({ size: 'small' }) - expect(expression.className).toBe('sx03kze sx03kzetmy8g--size-small') - expect(toString()).toBe('.sx03kzetmy8g--size-small{font-size:16px;}') + expect(expression.className).toBe('sx84yep sx84yeptmy8g--size-small') + expect(toString()).toBe('.sx84yeptmy8g--size-small{font-size:16px;}') }) test('Renders a component with the non-default variant explicitly applied', () => { @@ -162,8 +162,8 @@ describe('Variants with defaults', () => { const component = css(componentConfig) const expression = component({ size: 'large' }) - expect(expression.className).toBe('sx03kze sx03kzefhyhx--size-large') - expect(toString()).toBe('.sx03kzefhyhx--size-large{font-size:24px;}') + expect(expression.className).toBe('sx84yep sx84yepfhyhx--size-large') + expect(toString()).toBe('.sx84yepfhyhx--size-large{font-size:24px;}') }) test('Renders a component with the default variant applied and a different variant explicitly applied', () => { @@ -171,13 +171,13 @@ describe('Variants with defaults', () => { const component = css(componentConfig) const expression = component({ level: 1 }) - expect(expression.className).toBe('sx03kze sx03kzehmqox--level-1 sx03kzetmy8g--size-small') + expect(expression.className).toBe('sx84yep sx84yeptmy8g--size-small sx84yephmqox--level-1') expect(toString()).toBe( [ - // explicit level:1 - '.sx03kzehmqox--level-1{padding:0.5em;}', // implicit size:small - '.sx03kzetmy8g--size-small{font-size:16px;}', + '.sx84yeptmy8g--size-small{font-size:16px;}', + // explicit level:1 + '.sx84yephmqox--level-1{padding:0.5em;}', ].join(''), ) }) @@ -187,15 +187,15 @@ describe('Variants with defaults', () => { const component = css(componentConfig) const expression = component({ color: 'blue' }) - expect(expression.className).toBe('sx03kze sx03kzeif1wl--comp sx03kze4wpam--color-blue sx03kzetmy8g--size-small') + expect(expression.className).toBe('sx84yep sx84yep4wpam--color-blue sx84yeptmy8g--size-small sx84yepif1wl--c2') expect(toString()).toBe( [ // explicit color:blue - '.sx03kze4wpam--color-blue{background-color:dodgerblue;color:white;}', + '.sx84yep4wpam--color-blue{background-color:dodgerblue;color:white;}', // implicit size:small - '.sx03kzetmy8g--size-small{font-size:16px;}', + '.sx84yeptmy8g--size-small{font-size:16px;}', // compound color:blue + size:small - '.sx03kzeif1wl--comp{transform:scale(1.2);}', + '.sx84yepif1wl--c2{transform:scale(1.2);}', ].join(''), ) }) @@ -205,13 +205,8 @@ describe('Variants with defaults', () => { const component = css(componentConfig) const className = component.toString() - expect(className).toBe('sx03kze') - expect(toString()).toBe( - [ - // implicit size:small - '.sx03kzetmy8g--size-small{font-size:16px;}', - ].join(''), - ) + expect(className).toBe('sx84yep') + expect(toString()).toBe('') }) }) @@ -267,7 +262,7 @@ describe('Conditional variants', () => { test('Renders a component with no variant applied', () => { const { css, toString } = createCss(config) const component = css(componentConfig) - const componentClassName = 'sx03kze' + const componentClassName = 'sx1alao' expect(component().className).toBe(componentClassName) expect(toString()).toBe('') @@ -276,7 +271,7 @@ describe('Conditional variants', () => { test('Renders a component with one variant applied', () => { const { css, toString } = createCss(config) const component = css(componentConfig) - const componentClassName = `sx03kze` + const componentClassName = `sx1alao` const componentSmallClassName = `${componentClassName}tmy8g--size-small` const componentSmallCssText = `.${componentSmallClassName}{font-size:16px;}` @@ -287,8 +282,8 @@ describe('Conditional variants', () => { test('Renders a component with one conditional variant on one breakpoint applied', () => { const { css, toString } = createCss(config) const component = css(componentConfig) - const componentClassName = `sx03kze` - const componentSmallBp1ClassName = `${componentClassName}tmy8g--size-small--5m2l7` + const componentClassName = `sx1alao` + const componentSmallBp1ClassName = `${componentClassName}iopr7--size-small` const componentSmallBp1CssText = `@media (max-width: 767px){.${componentSmallBp1ClassName}{font-size:16px;}}` expect(component({ size: { bp1: 'small' } }).className).toBe([componentClassName, componentSmallBp1ClassName].join(' ')) @@ -298,11 +293,11 @@ describe('Conditional variants', () => { test('Renders a component with one conditional variant on two breakpoints applied', () => { const { css, toString } = createCss(config) const component = css(componentConfig) - const componentClassName = `sx03kze` - const componentSmallBp1ClassName = `${componentClassName}tmy8g--size-small--5m2l7` - const componentLargeBp2ClassName = `${componentClassName}fhyhx--size-large--8c3r4` + const componentClassName = `sx1alao` + const componentSmallBp1ClassName = `${componentClassName}iopr7--size-small` + const componentLargeBp2ClassName = `${componentClassName}o7z8r--size-large` const componentSmallBp1CssText = `@media (max-width: 767px){.${componentSmallBp1ClassName}{font-size:16px;}}` - const componentLargeBp2CssText = `@media (min-width: 768px){.sx03kzefhyhx--size-large--8c3r4{font-size:24px;}}` + const componentLargeBp2CssText = `@media (min-width: 768px){.sx1alaoo7z8r--size-large{font-size:24px;}}` expect(component({ size: { bp1: 'small', bp2: 'large' } }).className).toBe([componentClassName, componentSmallBp1ClassName, componentLargeBp2ClassName].join(' ')) expect(toString()).toBe([componentSmallBp1CssText, componentLargeBp2CssText].join('')) @@ -311,17 +306,17 @@ describe('Conditional variants', () => { test('Renders a component with a conditional variant repeatedly', () => { const { css, toString } = createCss(config) const component = css(componentConfig) - const componentClassName = `sx03kze` - const componentSmallBp1ClassName = `${componentClassName}tmy8g--size-small--5m2l7` - const componentLargeBp2ClassName = `${componentClassName}fhyhx--size-large--8c3r4` + const componentClassName = `sx1alao` + const componentSmallBp1ClassName = `${componentClassName}iopr7--size-small` + const componentLargeBp2ClassName = `${componentClassName}o7z8r--size-large` const componentSmallBp1CssText = `@media (max-width: 767px){.${componentSmallBp1ClassName}{font-size:16px;}}` - const componentLargeBp2CssText = `@media (min-width: 768px){.sx03kzefhyhx--size-large--8c3r4{font-size:24px;}}` + const componentLargeBp2CssText = `@media (min-width: 768px){.sx1alaoo7z8r--size-large{font-size:24px;}}` expect(component({ size: { bp1: 'small', bp2: 'large' } }).className).toBe([componentClassName, componentSmallBp1ClassName, componentLargeBp2ClassName].join(' ')) expect(toString()).toBe([componentSmallBp1CssText, componentLargeBp2CssText].join('')) - expect(component({ size: { bp1: 'small', bp2: 'large' } }).className).toBe([componentClassName, componentSmallBp1ClassName, componentLargeBp2ClassName].join(' ')) - expect(toString()).toBe([componentSmallBp1CssText, componentLargeBp2CssText].join('')) + expect(component({ size: { bp1: 'small', bp2: 'large' } }).className).toBe(`sx1alao sx1alaoiopr7--size-small sx1alaoo7z8r--size-large`) + expect(toString()).toBe(`@media (max-width: 767px){.sx1alaoiopr7--size-small{font-size:16px;}}@media (min-width: 768px){.sx1alaoo7z8r--size-large{font-size:24px;}}`) expect(component({ size: { bp1: 'small', bp2: 'large' } }).className).toBe([componentClassName, componentSmallBp1ClassName, componentLargeBp2ClassName].join(' ')) expect(toString()).toBe([componentSmallBp1CssText, componentLargeBp2CssText].join('')) diff --git a/packages/core/tests/global-atrules.js b/packages/core/tests/global-atrules.js index 2720b366..d024713e 100644 --- a/packages/core/tests/global-atrules.js +++ b/packages/core/tests/global-atrules.js @@ -7,7 +7,7 @@ describe('Support @import', () => { const importURL = `https://unpkg.com/sanitize.css@12.0.1/sanitize.css` global({ - '@import': `"${importURL}"` + '@import': `"${importURL}"`, })() expect(toString()).toBe(`@import "${importURL}";`) @@ -20,7 +20,7 @@ describe('Support @import', () => { const importURL2 = `https://unpkg.com/sanitize.css@12.0.1/typography.css` global({ - '@import': [`"${importURL1}"`, `"${importURL2}"`] + '@import': [`"${importURL1}"`, `"${importURL2}"`], })() expect(toString()).toBe(`@import "${importURL1}";@import "${importURL2}";`) @@ -45,12 +45,14 @@ describe('Support @font-face', () => { `local("Ubuntu")`, `local("Roboto-Regular")`, `local("DroidSans")`, - `local("Tahoma")` - ] - } + `local("Tahoma")`, + ], + }, })() - expect(toString()).toBe(`@font-face{font-family:system-ui;font-style:normal;font-weight:400;src:local(".SFNS-Regular"),local(".SFNSText-Regular"),local(".HelveticaNeueDeskInterface-Regular"),local(".LucidaGrandeUI"),local("Segoe UI"),local("Ubuntu"),local("Roboto-Regular"),local("DroidSans"),local("Tahoma");}`) + expect(toString()).toBe( + `@font-face{font-family:system-ui;font-style:normal;font-weight:400;src:local(".SFNS-Regular"),local(".SFNSText-Regular"),local(".HelveticaNeueDeskInterface-Regular"),local(".LucidaGrandeUI"),local("Segoe UI"),local("Ubuntu"),local("Roboto-Regular"),local("DroidSans"),local("Tahoma");}`, + ) }) test('Authors can define multiple @font-face rules', () => { const { global, toString } = createCss({}) @@ -70,8 +72,8 @@ describe('Support @font-face', () => { `local("Ubuntu")`, `local("Roboto-Regular")`, `local("DroidSans")`, - `local("Tahoma")` - ] + `local("Tahoma")`, + ], }, { fontFamily: 'system-ui', @@ -86,10 +88,10 @@ describe('Support @font-face', () => { `local("Ubuntu Italic")`, `local("Roboto-Italic")`, `local("DroidSans")`, - `local("Tahoma")` - ] - } - ] + `local("Tahoma")`, + ], + }, + ], })() const cssFontFaceRule1 = `@font-face{font-family:system-ui;font-style:normal;font-weight:400;src:local(".SFNS-Regular"),local(".SFNSText-Regular"),local(".HelveticaNeueDeskInterface-Regular"),local(".LucidaGrandeUI"),local("Segoe UI"),local("Ubuntu"),local("Roboto-Regular"),local("DroidSans"),local("Tahoma");}` diff --git a/packages/core/tests/internal-Object.js b/packages/core/tests/internal-Object.js index 6f4a0001..f325725c 100644 --- a/packages/core/tests/internal-Object.js +++ b/packages/core/tests/internal-Object.js @@ -1,4 +1,4 @@ -import Object, { assign, create } from '../src/Object.js' +import { assign, create } from '../src/Object.js' describe('Object()', () => { const object = {} @@ -17,7 +17,7 @@ describe('Object()', () => { expect(string.constructor).toBe(String) expect(string[0]).toBe('A') - expect(Object(string)).not.toBe(string) + expect(Object(string)).toNotBe(string) expect(typeof Object(string)).toBe('object') expect(Object(string).constructor).toBe(String) expect(Object(string)[0]).toBe('A') @@ -30,7 +30,7 @@ describe('Object()', () => { expect(typeof number).toBe('number') expect(number.constructor).toBe(Number) - expect(Object(number)).not.toBe(number) + expect(Object(number)).toNotBe(number) expect(typeof Object(number)).toBe('object') expect(Object(number).constructor).toBe(Number) diff --git a/packages/core/tests/internal-isDeclaration.js b/packages/core/tests/internal-isDeclaration.js index 7af5e44b..98891948 100644 --- a/packages/core/tests/internal-isDeclaration.js +++ b/packages/core/tests/internal-isDeclaration.js @@ -4,8 +4,8 @@ describe('isDeclaration()', () => { test('isDeclaration() correctly identifies some declarations', () => { expect(isDeclaration('1')).toBe(true) expect(isDeclaration(1)).toBe(true) - expect(isDeclaration(Object.assign(() => {}, { toString: () => '1' }))).toBe(true) - expect(isDeclaration(Object.assign(() => {}, { toString: () => 1 }))).toBe(true) + expect(isDeclaration(Object.assign(() => undefined, { toString: () => '1' }))).toBe(true) + expect(isDeclaration(Object.assign(() => undefined, { toString: () => 1 }))).toBe(true) expect(isDeclaration(Object('1'))).toBe(true) expect(isDeclaration(Object(1))).toBe(true) }) diff --git a/packages/react/src/index.js b/packages/react/src/index.js index d7ca1b17..0e786674 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -1,70 +1,27 @@ -import Object, { assign } from '../../core/src/Object.js' +import { assign } from '../../core/src/Object.js' import createCoreCss from '../../core/src/index.js' import defaultThemeMap from '../../core/src/defaultThemeMap.js' +import { $$composers } from '../../core/src/Symbol.js' -const $$typeof = Symbol.for('react.element') +const $$typeofElement = Symbol.for('react.element') const $$typeofForward = Symbol.for('react.forward_ref') const createCss = (init) => { - const hasDocument = typeof document === 'object' - - const importText = hasDocument && new Text('') - const themedText = hasDocument && new Text('') - const globalText = hasDocument && new Text('') - const styledText = hasDocument && new Text('') - - const createOnChange = hasDocument ? (textNode) => (data) => (textNode.data = data) : () => undefined - - let sheetParent - let sheetTarget - - init = assign( - { - onImport: createOnChange(importText, 'import'), - onThemed: createOnChange(themedText, 'themed'), - onGlobal: createOnChange(globalText, 'global'), - onStyled: createOnChange(styledText, 'styled'), - onResets() { - if (hasDocument) { - this.sync() - - sheetTarget.textContent = importText.data = themedText.data = globalText.data = styledText.data = '' - sheetTarget.append(importText, themedText, globalText, styledText) - } - }, - }, - init, - ) - const sheet = createCoreCss(init) return assign(sheet, { - sync() { - if (hasDocument) { - if (!sheetParent) sheetParent = document.head || document.documentElement - if (!sheetTarget) sheetTarget = document.getElementById('stitches') || assign(document.createElement('style'), { id: 'stitches' }) - if (!sheetTarget.parentNode) sheetParent.prepend(sheetTarget) - } - }, styled: new Proxy( /** Returns a React component. */ ( /** Type of component. */ - asType, - /** Component styles. */ - initStyles, + ...args ) => { - const isComposition = 'composes' in Object(asType) - - /** Expression used to activate the component CSS on the current styled sheet. */ - const composition = isComposition ? sheet.css(asType.composes, initStyles) : sheet.css(initStyles) - - const defaultType = (isComposition ? asType.type : asType) || 'span' + const composition = sheet.css(...args) + const defaultType = composition.type || 'span' /** Returns a React element. */ return Object.setPrototypeOf( { - $$typeof: $$typeofForward, render( /** Props used to determine the expression of the current component. */ initProps, @@ -76,12 +33,11 @@ const createCss = (init) => { ...expressedProps } = composition(initProps) - // sync the dynamic stylesheet - sheet.sync() - /** React element. */ - return { constructor: undefined, $$typeof, props, ref, type, __v: 0 } + return { constructor: undefined, $$typeof: $$typeofElement, props, ref, type, __v: 0 } }, + $$typeof: $$typeofForward, + [$$composers]: composition[$$composers], [Symbol.toPrimitive]() { return composition.selector }, @@ -94,12 +50,9 @@ const createCss = (init) => { get selector() { return composition.selector }, - composes: composition, - classNames: composition.classNames, - variants: composition.variants, type: defaultType, }, - Object(asType), + Object(defaultType), ) }, { diff --git a/packages/react/tests/component-composition.js b/packages/react/tests/component-composition.js new file mode 100644 index 00000000..1691a50e --- /dev/null +++ b/packages/react/tests/component-composition.js @@ -0,0 +1,23 @@ +import createCss from '../src/index.js' + +describe('Composition', () => { + test('Renders a component as the final composition by default', () => { + const { styled, toString } = createCss() + const red = styled({ color: 'red' }) + const size14 = styled({ fontSize: '14px' }) + const bold = styled({ fontWeight: 'bold' }) + const title = styled(red, size14, bold, { fontFamily: 'monospace' }) + expect(title.className).toBe('sxlktsn') + expect(toString()).toBe('.sxlktsn{font-family:monospace;}') + }) + + test('Renders a component with all compositions', () => { + const { styled, toString } = createCss() + const red = styled({ color: 'red' }) + const size14 = styled({ fontSize: '14px' }) + const bold = styled({ fontWeight: 'bold' }) + const title = styled(red, size14, bold, { fontFamily: 'monospace' }) + expect(title.render().props.className).toBe('sx3ye05 sxgsv3w sxsfl0j sxlktsn') + expect(toString()).toBe('.sx3ye05{color:red;}.sxgsv3w{font-size:14px;}.sxsfl0j{font-weight:bold;}.sxlktsn{font-family:monospace;}') + }) +}) diff --git a/packages/react/tests/component-variants.js b/packages/react/tests/component-variants.js index 4a76ce3c..bc484858 100644 --- a/packages/react/tests/component-variants.js +++ b/packages/react/tests/component-variants.js @@ -46,7 +46,7 @@ describe('Variants', () => { const component = styled('div', componentConfig) const expression = component.render() - expect(expression.props.className).toBe('sx03kze') + expect(expression.props.className).toBe('sx1alao') expect(toString()).toBe('') }) @@ -55,16 +55,16 @@ describe('Variants', () => { const component = styled('div', componentConfig) const expression1 = component.render({ size: 'small' }) - const expression1CssText = '.sx03kzetmy8g--size-small{font-size:16px;}' + const expression1CssText = '.sx1alaotmy8g--size-small{font-size:16px;}' - expect(expression1.props.className).toBe('sx03kze sx03kzetmy8g--size-small') + expect(expression1.props.className).toBe('sx1alao sx1alaotmy8g--size-small') expect(toString()).toBe(expression1CssText) const expression2 = component.render({ color: 'blue' }) - const expression2CssText = '.sx03kze4wpam--color-blue{background-color:dodgerblue;color:white;}' + const expression2CssText = '.sx1alao4wpam--color-blue{background-color:dodgerblue;color:white;}' - expect(expression2.props.className).toBe('sx03kze sx03kze4wpam--color-blue') + expect(expression2.props.className).toBe('sx1alao sx1alao4wpam--color-blue') expect(toString()).toBe(expression1CssText + expression2CssText) }) @@ -73,10 +73,10 @@ describe('Variants', () => { const component = styled('div', componentConfig) const expression = component.render({ size: 'small', level: 1 }) - expect(expression.props.className).toBe('sx03kze sx03kzetmy8g--size-small sx03kzehmqox--level-1') + expect(expression.props.className).toBe('sx1alao sx1alaotmy8g--size-small sx1alaohmqox--level-1') - const expressionSizeSmallCssText = '.sx03kzetmy8g--size-small{font-size:16px;}' - const expressionLevel1CssText = '.sx03kzehmqox--level-1{padding:0.5em;}' + const expressionSizeSmallCssText = '.sx1alaotmy8g--size-small{font-size:16px;}' + const expressionLevel1CssText = '.sx1alaohmqox--level-1{padding:0.5em;}' expect(toString()).toBe(expressionSizeSmallCssText + expressionLevel1CssText) }) @@ -86,12 +86,12 @@ describe('Variants', () => { const component = styled('div', componentConfig) const expression = component.render({ size: 'small', color: 'blue' }) - const expressionSizeSmallCssText = '.sx03kzetmy8g--size-small{font-size:16px;}' - const expressionColorBlueCssText = '.sx03kze4wpam--color-blue{background-color:dodgerblue;color:white;}' - const expressionCompoundCssText = '.sx03kzeif1wl--comp{transform:scale(1.2);}' + const expressionSizeSmallCssText = '.sx1alaotmy8g--size-small{font-size:16px;}' + const expressionColorBlueCssText = '.sx1alao4wpam--color-blue{background-color:dodgerblue;color:white;}' + const expressionCompoundCssText = '.sx1alaoif1wl--c2{transform:scale(1.2);}' - expect(expression.props.className).toBe('sx03kze sx03kzeif1wl--comp sx03kzetmy8g--size-small sx03kze4wpam--color-blue') - expect(toString()).toBe(expressionSizeSmallCssText + expressionColorBlueCssText + expressionCompoundCssText) + expect(expression.props.className).toBe('sx1alao sx1alao4wpam--color-blue sx1alaotmy8g--size-small sx1alaoif1wl--c2') + expect(toString()).toBe(expressionColorBlueCssText + expressionSizeSmallCssText + expressionCompoundCssText) }) }) @@ -144,8 +144,8 @@ describe('Variants with defaults', () => { const component = styled('div', componentConfig) const expression = component.render() - expect(expression.props.className).toBe('sx03kze sx03kzetmy8g--size-small') - expect(toString()).toBe('.sx03kzetmy8g--size-small{font-size:16px;}') + expect(expression.props.className).toBe('sx84yep sx84yeptmy8g--size-small') + expect(toString()).toBe('.sx84yeptmy8g--size-small{font-size:16px;}') }) test('Renders a component with the default variant explicitly applied', () => { @@ -153,8 +153,8 @@ describe('Variants with defaults', () => { const component = styled('div', componentConfig) const expression = component.render({ size: 'small' }) - expect(expression.props.className).toBe('sx03kze sx03kzetmy8g--size-small') - expect(toString()).toBe('.sx03kzetmy8g--size-small{font-size:16px;}') + expect(expression.props.className).toBe('sx84yep sx84yeptmy8g--size-small') + expect(toString()).toBe('.sx84yeptmy8g--size-small{font-size:16px;}') }) test('Renders a component with the non-default variant explicitly applied', () => { @@ -162,8 +162,8 @@ describe('Variants with defaults', () => { const component = styled('div', componentConfig) const expression = component.render({ size: 'large' }) - expect(expression.props.className).toBe('sx03kze sx03kzefhyhx--size-large') - expect(toString()).toBe('.sx03kzefhyhx--size-large{font-size:24px;}') + expect(expression.props.className).toBe('sx84yep sx84yepfhyhx--size-large') + expect(toString()).toBe('.sx84yepfhyhx--size-large{font-size:24px;}') }) test('Renders a component with the default variant applied and a different variant explicitly applied', () => { @@ -171,13 +171,13 @@ describe('Variants with defaults', () => { const component = styled('div', componentConfig) const expression = component.render({ level: 1 }) - expect(expression.props.className).toBe('sx03kze sx03kzehmqox--level-1 sx03kzetmy8g--size-small') + expect(expression.props.className).toBe('sx84yep sx84yeptmy8g--size-small sx84yephmqox--level-1') expect(toString()).toBe( [ - // explicit level:1 - '.sx03kzehmqox--level-1{padding:0.5em;}', // implicit size:small - '.sx03kzetmy8g--size-small{font-size:16px;}', + '.sx84yeptmy8g--size-small{font-size:16px;}', + // explicit level:1 + '.sx84yephmqox--level-1{padding:0.5em;}', ].join(''), ) }) @@ -187,15 +187,15 @@ describe('Variants with defaults', () => { const component = styled('div', componentConfig) const expression = component.render({ color: 'blue' }) - expect(expression.props.className).toBe('sx03kze sx03kzeif1wl--comp sx03kze4wpam--color-blue sx03kzetmy8g--size-small') + expect(expression.props.className).toBe('sx84yep sx84yep4wpam--color-blue sx84yeptmy8g--size-small sx84yepif1wl--c2') expect(toString()).toBe( [ // explicit color:blue - '.sx03kze4wpam--color-blue{background-color:dodgerblue;color:white;}', + '.sx84yep4wpam--color-blue{background-color:dodgerblue;color:white;}', // implicit size:small - '.sx03kzetmy8g--size-small{font-size:16px;}', + '.sx84yeptmy8g--size-small{font-size:16px;}', // compound color:blue + size:small - '.sx03kzeif1wl--comp{transform:scale(1.2);}', + '.sx84yepif1wl--c2{transform:scale(1.2);}', ].join(''), ) }) @@ -205,13 +205,8 @@ describe('Variants with defaults', () => { const component = styled('div', componentConfig) const selector = component.toString() - expect(selector).toBe('.sx03kze') - expect(toString()).toBe( - [ - // implicit size:small - '.sx03kzetmy8g--size-small{font-size:16px;}', - ].join(''), - ) + expect(selector).toBe('.sx84yep') + expect(toString()).toBe('') }) }) @@ -267,7 +262,7 @@ describe('Conditional variants', () => { test('Renders a component with no variant applied', () => { const { styled, toString } = createCss(config) const component = styled('div', componentConfig) - const componentClassName = 'sx03kze' + const componentClassName = 'sx1alao' expect(component.render().props.className).toBe(componentClassName) expect(toString()).toBe('') @@ -276,7 +271,7 @@ describe('Conditional variants', () => { test('Renders a component with one variant applied', () => { const { styled, toString } = createCss(config) const component = styled('div', componentConfig) - const componentClassName = `sx03kze` + const componentClassName = `sx1alao` const componentSmallClassName = `${componentClassName}tmy8g--size-small` const componentSmallCssText = `.${componentSmallClassName}{font-size:16px;}` @@ -287,8 +282,8 @@ describe('Conditional variants', () => { test('Renders a component with one conditional variant on one breakpoint applied', () => { const { styled, toString } = createCss(config) const component = styled('div', componentConfig) - const componentClassName = `sx03kze` - const componentSmallBp1ClassName = `${componentClassName}tmy8g--size-small--5m2l7` + const componentClassName = `sx1alao` + const componentSmallBp1ClassName = `${componentClassName}iopr7--size-small` const componentSmallBp1CssText = `@media (max-width: 767px){.${componentSmallBp1ClassName}{font-size:16px;}}` expect(component.render({ size: { bp1: 'small' } }).props.className).toBe([componentClassName, componentSmallBp1ClassName].join(' ')) @@ -298,11 +293,11 @@ describe('Conditional variants', () => { test('Renders a component with one conditional variant on two breakpoints applied', () => { const { styled, toString } = createCss(config) const component = styled('div', componentConfig) - const componentClassName = `sx03kze` - const componentSmallBp1ClassName = `${componentClassName}tmy8g--size-small--5m2l7` - const componentLargeBp2ClassName = `${componentClassName}fhyhx--size-large--8c3r4` + const componentClassName = `sx1alao` + const componentSmallBp1ClassName = `${componentClassName}iopr7--size-small` + const componentLargeBp2ClassName = `${componentClassName}o7z8r--size-large` const componentSmallBp1CssText = `@media (max-width: 767px){.${componentSmallBp1ClassName}{font-size:16px;}}` - const componentLargeBp2CssText = `@media (min-width: 768px){.sx03kzefhyhx--size-large--8c3r4{font-size:24px;}}` + const componentLargeBp2CssText = `@media (min-width: 768px){.${componentLargeBp2ClassName}{font-size:24px;}}` expect(component.render({ size: { bp1: 'small', bp2: 'large' } }).props.className).toBe([componentClassName, componentSmallBp1ClassName, componentLargeBp2ClassName].join(' ')) expect(toString()).toBe([componentSmallBp1CssText, componentLargeBp2CssText].join('')) @@ -311,19 +306,19 @@ describe('Conditional variants', () => { test('Renders a component with a conditional variant repeatedly', () => { const { styled, toString } = createCss(config) const component = styled('div', componentConfig) - const componentClassName = `sx03kze` - const componentSmallBp1ClassName = `${componentClassName}tmy8g--size-small--5m2l7` - const componentLargeBp2ClassName = `${componentClassName}fhyhx--size-large--8c3r4` + const componentClassName = `sx1alao` + const componentSmallBp1ClassName = `${componentClassName}iopr7--size-small` + const componentLargeBp2ClassName = `${componentClassName}o7z8r--size-large` const componentSmallBp1CssText = `@media (max-width: 767px){.${componentSmallBp1ClassName}{font-size:16px;}}` - const componentLargeBp2CssText = `@media (min-width: 768px){.sx03kzefhyhx--size-large--8c3r4{font-size:24px;}}` + const componentLargeBp2CssText = `@media (min-width: 768px){.sx1alaoo7z8r--size-large{font-size:24px;}}` expect(component.render({ size: { bp1: 'small', bp2: 'large' } }).props.className).toBe([componentClassName, componentSmallBp1ClassName, componentLargeBp2ClassName].join(' ')) expect(toString()).toBe([componentSmallBp1CssText, componentLargeBp2CssText].join('')) expect(component.render({ size: { bp1: 'small', bp2: 'large' } }).props.className).toBe([componentClassName, componentSmallBp1ClassName, componentLargeBp2ClassName].join(' ')) - expect(toString()).toBe([componentSmallBp1CssText, componentLargeBp2CssText].join('')) + expect(toString()).toBe(`@media (max-width: 767px){.sx1alaoiopr7--size-small{font-size:16px;}}@media (min-width: 768px){.sx1alaoo7z8r--size-large{font-size:24px;}}`) - expect(component.render({ size: { bp1: 'small', bp2: 'large' } }).props.className).toBe([componentClassName, componentSmallBp1ClassName, componentLargeBp2ClassName].join(' ')) - expect(toString()).toBe([componentSmallBp1CssText, componentLargeBp2CssText].join('')) + expect(component.render({ size: { bp1: 'small', bp2: 'large' } }).props.className).toBe(`sx1alao sx1alaoiopr7--size-small sx1alaoo7z8r--size-large`) + expect(toString()).toBe(`@media (max-width: 767px){.sx1alaoiopr7--size-small{font-size:16px;}}@media (min-width: 768px){.sx1alaoo7z8r--size-large{font-size:24px;}}`) }) }) diff --git a/packages/react/tests/issue-416.js b/packages/react/tests/issue-416.js new file mode 100644 index 00000000..de642d23 --- /dev/null +++ b/packages/react/tests/issue-416.js @@ -0,0 +1,77 @@ +import * as React from 'react' +import * as renderer from 'react-test-renderer' +import createCss from '../src/index.js' + +describe('Issue #416', () => { + test('Composition versus Descendancy', () => { + const { styled, toString } = createCss() + + const BoxA = styled('main', { + variants: { + foo: { + bar: { + '--box-a': 'foo-bar', + }, + }, + }, + }) + + const BoxB = styled(BoxA, { + variants: { + foo: { + bar: { + '--box-b': 'foo-bar', + }, + }, + }, + }) + + const GenY = (props) => { + return React.createElement(BoxB, props) + } + + const BoxZ = styled(GenY, { + variants: { + foo: { + bar: { + '--box-z': 'foo-bar', + }, + }, + }, + }) + + const App = () => { + return React.createElement( + 'div', + null, + // children + React.createElement(BoxA, { foo: 'bar' }), + React.createElement(BoxB, { foo: 'bar' }), + React.createElement(GenY, { foo: 'bar' }), + React.createElement(BoxZ, { foo: 'bar' }), + ) + } + + let wrapper + + renderer.act(() => { + wrapper = renderer.create(React.createElement(App)) + }) + + const [boxA, boxB, genY, boxZ] = wrapper.toJSON().children + + // Box A has an active variant + expect(boxA.props.className).toBe(`sxvbr6p sxvbr6p9ao8r--foo-bar`) + + // Box B has an active variant, plus the active variant of Box A + expect(boxB.props.className).toBe(`sxvbr6p sxvbr6p9ao8r--foo-bar sxxk7xo sxxk7xo8d9vp--foo-bar`) + + // Gen Y has no variant, but activates the variants of Box A and Box B + expect(genY.props.className).toBe(`sxvbr6p sxvbr6p9ao8r--foo-bar sxxk7xo sxxk7xo8d9vp--foo-bar`) + + // Box Z has an active variant, but does not activate the variants of Box A or Box B + expect(boxZ.props.className).toBe(`sxvbr6p sxxk7xo sxi3g1z sxi3g1z4qa6j--foo-bar`) + + expect(toString()).toBe(`.sxvbr6p9ao8r--foo-bar{--box-a:foo-bar;}.sxxk7xo8d9vp--foo-bar{--box-b:foo-bar;}.sxi3g1z4qa6j--foo-bar{--box-z:foo-bar;}`) + }) +}) diff --git a/packages/react/tests/issue-450.js b/packages/react/tests/issue-450.js new file mode 100644 index 00000000..ea9795c8 --- /dev/null +++ b/packages/react/tests/issue-450.js @@ -0,0 +1,87 @@ +import * as React from 'react' +import * as renderer from 'react-test-renderer' +import createCss from '../src/index.js' + +describe('Issue #416', () => { + test('Compound variants apply to composed components', () => { + const { styled } = createCss() + + const Tile = styled('div', { + '--tile': 1, + variants: { + appearance: { + primary: {}, + secondary: { + '--appearance-secondary': 1, + }, + }, + color: { + red: {}, + purple: { + '--color-purple': 1, + }, + lightBlue: { + '--color-lightblue': 1, + }, + }, + }, + compoundVariants: [ + { + appearance: 'secondary', + color: 'lightBlue', + css: { + '--compound-appearance_secondary-color_lightblue': 1, + }, + }, + ], + defaultVariants: { + appearance: 'primary', + color: 'red', + }, + }) + + const RoundedTile = styled(Tile, { + '--rounded-tile': 1, + defaultVariants: { + appearance: 'secondary', + color: 'lightBlue', + }, + }) + + let RenderOf = (...args) => { + let Rendered + + void renderer.act(() => { + Rendered = renderer.create(React.createElement(...args)) + }) + + return Rendered.toJSON() + } + + const classOfAppearancePrimary = 'sx7vw5z03kz9--appearance-primary' + const classOfAppearanceSecondary = 'sx7vw5z8kgjb--appearance-secondary' + const classOfColorLightBlue = 'sx7vw5z2xt05--color-lightBlue' + const classOfColorRed = `sx7vw5z03kz8--color-red` + const classOfCompound = 'sx7vw5zt2yhf--c2' + + /* Normal variants */ + // appearance: primary, color: red + expect(RenderOf(Tile, null, 'Red').props.className).toBe(`sxkpryy sxkpryy03kze--appearance-primary sxkpryy03kze--color-red`) + // appearance: primary, color: lightblue + expect(RenderOf(Tile, { color: 'lightBlue' }, 'Blue').props.className).toBe(`sxkpryy sxkpryy03kze--appearance-primary sxkpryy2xt05--color-lightBlue`) + + /* Compound variants */ + // appearance: primary, color: lightblue + expect(RenderOf(Tile, { appearance: 'secondary' }, 'Red').props.className).toBe(`sxkpryy sxkpryy8kgjb--appearance-secondary sxkpryy03kze--color-red`) + // appearance: secondary, compound*2 + expect(RenderOf(Tile, { appearance: 'secondary', color: 'lightBlue' }, 'Red').props.className).toBe(`sxkpryy sxkpryy8kgjb--appearance-secondary sxkpryy2xt05--color-lightBlue sxkpryyt2yhf--c2`) + + /* ❌ Restyled compound variants (default) */ + // appearance: primary, color: red, + + expect(RenderOf(RoundedTile, null, 'Blue').props.className).toBe(`sxkpryy sxkpryy8kgjb--appearance-secondary sxkpryy2xt05--color-lightBlue sxkpryyt2yhf--c2 sxhgh1l`) + + /* ❌ Restyled compound variants (explicit) */ + // appearance: secondary, compound * 2, + + expect(RenderOf(RoundedTile, { appearance: 'secondary', color: 'lightBlue' }, 'Blue').props.className).toBe(`sxkpryy sxkpryy8kgjb--appearance-secondary sxkpryy2xt05--color-lightBlue sxkpryyt2yhf--c2 sxhgh1l`) + }) +}) diff --git a/packages/react/tests/index.test.js b/packages/react/tests/react.js similarity index 100% rename from packages/react/tests/index.test.js rename to packages/react/tests/react.js diff --git a/packages/react/tests/universal-serialization.js b/packages/react/tests/universal-serialization.js index b93627fc..3955ea9a 100644 --- a/packages/react/tests/universal-serialization.js +++ b/packages/react/tests/universal-serialization.js @@ -46,6 +46,8 @@ describe('Serialization', () => { expect(myTheme.selector).toBe(myThemeSelector) }) + myComponent.render() + const sheetCssText = `${myThemeSelector}{--colors-blue:dodgerblue;}${myComponentSelector}{all:unset;font:inherit;margin:0;padding:0.5em 1em;}` test('Sheets implicitly return their cssText', () => { diff --git a/packages/react/tests/variants.js b/packages/react/tests/variants.js index 5adb179d..991a4b6a 100644 --- a/packages/react/tests/variants.js +++ b/packages/react/tests/variants.js @@ -20,16 +20,16 @@ describe('Variants', () => { }) const expression1 = component.render() - expect(expression1.props.className).toBe('sx03kze sx03kze11nwi--color-blue') + expect(expression1.props.className).toBe('sx9hpte sx9hpte11nwi--color-blue') const expression2 = component.render({ color: 'red' }) - expect(expression2.props.className).toBe('sx03kze sx03kze3ye05--color-red') + expect(expression2.props.className).toBe('sx9hpte sx9hpte3ye05--color-red') const expression3 = component.render({ color: undefined }) - expect(expression3.props.className).toBe('sx03kze sx03kze11nwi--color-blue') + expect(expression3.props.className).toBe('sx9hpte sx9hpte11nwi--color-blue') }) - test('Variant with an explicit undefined will work', () => { + test('Variant with an explicit undefined will not use default variant', () => { const { styled } = createCss() const component = styled('div', { variants: { @@ -51,12 +51,12 @@ describe('Variants', () => { }) const expression1 = component.render() - expect(expression1.props.className).toBe('sx03kze sx03kze11nwi--color-blue') + expect(expression1.props.className).toBe('sx5z3b4 sx5z3b4r02wp--color-undefined') const expression2 = component.render({ color: 'red' }) - expect(expression2.props.className).toBe('sx03kze sx03kze3ye05--color-red') + expect(expression2.props.className).toBe('sx5z3b4 sx5z3b43ye05--color-red') const expression3 = component.render({ color: undefined }) - expect(expression3.props.className).toBe('sx03kze sx03kzer02wp--color-undefined') + expect(expression3.props.className).toBe('sx5z3b4 sx5z3b4r02wp--color-undefined') }) })