diff --git a/packages/react-native-web/src/exports/ActivityIndicator/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/ActivityIndicator/__tests__/__snapshots__/index-test.js.snap index 36fcf1f2..5fe49c57 100644 --- a/packages/react-native-web/src/exports/ActivityIndicator/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/exports/ActivityIndicator/__tests__/__snapshots__/index-test.js.snap @@ -17,7 +17,24 @@ exports[`components/ActivityIndicator prop "animating" is "false" 1`] = ` Object { "animationDuration": "0.75s", "animationIterationCount": "infinite", - "animationName": "rn-ActivityIndicator-animation", + "animationName": Array [ + Object { + "0%": Object { + "transform": Array [ + Object { + "rotate": "0deg", + }, + ], + }, + "100%": Object { + "transform": Array [ + Object { + "rotate": "360deg", + }, + ], + }, + }, + ], "animationPlayState": "paused", "animationTimingFunction": "linear", "height": 20, @@ -80,7 +97,24 @@ exports[`components/ActivityIndicator prop "animating" is "true" 1`] = ` Object { "animationDuration": "0.75s", "animationIterationCount": "infinite", - "animationName": "rn-ActivityIndicator-animation", + "animationName": Array [ + Object { + "0%": Object { + "transform": Array [ + Object { + "rotate": "0deg", + }, + ], + }, + "100%": Object { + "transform": Array [ + Object { + "rotate": "360deg", + }, + ], + }, + }, + ], "animationTimingFunction": "linear", "height": 20, "width": 20, @@ -177,7 +211,24 @@ exports[`components/ActivityIndicator prop "hidesWhenStopped" is "false" 1`] = ` Object { "animationDuration": "0.75s", "animationIterationCount": "infinite", - "animationName": "rn-ActivityIndicator-animation", + "animationName": Array [ + Object { + "0%": Object { + "transform": Array [ + Object { + "rotate": "0deg", + }, + ], + }, + "100%": Object { + "transform": Array [ + Object { + "rotate": "360deg", + }, + ], + }, + }, + ], "animationPlayState": "paused", "animationTimingFunction": "linear", "height": 20, @@ -239,7 +290,24 @@ exports[`components/ActivityIndicator prop "hidesWhenStopped" is "true" 1`] = ` Object { "animationDuration": "0.75s", "animationIterationCount": "infinite", - "animationName": "rn-ActivityIndicator-animation", + "animationName": Array [ + Object { + "0%": Object { + "transform": Array [ + Object { + "rotate": "0deg", + }, + ], + }, + "100%": Object { + "transform": Array [ + Object { + "rotate": "360deg", + }, + ], + }, + }, + ], "animationPlayState": "paused", "animationTimingFunction": "linear", "height": 20, @@ -302,7 +370,24 @@ exports[`components/ActivityIndicator prop "size" is "large" 1`] = ` Object { "animationDuration": "0.75s", "animationIterationCount": "infinite", - "animationName": "rn-ActivityIndicator-animation", + "animationName": Array [ + Object { + "0%": Object { + "transform": Array [ + Object { + "rotate": "0deg", + }, + ], + }, + "100%": Object { + "transform": Array [ + Object { + "rotate": "360deg", + }, + ], + }, + }, + ], "animationTimingFunction": "linear", "height": 36, "width": 36, @@ -363,7 +448,24 @@ exports[`components/ActivityIndicator prop "size" is a number 1`] = ` Object { "animationDuration": "0.75s", "animationIterationCount": "infinite", - "animationName": "rn-ActivityIndicator-animation", + "animationName": Array [ + Object { + "0%": Object { + "transform": Array [ + Object { + "rotate": "0deg", + }, + ], + }, + "100%": Object { + "transform": Array [ + Object { + "rotate": "360deg", + }, + ], + }, + }, + ], "animationTimingFunction": "linear", "height": 30, "width": 30, diff --git a/packages/react-native-web/src/exports/ActivityIndicator/index.js b/packages/react-native-web/src/exports/ActivityIndicator/index.js index d94884c4..5d88e4b1 100644 --- a/packages/react-native-web/src/exports/ActivityIndicator/index.js +++ b/packages/react-native-web/src/exports/ActivityIndicator/index.js @@ -88,7 +88,12 @@ const styles = StyleSheet.create({ }, animation: { animationDuration: '0.75s', - animationName: 'rn-ActivityIndicator-animation', + animationName: [ + { + '0%': { transform: [{ rotate: '0deg' }] }, + '100%': { transform: [{ rotate: '360deg' }] } + } + ], animationTimingFunction: 'linear', animationIterationCount: 'infinite' }, diff --git a/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap b/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap index 13e3773a..3058da29 100644 --- a/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap +++ b/packages/react-native-web/src/exports/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap @@ -23,9 +23,7 @@ html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlig body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;} -} -@keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}} -@keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}}" +}" `; exports[`CSS for an unstyled app 1`] = ` @@ -35,8 +33,6 @@ body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;} } -@keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}} -@keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}} .rn-pointerEvents-12vffkv > *{pointer-events:auto} .rn-pointerEvents-12vffkv{pointer-events:none !important} .rn-alignItems-1oszu61{-ms-flex-align:stretch;-webkit-align-items:stretch;-webkit-box-align:stretch;align-items:stretch} diff --git a/packages/react-native-web/src/exports/ProgressBar/index.js b/packages/react-native-web/src/exports/ProgressBar/index.js index 1529c4dc..8637fdba 100644 --- a/packages/react-native-web/src/exports/ProgressBar/index.js +++ b/packages/react-native-web/src/exports/ProgressBar/index.js @@ -96,7 +96,12 @@ const styles = StyleSheet.create({ }, animation: { animationDuration: '1s', - animationName: 'rn-ProgressBar-animation', + animationName: [ + { + '0%': { transform: [{ translateX: '-100%' }] }, + '100%': { transform: [{ translateX: '400%' }] } + } + ], animationTimingFunction: 'linear', animationIterationCount: 'infinite' } diff --git a/packages/react-native-web/src/exports/StyleSheet/ReactNativeStyleResolver.js b/packages/react-native-web/src/exports/StyleSheet/ReactNativeStyleResolver.js index c891a1a7..d53378b0 100644 --- a/packages/react-native-web/src/exports/StyleSheet/ReactNativeStyleResolver.js +++ b/packages/react-native-web/src/exports/StyleSheet/ReactNativeStyleResolver.js @@ -25,6 +25,7 @@ const emptyObject = {}; export default class ReactNativeStyleResolver { _init() { this.cache = { ltr: {}, rtl: {} }; + this.injectedCache = { ltr: {}, rtl: {} }; this.styleSheetManager = new StyleSheetManager(); } @@ -43,7 +44,7 @@ export default class ReactNativeStyleResolver { _injectRegisteredStyle(id) { const dir = I18nManager.isRTL ? 'rtl' : 'ltr'; - if (!this.cache[dir][id]) { + if (!this.injectedCache[dir][id]) { const style = flattenStyle(id); const domStyle = createReactDOMStyle(i18nStyle(style)); Object.keys(domStyle).forEach(styleProp => { @@ -52,7 +53,7 @@ export default class ReactNativeStyleResolver { this.styleSheetManager.injectDeclaration(styleProp, value); } }); - this.cache[dir][id] = true; + this.injectedCache[dir][id] = true; } } @@ -160,7 +161,11 @@ export default class ReactNativeStyleResolver { // Certain properties and values are not transformed by 'createReactDOMStyle' as they // require more complex transforms into multiple CSS rules. Here we assume that StyleManager // can bind these styles to a className, and prevent them becoming invalid inline-styles. - if (styleProp === 'pointerEvents' || styleProp === 'placeholderTextColor') { + if ( + styleProp === 'pointerEvents' || + styleProp === 'placeholderTextColor' || + styleProp === 'animationName' + ) { const className = this.styleSheetManager.injectDeclaration(styleProp, value); if (className) { props.classList.push(className); diff --git a/packages/react-native-web/src/exports/StyleSheet/StyleSheetManager.js b/packages/react-native-web/src/exports/StyleSheet/StyleSheetManager.js index 91f08edb..5afa4659 100644 --- a/packages/react-native-web/src/exports/StyleSheet/StyleSheetManager.js +++ b/packages/react-native-web/src/exports/StyleSheet/StyleSheetManager.js @@ -17,10 +17,12 @@ const emptyObject = {}; const STYLE_ELEMENT_ID = 'react-native-stylesheet'; const createClassName = (prop, value) => { - const hashed = hash(prop + value); + const hashed = hash(prop + normalizeValue(value)); return process.env.NODE_ENV !== 'production' ? `rn-${prop}-${hashed}` : `rn-${hashed}`; }; +const normalizeValue = value => (typeof value === 'object' ? JSON.stringify(value) : value); + export default class StyleSheetManager { _cache = { byClassName: {}, @@ -35,8 +37,9 @@ export default class StyleSheetManager { } getClassName(prop, value) { + const val = normalizeValue(value); const cache = this._cache.byProp; - return cache[prop] && cache[prop].hasOwnProperty(value) && cache[prop][value]; + return cache[prop] && cache[prop].hasOwnProperty(val) && cache[prop][val]; } getDeclaration(className) { @@ -54,10 +57,11 @@ export default class StyleSheetManager { } injectDeclaration(prop, value): string { - let className = this.getClassName(prop, value); + const val = normalizeValue(value); + let className = this.getClassName(prop, val); if (!className) { - className = createClassName(prop, value); - this._addToCache(className, prop, value); + className = createClassName(prop, val); + this._addToCache(className, prop, val); const rules = createAtomicRules(`.${className}`, prop, value); rules.forEach(rule => { this._sheet.insertRuleOnce(rule); @@ -66,6 +70,10 @@ export default class StyleSheetManager { return className; } + injectKeyframe(): string { + // return identifier; + } + _addToCache(className, prop, value) { const cache = this._cache; if (!cache.byProp[prop]) { diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/StyleSheetManager-test.js.snap b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/StyleSheetManager-test.js.snap index b346a3ed..3142a035 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/StyleSheetManager-test.js.snap +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/StyleSheetManager-test.js.snap @@ -11,8 +11,6 @@ body{margin:0;} button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;} } -@keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}} -@keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}} .rn---test-property-ax3bxi{--test-property:test-value}", } `; diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/createAtomicRules.js.snap b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/createAtomicRules.js.snap index 06bca7c9..a9c46293 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/createAtomicRules.js.snap +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/__snapshots__/createAtomicRules.js.snap @@ -1,17 +1,22 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StyleSheet/createAtomicRules transforms custom placeholderTextColor declaration 1`] = ` +exports[`StyleSheet/createAtomicRules transforms custom "animationName" declaration 1`] = ` Array [ - "@media all { -.test::-webkit-input-placeholder{color:gray;opacity:1} -.test::-moz-placeholder{color:gray;opacity:1} -.test:-ms-input-placeholder{color:gray;opacity:1} -.test::placeholder{color:gray;opacity:1} -}", + "@media all {@-webkit-keyframes rn-anim-2k74q5{0%{top:0px}50%{top:5px}100%{top:10px}}}", + "@media all {@keyframes rn-anim-2k74q5{0%{top:0px}50%{top:5px}100%{top:10px}}}", + "@media all {@-webkit-keyframes rn-anim-zc91cv{from{left:0px}to{left:10px}}}", + "@media all {@keyframes rn-anim-zc91cv{from{left:0px}to{left:10px}}}", + ".test{-webkit-animation-name:rn-anim-2k74q5,rn-anim-zc91cv;animation-name:rn-anim-2k74q5,rn-anim-zc91cv}", ] `; -exports[`StyleSheet/createAtomicRules transforms custom pointerEvents declaration 1`] = ` +exports[`StyleSheet/createAtomicRules transforms custom "placeholderTextColor" declaration 1`] = ` +Array [ + "@media all {.test::-webkit-input-placeholder{color:gray;opacity:1}.test::-moz-placeholder{color:gray;opacity:1}.test:-ms-input-placeholder{color:gray;opacity:1}.test::placeholder{color:gray;opacity:1}}", +] +`; + +exports[`StyleSheet/createAtomicRules transforms custom "pointerEvents" declaration 1`] = ` Array [ ".test > *{pointer-events:none}", ".test{pointer-events:auto !important}", diff --git a/packages/react-native-web/src/exports/StyleSheet/__tests__/createAtomicRules.js b/packages/react-native-web/src/exports/StyleSheet/__tests__/createAtomicRules.js index 9f381c7e..2cce2e90 100644 --- a/packages/react-native-web/src/exports/StyleSheet/__tests__/createAtomicRules.js +++ b/packages/react-native-web/src/exports/StyleSheet/__tests__/createAtomicRules.js @@ -7,11 +7,19 @@ describe('StyleSheet/createAtomicRules', () => { expect(createAtomicRules('.test', 'margin', 0)).toMatchSnapshot(); }); - test('transforms custom pointerEvents declaration', () => { + test('transforms custom "animationName" declaration', () => { + const value = [ + { '0%': { top: 0 }, '50%': { top: 5 }, '100%': { top: 10 } }, + { from: { left: 0 }, to: { left: 10 } } + ]; + expect(createAtomicRules('.test', 'animationName', value)).toMatchSnapshot(); + }); + + test('transforms custom "pointerEvents" declaration', () => { expect(createAtomicRules('.test', 'pointerEvents', 'box-only')).toMatchSnapshot(); }); - test('transforms custom placeholderTextColor declaration', () => { + test('transforms custom "placeholderTextColor" declaration', () => { expect(createAtomicRules('.test', 'placeholderTextColor', 'gray')).toMatchSnapshot(); }); }); diff --git a/packages/react-native-web/src/exports/StyleSheet/createAtomicRules.js b/packages/react-native-web/src/exports/StyleSheet/createAtomicRules.js index 781d73ce..da2a0ceb 100644 --- a/packages/react-native-web/src/exports/StyleSheet/createAtomicRules.js +++ b/packages/react-native-web/src/exports/StyleSheet/createAtomicRules.js @@ -1,10 +1,12 @@ +import createKeyframesRules from './createKeyframesRules'; import createRuleBlock from './createRuleBlock'; const createAtomicRules = (selector, prop, value) => { const rules = []; + // Handle custom properties and custom values that require additional rules + // to be created. switch (prop) { - // pointerEvents is a special case that requires custom values and additional rules // See #513 case 'pointerEvents': { let val = value; @@ -28,12 +30,43 @@ const createAtomicRules = (selector, prop, value) => { case 'placeholderTextColor': { const block = createRuleBlock({ color: value, opacity: 1 }); - rules.push(`@media all { -${selector}::-webkit-input-placeholder{${block}} -${selector}::-moz-placeholder{${block}} -${selector}:-ms-input-placeholder{${block}} -${selector}::placeholder{${block}} -}`); + rules.push( + '@media all {' + + `${selector}::-webkit-input-placeholder{${block}}` + + `${selector}::-moz-placeholder{${block}}` + + `${selector}:-ms-input-placeholder{${block}}` + + `${selector}::placeholder{${block}}` + + '}' + ); + break; + } + + case 'animationName': { + if (typeof value === 'string') { + // add a className referencing the animation + const block = createRuleBlock({ [prop]: value }); + rules.push(`${selector}{${block}}`); + } else { + const animationNames = []; + + // add the keyframes needed to implement each value + value.forEach(keyframes => { + if (typeof keyframes === 'string') { + animationNames.push(keyframes); + } else { + const { identifier, rules: keyframesRules } = createKeyframesRules(keyframes); + keyframesRules.forEach(rule => { + rules.push(rule); + }); + animationNames.push(identifier); + } + }); + + // add a className referencing the animation identifiers + const block = createRuleBlock({ [prop]: animationNames.join(',') }); + rules.push(`${selector}{${block}}`); + } + break; } diff --git a/packages/react-native-web/src/exports/StyleSheet/createKeyframesRules.js b/packages/react-native-web/src/exports/StyleSheet/createKeyframesRules.js new file mode 100644 index 00000000..3445d34a --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/createKeyframesRules.js @@ -0,0 +1,37 @@ +import createRuleBlock from './createRuleBlock'; +import createReactDOMStyle from './createReactDOMStyle'; +import i18nStyle from './i18nStyle'; +import hash from '../../vendor/hash'; + +const hashObject = obj => hash(JSON.stringify(obj)); + +const createIdentifier = obj => { + const hashed = hashObject(obj); + return process.env.NODE_ENV !== 'production' ? `rn-anim-${hashed}` : `rn-${hashed}`; +}; + +const prefixes = ['-webkit-', '']; + +const makeBlock = rule => { + const domStyle = createReactDOMStyle(i18nStyle(rule)); + return createRuleBlock(domStyle); +}; + +const makeSteps = keyframes => + Object.keys(keyframes) + .map(stepName => { + const rule = keyframes[stepName]; + const block = makeBlock(rule); + return `${stepName}{${block}}`; + }) + .join(''); + +const createKeyframesRules = (keyframes: Object): Array => { + const identifier = createIdentifier(keyframes); + const rules = prefixes.map(prefix => { + return `@media all {@${prefix}keyframes ${identifier}{${makeSteps(keyframes)}}}`; + }); + return { identifier, rules }; +}; + +export default createKeyframesRules; diff --git a/packages/react-native-web/src/exports/StyleSheet/createRuleBlock.js b/packages/react-native-web/src/exports/StyleSheet/createRuleBlock.js index ee81442b..92b6457b 100644 --- a/packages/react-native-web/src/exports/StyleSheet/createRuleBlock.js +++ b/packages/react-native-web/src/exports/StyleSheet/createRuleBlock.js @@ -25,12 +25,12 @@ const createDeclarationString = (prop, val) => { /** * Generates valid CSS rule body from a JS object * - * generateCss({ width: 20, color: 'blue' }); + * createRuleBlock({ width: 20, color: 'blue' }); * // => 'color:blue;width:20px' */ -const generateCss = style => +const createRuleBlock = style => mapKeyValue(prefixStyles(style), createDeclarationString) .sort() .join(';'); -export default generateCss; +export default createRuleBlock; diff --git a/packages/react-native-web/src/exports/StyleSheet/initialRules.js b/packages/react-native-web/src/exports/StyleSheet/initialRules.js index 9c282308..100245bd 100644 --- a/packages/react-native-web/src/exports/StyleSheet/initialRules.js +++ b/packages/react-native-web/src/exports/StyleSheet/initialRules.js @@ -22,19 +22,6 @@ const resets = [ 'input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}' ]; -const reset = safeRule(resets.join('\n')); +const reset = [safeRule(resets.join('\n'))]; -const initialRules = [ - reset, - // temporary keyframes - '@keyframes rn-ActivityIndicator-animation{' + - '0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}' + - '100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}' + - '}', - '@keyframes rn-ProgressBar-animation{' + - '0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}' + - '100%{-webkit-transform:translateX(400%);transform:translateX(400%);}' + - '}' -]; - -export default initialRules; +export default reset; diff --git a/packages/react-native-web/src/modules/AnimationPropTypes/index.js b/packages/react-native-web/src/modules/AnimationPropTypes/index.js index 2925be66..d4285230 100644 --- a/packages/react-native-web/src/modules/AnimationPropTypes/index.js +++ b/packages/react-native-web/src/modules/AnimationPropTypes/index.js @@ -8,7 +8,7 @@ * @flow */ -import { number, oneOf, oneOfType, string } from 'prop-types'; +import { arrayOf, number, object, oneOf, oneOfType, string } from 'prop-types'; const AnimationPropTypes = { animationDelay: string, @@ -16,7 +16,7 @@ const AnimationPropTypes = { animationDuration: string, animationFillMode: oneOf(['none', 'forwards', 'backwards', 'both']), animationIterationCount: oneOfType([number, oneOf(['infinite'])]), - animationName: string, + animationName: oneOfType([string, arrayOf(oneOfType([string, object]))]), animationPlayState: oneOf(['paused', 'running']), animationTimingFunction: string }; diff --git a/packages/react-native-web/src/vendor/hash/index.js b/packages/react-native-web/src/vendor/hash/index.js index ca72a791..bfec7445 100644 --- a/packages/react-native-web/src/vendor/hash/index.js +++ b/packages/react-native-web/src/vendor/hash/index.js @@ -54,4 +54,5 @@ function murmurhash2_32_gc(str, seed) { } const hash = str => murmurhash2_32_gc(str, 1).toString(36); + export default hash; diff --git a/website/storybook/1-components/View/ViewScreen.js b/website/storybook/1-components/View/ViewScreen.js index 6cdcafae..0308e645 100644 --- a/website/storybook/1-components/View/ViewScreen.js +++ b/website/storybook/1-components/View/ViewScreen.js @@ -334,7 +334,7 @@ const stylePropTypes = [ { label: 'web', name: 'animationName', - typeInfo: 'string' + typeInfo: 'string | Array' }, { label: 'web', @@ -776,7 +776,7 @@ const stylePropTypes = [ }, { name: 'transform', - typeInfo: 'array' + typeInfo: 'Array' }, { label: 'web',