From 87df93de0a3ba0d9dd84cb3f1025f843959b6928 Mon Sep 17 00:00:00 2001
From: Adam Wathan <adam.wathan@gmail.com>
Date: Fri, 14 May 2021 13:07:56 -0400
Subject: [PATCH] Support opacity modifiers for colors in JIT (#4348)

* Support opacity modifiers for colors in JIT

* Add test for function colors

* Support opacity modifiers for plugins with arbitrary "any" type
---
 src/jit/lib/generateRules.js              |   4 +-
 src/jit/lib/setupContext.js               |   5 +-
 src/plugins/fill.js                       |   2 +-
 src/plugins/gradientColorStops.js         |   2 +-
 src/plugins/placeholderColor.js           |   2 +-
 src/util/pluginUtils.js                   |  65 +++++--
 tests/jit/color-opacity-modifiers.test.js | 200 ++++++++++++++++++++++
 7 files changed, 260 insertions(+), 20 deletions(-)
 create mode 100644 tests/jit/color-opacity-modifiers.test.js

diff --git a/src/jit/lib/generateRules.js b/src/jit/lib/generateRules.js
index 027ad707ce5f..916a64181e57 100644
--- a/src/jit/lib/generateRules.js
+++ b/src/jit/lib/generateRules.js
@@ -27,9 +27,9 @@ function* candidatePermutations(candidate, lastIndex = Infinity) {
   if (lastIndex === Infinity && candidate.endsWith(']')) {
     let bracketIdx = candidate.lastIndexOf('[')
 
-    // If character before `[` isn't a dash, this isn't a dynamic class
+    // If character before `[` isn't a dash or a slash, this isn't a dynamic class
     // eg. string[]
-    dashIdx = candidate[bracketIdx - 1] === '-' ? bracketIdx - 1 : -1
+    dashIdx = ['-', '/'].includes(candidate[bracketIdx - 1]) ? bracketIdx - 1 : -1
   } else {
     dashIdx = candidate.lastIndexOf('-', lastIndex)
   }
diff --git a/src/jit/lib/setupContext.js b/src/jit/lib/setupContext.js
index b0e01d3a2f1a..3ea367b8ac45 100644
--- a/src/jit/lib/setupContext.js
+++ b/src/jit/lib/setupContext.js
@@ -541,9 +541,10 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
 
         function wrapped(modifier) {
           let { type = 'any' } = options
-          let [value, coercedType] = coerceValue(type, modifier, options.values)
+          type = [].concat(type)
+          let [value, coercedType] = coerceValue(type, modifier, options.values, tailwindConfig)
 
-          if (type !== coercedType || value === undefined) {
+          if (!type.includes(coercedType) || value === undefined) {
             return []
           }
 
diff --git a/src/plugins/fill.js b/src/plugins/fill.js
index 311524f9d968..7778ece9156c 100644
--- a/src/plugins/fill.js
+++ b/src/plugins/fill.js
@@ -12,7 +12,7 @@ export default function () {
       {
         values: flattenColorPalette(theme('fill')),
         variants: variants('fill'),
-        type: 'any',
+        type: ['color', 'any'],
       }
     )
   }
diff --git a/src/plugins/gradientColorStops.js b/src/plugins/gradientColorStops.js
index 38d7d39a767d..e1ebba1364ec 100644
--- a/src/plugins/gradientColorStops.js
+++ b/src/plugins/gradientColorStops.js
@@ -11,7 +11,7 @@ export default function () {
     let options = {
       values: flattenColorPalette(theme('gradientColorStops')),
       variants: variants('gradientColorStops'),
-      type: 'any',
+      type: ['color', 'any'],
     }
 
     matchUtilities(
diff --git a/src/plugins/placeholderColor.js b/src/plugins/placeholderColor.js
index 66e6e27a5152..412d3647de8a 100644
--- a/src/plugins/placeholderColor.js
+++ b/src/plugins/placeholderColor.js
@@ -26,7 +26,7 @@ export default function () {
       {
         values: flattenColorPalette(theme('placeholderColor')),
         variants: variants('placeholderColor'),
-        type: 'any',
+        type: ['color', 'any'],
       }
     )
   }
diff --git a/src/util/pluginUtils.js b/src/util/pluginUtils.js
index acc9e4a9200a..a37c6e98d7dc 100644
--- a/src/util/pluginUtils.js
+++ b/src/util/pluginUtils.js
@@ -2,6 +2,7 @@ import selectorParser from 'postcss-selector-parser'
 import postcss from 'postcss'
 import createColor from 'color'
 import escapeCommas from './escapeCommas'
+import { withAlphaValue } from './withAlphaVariable'
 
 export function updateAllClasses(selectors, updateClass) {
   let parser = selectorParser((selectors) => {
@@ -148,16 +149,50 @@ export function asList(modifier, lookup = {}) {
   })
 }
 
-export function asColor(modifier, lookup = {}) {
+function isArbitraryValue(input) {
+  return input.startsWith('[') && input.endsWith(']')
+}
+
+function splitAlpha(modifier) {
+  let slashIdx = modifier.lastIndexOf('/')
+
+  if (slashIdx === -1 || slashIdx === modifier.length - 1) {
+    return [modifier]
+  }
+
+  return [modifier.slice(0, slashIdx), modifier.slice(slashIdx + 1)]
+}
+
+function isColor(value) {
+  try {
+    createColor(value)
+    return true
+  } catch (e) {
+    return false
+  }
+}
+
+export function asColor(modifier, lookup = {}, tailwindConfig = {}) {
+  if (lookup[modifier] !== undefined) {
+    return lookup[modifier]
+  }
+
+  let [color, alpha] = splitAlpha(modifier)
+
+  if (lookup[color] !== undefined) {
+    if (isArbitraryValue(alpha)) {
+      return withAlphaValue(lookup[color], alpha.slice(1, -1))
+    }
+
+    if (tailwindConfig.theme?.opacity?.[alpha] === undefined) {
+      return undefined
+    }
+
+    return withAlphaValue(lookup[color], tailwindConfig.theme.opacity[alpha])
+  }
+
   return asValue(modifier, lookup, {
-    validate: (value) => {
-      try {
-        createColor(value)
-        return true
-      } catch (e) {
-        return false
-      }
-    },
+    validate: isColor,
   })
 }
 
@@ -208,14 +243,18 @@ function splitAtFirst(input, delim) {
   return (([first, ...rest]) => [first, rest.join(delim)])(input.split(delim))
 }
 
-export function coerceValue(type, modifier, values) {
-  if (modifier.startsWith('[') && modifier.endsWith(']')) {
+export function coerceValue(type, modifier, values, tailwindConfig) {
+  let [scaleType, arbitraryType = scaleType] = [].concat(type)
+
+  if (isArbitraryValue(modifier)) {
     let [explicitType, value] = splitAtFirst(modifier.slice(1, -1), ':')
 
     if (value.length > 0 && Object.keys(typeMap).includes(explicitType)) {
-      return [asValue(`[${value}]`, values), explicitType]
+      return [asValue(`[${value}]`, values, tailwindConfig), explicitType]
     }
+
+    return [typeMap[arbitraryType](modifier, values, tailwindConfig), arbitraryType]
   }
 
-  return [typeMap[type](modifier, values), type]
+  return [typeMap[scaleType](modifier, values, tailwindConfig), scaleType]
 }
diff --git a/tests/jit/color-opacity-modifiers.test.js b/tests/jit/color-opacity-modifiers.test.js
new file mode 100644
index 000000000000..7955a758f091
--- /dev/null
+++ b/tests/jit/color-opacity-modifiers.test.js
@@ -0,0 +1,200 @@
+import postcss from 'postcss'
+import path from 'path'
+import tailwind from '../../src/jit/index.js'
+
+function run(input, config = {}) {
+  const { currentTestName } = expect.getState()
+
+  return postcss(tailwind(config)).process(input, {
+    from: `${path.resolve(__filename)}?test=${currentTestName}`,
+  })
+}
+
+test('basic color opacity modifier', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-red-500/50"></div>',
+      },
+    ],
+    theme: {},
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .bg-red-500\\/50 {
+        background-color: rgba(239, 68, 68, 0.5);
+      }
+    `)
+  })
+})
+
+test('colors with slashes are matched first', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-red-500/50"></div>',
+      },
+    ],
+    theme: {
+      extend: {
+        colors: {
+          'red-500/50': '#ff0000',
+        },
+      },
+    },
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .bg-red-500\\/50 {
+        --tw-bg-opacity: 1;
+        background-color: rgba(255, 0, 0, var(--tw-bg-opacity));
+      }
+    `)
+  })
+})
+
+test('arbitrary color opacity modifier', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: 'bg-red-500/[var(--opacity)]',
+      },
+    ],
+    theme: {},
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .bg-red-500\\/\\[var\\(--opacity\\)\\] {
+        background-color: rgba(239, 68, 68, var(--opacity));
+      }
+    `)
+  })
+})
+
+test('missing alpha generates nothing', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-red-500/"></div>',
+      },
+    ],
+    theme: {},
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(``)
+  })
+})
+
+test('values not in the opacity config are ignored', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-red-500/29"></div>',
+      },
+    ],
+    theme: {
+      opacity: {
+        0: '0',
+        25: '0.25',
+        5: '0.5',
+        75: '0.75',
+        100: '1',
+      },
+    },
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(``)
+  })
+})
+
+test('function colors are supported', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-blue/50"></div>',
+      },
+    ],
+    theme: {
+      colors: {
+        blue: ({ opacityValue }) => {
+          return `rgba(var(--colors-blue), ${opacityValue})`
+        },
+      },
+    },
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .bg-blue\\/50 {
+        background-color: rgba(var(--colors-blue), 0.5);
+      }
+    `)
+  })
+})
+
+test('utilities that support any type are supported', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: `
+          <div class="from-red-500/50"></div>
+          <div class="fill-red-500/25"></div>
+          <div class="placeholder-red-500/75"></div>
+        `,
+      },
+    ],
+    theme: {
+      extend: {
+        fill: (theme) => theme('colors'),
+      },
+    },
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .from-red-500\\/50 {
+        --tw-gradient-from: rgba(239, 68, 68, 0.5);
+        --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(239, 68, 68, 0));
+      }
+      .fill-red-500\\/25 {
+        fill: rgba(239, 68, 68, 0.25);
+      }
+      .placeholder-red-500\\/75::placeholder {
+        color: rgba(239, 68, 68, 0.75);
+      }
+    `)
+  })
+})