diff --git a/__tests__/parseAnimationValue.test.js b/__tests__/parseAnimationValue.test.js new file mode 100644 index 000000000000..200a77323cdb --- /dev/null +++ b/__tests__/parseAnimationValue.test.js @@ -0,0 +1,228 @@ +import parseAnimationValue from '../src/util/parseAnimationValue' +import { produce } from './util/produce' + +describe('Tailwind Defaults', () => { + it.each([ + [ + 'spin 1s linear infinite', + { name: 'spin', duration: '1s', timingFunction: 'linear', iterationCount: 'infinite' }, + ], + [ + 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', + { + name: 'ping', + duration: '1s', + timingFunction: 'cubic-bezier(0, 0, 0.2, 1)', + iterationCount: 'infinite', + }, + ], + [ + 'pulse 2s cubic-bezier(0.4, 0, 0.6) infinite', + { + name: 'pulse', + duration: '2s', + timingFunction: 'cubic-bezier(0.4, 0, 0.6)', + iterationCount: 'infinite', + }, + ], + ['bounce 1s infinite', { name: 'bounce', duration: '1s', iterationCount: 'infinite' }], + ])('should be possible to parse: "%s"', (input, expected) => { + expect(parseAnimationValue(input)).toEqual(expected) + }) +}) + +describe('MDN Examples', () => { + it.each([ + [ + '3s ease-in 1s 2 reverse both paused slidein', + { + delay: '1s', + direction: 'reverse', + duration: '3s', + fillMode: 'both', + iterationCount: '2', + name: 'slidein', + playState: 'paused', + timingFunction: 'ease-in', + }, + ], + [ + 'slidein 3s linear 1s', + { delay: '1s', duration: '3s', name: 'slidein', timingFunction: 'linear' }, + ], + ['slidein 3s', { duration: '3s', name: 'slidein' }], + ])('should be possible to parse: "%s"', (input, expected) => { + expect(parseAnimationValue(input)).toEqual(expected) + }) +}) + +describe('duration & delay', () => { + it.each([ + // Positive seconds (integer) + ['spin 1s 1s linear', { duration: '1s', delay: '1s' }], + ['spin 2s 1s linear', { duration: '2s', delay: '1s' }], + ['spin 1s 2s linear', { duration: '1s', delay: '2s' }], + + // Negative seconds (integer) + ['spin -1s -1s linear', { duration: '-1s', delay: '-1s' }], + ['spin -2s -1s linear', { duration: '-2s', delay: '-1s' }], + ['spin -1s -2s linear', { duration: '-1s', delay: '-2s' }], + + // Positive seconds (float) + ['spin 1.321s 1.321s linear', { duration: '1.321s', delay: '1.321s' }], + ['spin 2.321s 1.321s linear', { duration: '2.321s', delay: '1.321s' }], + ['spin 1.321s 2.321s linear', { duration: '1.321s', delay: '2.321s' }], + + // Negative seconds (float) + ['spin -1.321s -1.321s linear', { duration: '-1.321s', delay: '-1.321s' }], + ['spin -2.321s -1.321s linear', { duration: '-2.321s', delay: '-1.321s' }], + ['spin -1.321s -2.321s linear', { duration: '-1.321s', delay: '-2.321s' }], + + // Positive milliseconds (integer) + ['spin 100ms 100ms linear', { duration: '100ms', delay: '100ms' }], + ['spin 200ms 100ms linear', { duration: '200ms', delay: '100ms' }], + ['spin 100ms 200ms linear', { duration: '100ms', delay: '200ms' }], + + // Negative milliseconds (integer) + ['spin -100ms -100ms linear', { duration: '-100ms', delay: '-100ms' }], + ['spin -200ms -100ms linear', { duration: '-200ms', delay: '-100ms' }], + ['spin -100ms -200ms linear', { duration: '-100ms', delay: '-200ms' }], + + // Positive milliseconds (float) + ['spin 100.321ms 100.321ms linear', { duration: '100.321ms', delay: '100.321ms' }], + ['spin 200.321ms 100.321ms linear', { duration: '200.321ms', delay: '100.321ms' }], + ['spin 100.321ms 200.321ms linear', { duration: '100.321ms', delay: '200.321ms' }], + + // Negative milliseconds (float) + ['spin -100.321ms -100.321ms linear', { duration: '-100.321ms', delay: '-100.321ms' }], + ['spin -200.321ms -100.321ms linear', { duration: '-200.321ms', delay: '-100.321ms' }], + ['spin -100.321ms -200.321ms linear', { duration: '-100.321ms', delay: '-200.321ms' }], + ])('should be possible to parse "%s" into %o', (input, { duration, delay }) => { + const parsed = parseAnimationValue(input) + expect(parsed.duration).toEqual(duration) + expect(parsed.delay).toEqual(delay) + }) +}) + +describe('iteration count', () => { + it.each([ + // Number + ['1 spin 200s 100s linear', '1'], + ['spin 2 200s 100s linear', '2'], + ['spin 200s 3 100s linear', '3'], + ['spin 200s 100s 4 linear', '4'], + ['spin 200s 100s linear 5', '5'], + + // Infinite + ['infinite spin 200s 100s linear', 'infinite'], + ['spin infinite 200s 100s linear', 'infinite'], + ['spin 200s infinite 100s linear', 'infinite'], + ['spin 200s 100s infinite linear', 'infinite'], + ['spin 200s 100s linear infinite', 'infinite'], + ])( + 'should be possible to parse "%s" with an iteraction count of "%s"', + (input, iterationCount) => { + expect(parseAnimationValue(input).iterationCount).toEqual(iterationCount) + } + ) +}) + +describe('iteration count', () => { + it.each([ + // Number + ['1 spin 200s 100s linear', '1'], + ['spin 2 200s 100s linear', '2'], + ['spin 200s 3 100s linear', '3'], + ['spin 200s 100s 4 linear', '4'], + ['spin 200s 100s linear 5', '5'], + ['100 spin 200s 100s linear', '100'], + ['spin 200 200s 100s linear', '200'], + ['spin 200s 300 100s linear', '300'], + ['spin 200s 100s 400 linear', '400'], + ['spin 200s 100s linear 500', '500'], + + // Infinite + ['infinite spin 200s 100s linear', 'infinite'], + ['spin infinite 200s 100s linear', 'infinite'], + ['spin 200s infinite 100s linear', 'infinite'], + ['spin 200s 100s infinite linear', 'infinite'], + ['spin 200s 100s linear infinite', 'infinite'], + ])( + 'should be possible to parse "%s" with an iteraction count of "%s"', + (input, iterationCount) => { + expect(parseAnimationValue(input).iterationCount).toEqual(iterationCount) + } + ) +}) + +describe('multiple animations', () => { + it('should be possible to parse multiple applications at once', () => { + const input = [ + 'spin 1s linear infinite', + 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', + 'pulse 2s cubic-bezier(0.4, 0, 0.6) infinite', + ].join(',') + + const parsed = parseAnimationValue(input) + expect(parsed).toHaveLength(3) + expect(parsed).toEqual([ + { name: 'spin', duration: '1s', timingFunction: 'linear', iterationCount: 'infinite' }, + { + name: 'ping', + duration: '1s', + timingFunction: 'cubic-bezier(0, 0, 0.2, 1)', + iterationCount: 'infinite', + }, + { + name: 'pulse', + duration: '2s', + timingFunction: 'cubic-bezier(0.4, 0, 0.6)', + iterationCount: 'infinite', + }, + ]) + }) +}) + +describe('randomized crazy big examples', () => { + function reOrder(input, offset = 0) { + return [...input.slice(offset), ...input.slice(0, offset)] + } + + it.each( + produce((choose) => { + const direction = choose('normal', 'reverse', 'alternate', 'alternate-reverse') + const playState = choose('running', 'paused') + const fillMode = choose('none', 'forwards', 'backwards', 'both') + const iterationCount = choose('infinite', '1', '100') + const timingFunction = choose( + 'linear', + 'ease', + 'ease-in', + 'ease-out', + 'ease-in-out', + 'cubic-bezier(0, 0, 0.2, 1)', + 'steps(4, end)' + ) + const name = choose('animation-name-a', 'animation-name-b') + const inputArgs = [direction, playState, fillMode, iterationCount, timingFunction, name] + const orderOffset = choose(...Array(inputArgs.length).keys()) + + return [ + // Input + reOrder(inputArgs, orderOffset).join(' '), + + // Output + { + direction, + playState, + fillMode, + iterationCount, + timingFunction, + name, + }, + ] + }) + )('should be possible to parse "%s"', (input, output) => { + expect(parseAnimationValue(input)).toEqual(output) + }) +}) diff --git a/__tests__/plugins/animation.test.js b/__tests__/plugins/animation.test.js new file mode 100644 index 000000000000..19409d52f535 --- /dev/null +++ b/__tests__/plugins/animation.test.js @@ -0,0 +1,92 @@ +import postcss from 'postcss' +import processPlugins from '../../src/util/processPlugins' +import plugin from '../../src/plugins/animation' + +function css(nodes) { + return postcss.root({ nodes }).toString() +} + +test('defining animation and keyframes', () => { + const config = { + theme: { + animation: { + none: 'none', + spin: 'spin 1s linear infinite', + ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', + }, + keyframes: { + spin: { to: { transform: 'rotate(360deg)' } }, + ping: { '75%, 100%': { transform: 'scale(2)', opacity: '0' } }, + }, + }, + variants: { + animation: [], + }, + } + + const { utilities } = processPlugins([plugin()], config) + + expect(css(utilities)).toMatchCss(` + @layer utilities { + @variants { + @keyframes spin { + to { transform: rotate(360deg); } + } + @keyframes ping { + 75%, 100% { transform: scale(2); opacity: 0; } + } + } + } + + @layer utilities { + @variants { + .animate-none { animation: none; } + .animate-spin { animation: spin 1s linear infinite; } + .animate-ping { animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; } + } + } + `) +}) + +test('defining animation and keyframes with prefix', () => { + const config = { + prefix: 'tw-', + theme: { + animation: { + none: 'none', + spin: 'spin 1s linear infinite', + ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', + }, + keyframes: { + spin: { to: { transform: 'rotate(360deg)' } }, + ping: { '75%, 100%': { transform: 'scale(2)', opacity: '0' } }, + }, + }, + variants: { + animation: [], + }, + } + + const { utilities } = processPlugins([plugin()], config) + + expect(css(utilities)).toMatchCss(` + @layer utilities { + @variants { + @keyframes tw-spin { + to { transform: rotate(360deg); } + } + @keyframes tw-ping { + 75%, 100% { transform: scale(2); opacity: 0; } + } + } + } + + @layer utilities { + @variants { + .tw-animate-none { animation: none; } + .tw-animate-spin { animation: tw-spin 1s linear infinite; } + .tw-animate-ping { animation: tw-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; } + } + } + `) +}) diff --git a/__tests__/util/produce.js b/__tests__/util/produce.js new file mode 100644 index 000000000000..1949a1add161 --- /dev/null +++ b/__tests__/util/produce.js @@ -0,0 +1,42 @@ +// Full credit goes to: https://github.com/purplestone/exhaust +// However, it is modified so that it is a bit more modern +export function produce(blueprint) { + let groups = [] + + // Call the blueprint once so that we can collect all the possible values we + // spit out in the callback function. + blueprint((...args) => { + if (args.length <= 0) throw new Error('Blueprint callback must have at least a single value') + groups.push(args) + }) + + // Calculate how many combinations there are + let iterations = groups.reduce((total, current) => total * current.length, 1) + + // Calculate all the combinations possible + let zippedGroups = [] + let currentIteration = iterations + groups.forEach((a) => { + let n = a.length + currentIteration = currentIteration / n + let iS = -1 + let aS = [] + + for (let i = 0; i < iterations; i++) { + if (!(i % currentIteration)) iS++ + aS.push(a[iS % n]) + } + zippedGroups.push(aS) + }) + + // Transpose the matrix, so that we can get the correct rows/columns structure + // again. + zippedGroups = zippedGroups[0].map((_, i) => zippedGroups.map((o) => o[i])) + + // Call the blueprint again, but now give the inner function a single value + // every time so that we can build up the final result with single values. + return zippedGroups.map((group) => { + let i = 0 + return blueprint(() => group[i++]) + }) +} diff --git a/src/plugins/animation.js b/src/plugins/animation.js index 6a830674213a..c3a839b5a944 100644 --- a/src/plugins/animation.js +++ b/src/plugins/animation.js @@ -1,16 +1,26 @@ import _ from 'lodash' import nameClass from '../util/nameClass' +import parseAnimationValue from '../util/parseAnimationValue' export default function () { - return function ({ addUtilities, theme, variants }) { + return function ({ addUtilities, theme, variants, prefix }) { + const prefixName = (name) => prefix(`.${name}`).slice(1) const keyframesConfig = theme('keyframes') - const keyframesStyles = _.mapKeys(keyframesConfig, (_keyframes, name) => `@keyframes ${name}`) + const keyframesStyles = _.mapKeys( + keyframesConfig, + (_keyframes, name) => `@keyframes ${prefixName(name)}` + ) + addUtilities(keyframesStyles, { respectImportant: false }) const animationConfig = theme('animation') const utilities = _.mapValues( _.mapKeys(animationConfig, (_animation, suffix) => nameClass('animate', suffix)), - (animation) => ({ animation }) + (animation) => { + const { name } = parseAnimationValue(animation) + if (name === undefined) return { animation } + return { animation: animation.replace(name, prefixName(name)) } + } ) addUtilities(utilities, variants('animation')) } diff --git a/src/util/parseAnimationValue.js b/src/util/parseAnimationValue.js new file mode 100644 index 000000000000..8e883d8b6bac --- /dev/null +++ b/src/util/parseAnimationValue.js @@ -0,0 +1,35 @@ +const DIRECTIONS = new Set(['normal', 'reverse', 'alternate', 'alternate-reverse']) +const PLAY_STATES = new Set(['running', 'paused']) +const FILL_MODES = new Set(['none', 'forwards', 'backwards', 'both']) +const ITERATION_COUNTS = new Set(['infinite']) +const TIMINGS = new Set(['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']) +const TIMING_FNS = ['cubic-bezier', 'steps'] + +const COMMA = /\,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count. +const SPACE = /\ (?![^(]*\))/g // Similar to the one above, but with spaces instead. +const TIME = /^(-?[\d.]+m?s)$/ +const DIGIT = /^(\d+)$/ + +export default function parseAnimationValue(input) { + const animations = input.split(COMMA) + const result = animations.map((animation) => { + const result = {} + const parts = animation.split(SPACE) + + for (let part of parts) { + if (DIRECTIONS.has(part)) result.direction = part + else if (PLAY_STATES.has(part)) result.playState = part + else if (FILL_MODES.has(part)) result.fillMode = part + else if (ITERATION_COUNTS.has(part)) result.iterationCount = part + else if (TIMINGS.has(part)) result.timingFunction = part + else if (TIMING_FNS.some((f) => part.startsWith(`${f}(`))) result.timingFunction = part + else if (TIME.test(part)) result[result.duration === undefined ? 'duration' : 'delay'] = part + else if (DIGIT.test(part)) result.iterationCount = part + else result.name = part + } + + return result + }) + + return animations.length > 1 ? result : result[0] +}