From cd71541578a670058f8aab707de331cb5be782a1 Mon Sep 17 00:00:00 2001 From: Trond Bergquist Date: Tue, 10 Nov 2020 09:57:15 +0100 Subject: [PATCH] sx: Add keyframes support This commit adds a keyframes map to StyleCollector and exports a new keyframes function. The keyframes function parses the animation object and generates a hashed name based on the input. Then it passes the generated style and hash to the StyleCollector. The StyleCollector reports back if the animation already exists. If it does not exists and we are in the browser, the function appends the animation to the SX stylesheet definitions. --- src/sx/README.md | 36 +++++++ src/sx/__flowtests__/css-properties.js | 2 +- src/sx/index.js | 3 +- src/sx/package.json | 1 + src/sx/src/StyleCollector.js | 13 +++ src/sx/src/__tests__/StyleCollector.test.js | 10 ++ src/sx/src/__tests__/SxDomTests.test.js | 27 ++++++ .../__snapshots__/fixtures.test.js.snap | 59 ++++++++++++ .../__tests__/fixtures/keyframes-from-to.js | 19 ++++ .../fixtures/keyframes-percentage.js | 22 +++++ src/sx/src/__tests__/keyframes.test.js | 94 +++++++++++++++++++ src/sx/src/create.js | 9 +- src/sx/src/injectRuntimeStyles.js | 59 ++++++++---- src/sx/src/keyframes.js | 55 +++++++++++ yarn.lock | 12 +++ 15 files changed, 394 insertions(+), 27 deletions(-) create mode 100644 src/sx/src/__tests__/fixtures/keyframes-from-to.js create mode 100644 src/sx/src/__tests__/fixtures/keyframes-percentage.js create mode 100644 src/sx/src/__tests__/keyframes.test.js create mode 100644 src/sx/src/keyframes.js diff --git a/src/sx/README.md b/src/sx/README.md index fdcdf89480..587c48bc09 100644 --- a/src/sx/README.md +++ b/src/sx/README.md @@ -5,6 +5,7 @@ In conventional applications, CSS rules are duplicated throughout the stylesheet - [Multiple stylesheets precedence](#multiple-stylesheets-precedence) - [Pseudo CSS classes and elements](#pseudo-css-classes-and-elements) - [`@media` and `@supports`](#media-and-supports) + - [Keyframes](#keyframes) - [Precise Flow types](#precise-flow-types) - [Production usage considerations](#production-usage-considerations) - [Server-side rendering](#server-side-rendering) @@ -227,6 +228,41 @@ const styles = sx.create({ }); ``` +### Keyframes + +SX also has support for keyframes, it exports a function that generates an animation name from the input you give it. You can use it like this: + +```jsx +export function AnimatedComponent() { + return
text
; +} + +const fadeIn = sx.keyframes({ + '0%': { + opacity: 0, + }, + '50%, 55%': { + opacity: 0.3, + }, + '100%': { + opacity: 1, + }, +}); + +const styles = sx.create({ + text: { + animationName: fadeIn, + animationDuration: '2s', + }, +}); +``` + +It also supports `from` and `to` for simpler animations. + +```js +const simple = sx.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }); +``` + ### Precise Flow types SX knows about almost every property or rule which exists in CSS and tries to help with mistakes when writing the styles. diff --git a/src/sx/__flowtests__/css-properties.js b/src/sx/__flowtests__/css-properties.js index cb926a38ff..142fc772ae 100644 --- a/src/sx/__flowtests__/css-properties.js +++ b/src/sx/__flowtests__/css-properties.js @@ -54,8 +54,8 @@ sx.create({ unknownProperty: 'red', }, UnknownPropertyInsideMedia: { - // $FlowExpectedError[incompatible-call] '@media print': { + // $FlowExpectedError[incompatible-call] unknownProperty: 'red', }, }, diff --git a/src/sx/index.js b/src/sx/index.js index 3d895e9498..351aedccb6 100644 --- a/src/sx/index.js +++ b/src/sx/index.js @@ -1,6 +1,7 @@ // @flow import create from './src/create'; +import keyframes from './src/keyframes'; import renderPageWithSX from './src/renderPageWithSX'; -export { create, renderPageWithSX }; +export { create, keyframes, renderPageWithSX }; diff --git a/src/sx/package.json b/src/sx/package.json index c55a02d80d..ba259af070 100644 --- a/src/sx/package.json +++ b/src/sx/package.json @@ -17,6 +17,7 @@ "change-case": "^4.1.1", "css-tree": "^1.0.1", "fast-levenshtein": "^3.0.0", + "json-stable-stringify": "^1.0.1", "mdn-data": "^2.0.14", "prettier": "^2.1.2" }, diff --git a/src/sx/src/StyleCollector.js b/src/sx/src/StyleCollector.js index 6474f77283..413a7469d2 100644 --- a/src/sx/src/StyleCollector.js +++ b/src/sx/src/StyleCollector.js @@ -39,6 +39,7 @@ export type StyleBufferType = Map; class StyleCollector { #styleBuffer: StyleBufferType = new Map(); + #keyframes: Map = new Map(); collect(baseStyleSheet: {| +[sheetName: string]: $FlowFixMe, @@ -109,11 +110,23 @@ class StyleCollector { this.#styleBuffer.forEach((node) => { sxStyle += node.printNodes().join(''); }); + this.#keyframes.forEach((node) => { + sxStyle += node; + }); return sxStyle; } + addKeyframe(name: string, value: string): boolean { + if (this.#keyframes.has(name)) { + return true; + } + this.#keyframes.set(name, value); + return false; + } + reset(): void { this.#styleBuffer.clear(); + this.#keyframes.clear(); } } diff --git a/src/sx/src/__tests__/StyleCollector.test.js b/src/sx/src/__tests__/StyleCollector.test.js index 7bf8291aa4..a51a1a69f0 100644 --- a/src/sx/src/__tests__/StyleCollector.test.js +++ b/src/sx/src/__tests__/StyleCollector.test.js @@ -158,3 +158,13 @@ it('works with mediaQueries', () => { } `); }); + +it('works with keyframes', () => { + expect( + StyleCollector.addKeyframe('_1kFWtB', `@keyframes _1kFWtB {from {opacity: 0}to {opacity:1}}`), + ).toBe(false); + + expect( + StyleCollector.addKeyframe('_1kFWtB', `@keyframes _1kFWtB {from {opacity: 0}to {opacity:1}}`), + ).toBe(true); +}); diff --git a/src/sx/src/__tests__/SxDomTests.test.js b/src/sx/src/__tests__/SxDomTests.test.js index d010518dc1..f1ff82b33a 100644 --- a/src/sx/src/__tests__/SxDomTests.test.js +++ b/src/sx/src/__tests__/SxDomTests.test.js @@ -169,3 +169,30 @@ it('handles background:none specificity correctly', () => { const test2 = screen.getByText('test_2'); expect(test2).toHaveStyle(`background-color:${normalizeColor('blue')}`); }); + +it('works with keyframes', () => { + const animation = sx.keyframes({ + from: { opacity: 0 }, + to: { opacity: 1 }, + }); + const styles = sx.create({ + myClass: { + animationName: animation, + animationDuration: '2s', + }, + }); + + const { container } = render( + <> + {sx.renderPageWithSX(jest.fn()).styles} +
test_1
+ , + ); + expect(container.querySelector('[data-adeira-sx]')).toMatchInlineSnapshot(` + + `); +}); diff --git a/src/sx/src/__tests__/__snapshots__/fixtures.test.js.snap b/src/sx/src/__tests__/__snapshots__/fixtures.test.js.snap index 28178d5b37..5f59c0b823 100644 --- a/src/sx/src/__tests__/__snapshots__/fixtures.test.js.snap +++ b/src/sx/src/__tests__/__snapshots__/fixtures.test.js.snap @@ -111,6 +111,65 @@ exports[`matches expected output: @unknown.js 1`] = ` Invariant Violation: Unsupported rule "@unknown" `; +exports[`matches expected output: keyframes-from-to.js 1`] = ` +~~~~~~~~~~ INPUT ~~~~~~~~~~ +{ + "aaa": { + "animationName": "_2rMlJa" + } +} +~~~~~~~~~~ OUTPUT ~~~~~~~~~~ +.P4y5l { + animation-name: _2rMlJa; +} +@keyframes _2rMlJa { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +~~~~~~~~~~ USAGE ~~~~~~~~~~ + +className={styles('aaa')} + ↓ ↓ ↓ +class="P4y5l" + +`; + +exports[`matches expected output: keyframes-percentage.js 1`] = ` +~~~~~~~~~~ INPUT ~~~~~~~~~~ +{ + "aaa": { + "animationName": "gg1lu" + } +} +~~~~~~~~~~ OUTPUT ~~~~~~~~~~ +._4BANPA { + animation-name: gg1lu; +} +@keyframes gg1lu { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + 25% { + opacity: 0.7; + } +} + +~~~~~~~~~~ USAGE ~~~~~~~~~~ + +className={styles('aaa')} + ↓ ↓ ↓ +class="_4BANPA" + +`; + exports[`matches expected output: media-queries.js 1`] = ` ~~~~~~~~~~ INPUT ~~~~~~~~~~ { diff --git a/src/sx/src/__tests__/fixtures/keyframes-from-to.js b/src/sx/src/__tests__/fixtures/keyframes-from-to.js new file mode 100644 index 0000000000..64c3cbdeba --- /dev/null +++ b/src/sx/src/__tests__/fixtures/keyframes-from-to.js @@ -0,0 +1,19 @@ +// @flow + +import type { SheetDefinitions } from '../../create'; +import keyframes from '../../keyframes'; + +const animation = keyframes({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, +}); + +export default ({ + aaa: { + animationName: animation, + }, +}: SheetDefinitions); diff --git a/src/sx/src/__tests__/fixtures/keyframes-percentage.js b/src/sx/src/__tests__/fixtures/keyframes-percentage.js new file mode 100644 index 0000000000..510be3d554 --- /dev/null +++ b/src/sx/src/__tests__/fixtures/keyframes-percentage.js @@ -0,0 +1,22 @@ +// @flow + +import type { SheetDefinitions } from '../../create'; +import keyframes from '../../keyframes'; + +const animation = keyframes({ + '0%': { + opacity: 0, + }, + '25%': { + opacity: 0.7, + }, + '100%': { + opacity: 1, + }, +}); + +export default ({ + aaa: { + animationName: animation, + }, +}: SheetDefinitions); diff --git a/src/sx/src/__tests__/keyframes.test.js b/src/sx/src/__tests__/keyframes.test.js new file mode 100644 index 0000000000..9717553406 --- /dev/null +++ b/src/sx/src/__tests__/keyframes.test.js @@ -0,0 +1,94 @@ +// @flow + +import keyframes from '../keyframes'; +import styleCollector from '../StyleCollector'; + +it('returns hashed name of the keyframe', () => { + const spy = jest.spyOn(styleCollector, 'addKeyframe'); + const hashedName = keyframes({ + from: { + opacity: 0, + transform: 'translateX(-300px)', + }, + to: { + opacity: 1, + transform: 'translateX(0)', + }, + }); + expect(spy).toHaveBeenCalledWith( + hashedName, + '@keyframes wOIjT {from {opacity:0;transform:translateX(-300px);}to {opacity:1;transform:translateX(0);}}', + ); + const hashedName2 = keyframes({ + from: { + transform: 'translateX(-300px)', + }, + to: { + transform: 'translateX(0)', + }, + }); + expect(spy).toHaveBeenNthCalledWith( + 2, + hashedName2, + '@keyframes _1kFWtB {from {transform:translateX(-300px);}to {transform:translateX(0);}}', + ); + expect(hashedName).toMatchInlineSnapshot(`"wOIjT"`); + expect(hashedName2).toMatchInlineSnapshot(`"_1kFWtB"`); + spy.mockReset(); +}); + +it('works with percentages', () => { + const spy = jest.spyOn(styleCollector, 'addKeyframe'); + + const hashedName = keyframes({ + '0%': { + transform: 'translateX(-300px)', + }, + '75%, 80%': { + transform: 'translateX(50px)', + }, + '100%': { + transform: 'translateX(0)', + }, + }); + expect(spy).toHaveBeenCalledWith( + hashedName, + '@keyframes _3B4dOq {0% {transform:translateX(-300px);}100% {transform:translateX(0);}75%,80% {transform:translateX(50px);}}', + ); + spy.mockReset(); +}); + +it('generates same hash for similar object', () => { + const hashedName = keyframes({ + from: { + opacity: 0, + transform: 'translateX(-300px)', + }, + to: { + opacity: 1, + transform: 'translateX(0)', + }, + }); + const hashedName2 = keyframes({ + from: { + opacity: 0, + transform: 'translateX(-300px)', + }, + to: { + opacity: 1, + transform: 'translateX(0)', + }, + }); + const hashedName3 = keyframes({ + from: { + transform: 'translateX(-300px)', + opacity: 0, + }, + to: { + transform: 'translateX(0)', + opacity: 1, + }, + }); + expect(hashedName).toEqual(hashedName2); + expect(hashedName2).toEqual(hashedName3); +}); diff --git a/src/sx/src/create.js b/src/sx/src/create.js index d82025fb1d..9ce2c3919e 100644 --- a/src/sx/src/create.js +++ b/src/sx/src/create.js @@ -15,15 +15,8 @@ type MediaQueries = {| +[string]: MediaQueries, // media queries can be recursively nested |}; -// https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes -type KeyFrames = {| - +from: AllCSSPropertyTypes, - +to: AllCSSPropertyTypes, - +[number]: AllCSSPropertyTypes, // percentages -|}; - // https://developer.mozilla.org/en-US/docs/Web/CSS/At-rule -type AtRules = MediaQueries | KeyFrames; +type AtRules = MediaQueries; type AllCSSProperties = {| ...AllCSSPropertyTypes, diff --git a/src/sx/src/injectRuntimeStyles.js b/src/sx/src/injectRuntimeStyles.js index 851a9dba4b..06a88a6400 100644 --- a/src/sx/src/injectRuntimeStyles.js +++ b/src/sx/src/injectRuntimeStyles.js @@ -13,8 +13,7 @@ import type { StyleBufferType } from './StyleCollector'; opaque type StyleElementType = HTMLStyleElement | null; let styleAdeiraSXTag: StyleElementType = null; -// https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model -export default function injectRuntimeStyles(styleBuffer: StyleBufferType) { +const getStyleTag = (): CSSStyleSheet => { if (styleAdeiraSXTag === null) { styleAdeiraSXTag = ((document.querySelector('style[data-adeira-sx]'): any): StyleElementType); if (styleAdeiraSXTag === null) { @@ -25,36 +24,62 @@ export default function injectRuntimeStyles(styleBuffer: StyleBufferType) { styleAdeiraSXTag.setAttribute('data-adeira-sx', ''); htmlHead?.appendChild(styleAdeiraSXTag); } - - invariant( - styleAdeiraSXTag != null, - 'SX cannot render any styles because