Repository: tailwindlabs/tailwindcss
Pull request #2621 (2020-10-19 19:49:37)

feat: prefix animation names
This PR adds an animation string parser, for example 

This:

```js
"ping 1s cubic-bezier(0, 0, 0.2, 1) infinite"
```

Can be parsed as:

```js
{
  name: 'ping',
  duration: '1s',
  timingFunction: 'cubic-bezier(0, 0, 0.2, 1)',
  iterationCount: 'infinite',
}
```

This is useful so that we can run user defined animations through this function so that we can get the animation name. We then can apply the prefix to the animation name. For example `ping` could become `tw-ping`. Useful for codebases that require a prefix and don't like the global `ping` name.

---

The second commit allows us to do the prefixing of animation `values`.

A prefix of `tw-` will generate:

```css
.tw-animate-spin {
  animation: tw-spin 1s linear infinite;
}

@keyframes tw-spin {
  to {
    transform: rotate(360deg);
  }
}
```

No prefix will result in:

```css
.animate-spin {
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
```

> **Note**: omitted autoprefixer results


Patch:
diff --git a/src/lib/substituteVariantsAtRules.js b/src/lib/substituteVariantsAtRules.js
index c65cee82..febd89d6 100644
--- a/src/lib/substituteVariantsAtRules.js
+++ b/src/lib/substituteVariantsAtRules.js
@@ -61,7 +61,7 @@ const defaultVariantGenerators = (config) => ({
         return modified
       }
 
-      throw new Error("The `dark` config option must be either 'media' or 'class'.")
+      throw new Error("The `darkMode` config option must be either 'media' or 'class'.")
     },
     { unstable_stack: true }
   ),
diff --git a/src/plugins/animation.js b/src/plugins/animation.js
index 6a830674..c3a839b5 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 00000000..8e883d8b
--- /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]
+}


Test patch:
diff --git a/__tests__/parseAnimationValue.test.js b/__tests__/parseAnimationValue.test.js
new file mode 100644
index 00000000..200a7732
--- /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 00000000..19409d52
--- /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 00000000..1949a1ad
--- /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++])
+  })
+}