diff --git a/__tests__/animationParser.test.js b/__tests__/animationParser.test.js new file mode 100644 index 000000000000..e6c1a9dc0178 --- /dev/null +++ b/__tests__/animationParser.test.js @@ -0,0 +1,228 @@ +import parse from '../src/util/animationParser' +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(parse(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(parse(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 = parse(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(parse(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(parse(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 = parse(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(parse(input)).toEqual(output) + }) +}) 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/util/animationParser.js b/src/util/animationParser.js new file mode 100644 index 000000000000..90c4825b84b5 --- /dev/null +++ b/src/util/animationParser.js @@ -0,0 +1,35 @@ +const directions = new Set(['normal', 'reverse', 'alternate', 'alternate-reverse']) +const playStates = new Set(['running', 'paused']) +const fillModes = new Set(['none', 'forwards', 'backwards', 'both']) +const iterationCount = new Set(['infinite']) +const timingFns = new Set(['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']) +const timingFnsWithArgs = ['cubic-bezier', 'steps'] +const commaSeparator = /\,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count. +const spaceSeparator = /\ (?![^(]*\))/g // Similar to the one above, but with spaces instead. + +export default function parse(input) { + const animations = input.split(commaSeparator) + const result = animations.map((animation) => { + const parts = animation.split(spaceSeparator) + + const result = {} + + for (let part of parts) { + if (directions.has(part)) result.direction = part + else if (playStates.has(part)) result.playState = part + else if (fillModes.has(part)) result.fillMode = part + else if (iterationCount.has(part)) result.iterationCount = part + else if (timingFns.has(part)) result.timingFunction = part + else if (timingFnsWithArgs.some((fn) => part.startsWith(`${fn}(`))) + result.timingFunction = part + else if (/^(-?[\d.]+m?s)$/.test(part)) + result[result.duration === undefined ? 'duration' : 'delay'] = part + else if (/^(\d+)$/.test(part)) result.iterationCount = part + else result.name = part + } + + return result + }) + + return animations.length > 1 ? result : result[0] +}