Repository: tailwindlabs/tailwindcss
Pull request #2309 (2020-09-04 02:54:24)

Support defining variants as functions for easier extending
This PR adds support for configuring variants as functions to make it easier to add variants without respecifying the entire list.

Variant functions are invoked with an object that can be destructured into three helper functions:

- `variants`, which can be called like `variants('padding')` to get the list of variants for that key
- `before`, which can be called like `before(['hover'])` to add some variants before the existing variants, `before(['hover'], 'focus')` to add some variants before a specific existing variant, or `before(['hover'], 'focus', ['responsive', 'focus'])` to add variants before a specific variant to an explicit list, instead of automatically adding it to the list matching the current key
- `after`, which can be called like `after(['hover'])` to add some variants after the existing variants, `after(['hover'], 'focus')` to add some variants after a specific existing variant, or `after(['hover'], 'focus', ['responsive', 'focus'])` to add variants after a specific variant to an explicit list, instead of automatically adding it to the list matching the current key
- `without`, which can be called like `without(['hover'])` to disable a variant that's enabled by default, or `without(['hover'], ['responsive', 'hover', 'focus])` to remove that variant from a list that is passed in rather than assuming the current context

In practice it looks like this:

```js
// tailwind.config.js
module.exports = {
  variants: {
    opacity: ({ before }) => before(['group-hover'], 'hover')
  }
}
```

Using the last parameter of each helper lets you compose these together, to do stuff like:

```js
// tailwind.config.js
module.exports = {
  variants: {
    opacity: ({ before, after, without }) => without(['responsive'], before(['group-hover'], 'hover', after(['group-focus', 'focus')))
  }
}
```

Landish: Excellent PR!

Also, alternative solution:

```js
const { variants } = require('tailwindcss/defaultConfig');
// ...
variants: {
   textColor: [...variants.textColor, 'group-hover']
},
```
adamwathan: @Landish Totally, and that's what we've recommended until now! The main downsides of that approach are that you can't easily get the default variants for stuff that comes from third party plugins, and that you can't easily insert a variant at a specific spot in the list 👍🏻 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19d11212..107d0433 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - New `layers` mode for `purge` ([#2288](https://github.com/tailwindlabs/tailwindcss/pull/2288))
 - New `font-variant-numeric` utilities ([#2305](https://github.com/tailwindlabs/tailwindcss/pull/2305))
 - New `place-items`, `place-content`, `place-self`, `justify-items`, and `justify-self` utilities ([#2306](https://github.com/tailwindlabs/tailwindcss/pull/2306))
+- Support configuring variants as functions ([#2309](https://github.com/tailwindlabs/tailwindcss/pull/2309))
 
 ### Deprecated
 
diff --git a/src/util/resolveConfig.js b/src/util/resolveConfig.js
index 75dac0d4..0af2b176 100644
--- a/src/util/resolveConfig.js
+++ b/src/util/resolveConfig.js
@@ -83,7 +83,7 @@ function mergeExtensions({ extend, ...theme }) {
 }
 
 function resolveFunctionKeys(object) {
-  const resolveThemePath = (key, defaultValue) => {
+  const resolvePath = (key, defaultValue) => {
     const path = toPath(key)
 
     let index = 0
@@ -91,7 +91,7 @@ function resolveFunctionKeys(object) {
 
     while (val !== undefined && val !== null && index < path.length) {
       val = val[path[index++]]
-      val = isFunction(val) ? val(resolveThemePath, configUtils) : val
+      val = isFunction(val) ? val(resolvePath, configUtils) : val
     }
 
     return val === undefined ? defaultValue : val
@@ -100,7 +100,7 @@ function resolveFunctionKeys(object) {
   return Object.keys(object).reduce((resolved, key) => {
     return {
       ...resolved,
-      [key]: isFunction(object[key]) ? object[key](resolveThemePath, configUtils) : object[key],
+      [key]: isFunction(object[key]) ? object[key](resolvePath, configUtils) : object[key],
     }
   }, {})
 }
@@ -128,6 +128,65 @@ function extractPluginConfigs(configs) {
   return allConfigs
 }
 
+function resolveVariants([firstConfig, ...variantConfigs]) {
+  if (Array.isArray(firstConfig)) {
+    return firstConfig
+  }
+
+  return [firstConfig, ...variantConfigs].reverse().reduce((resolved, variants) => {
+    Object.entries(variants || {}).forEach(([plugin, pluginVariants]) => {
+      if (isFunction(pluginVariants)) {
+        resolved[plugin] = pluginVariants({
+          variants(path) {
+            return get(resolved, path, [])
+          },
+          before(toInsert, variant, existingPluginVariants = get(resolved, plugin, [])) {
+            if (variant === undefined) {
+              return [...toInsert, ...existingPluginVariants]
+            }
+
+            const index = existingPluginVariants.indexOf(variant)
+
+            if (index === -1) {
+              return [...existingPluginVariants, ...toInsert]
+            }
+
+            return [
+              ...existingPluginVariants.slice(0, index),
+              ...toInsert,
+              ...existingPluginVariants.slice(index),
+            ]
+          },
+          after(toInsert, variant, existingPluginVariants = get(resolved, plugin, [])) {
+            if (variant === undefined) {
+              return [...existingPluginVariants, ...toInsert]
+            }
+
+            const index = existingPluginVariants.indexOf(variant)
+
+            if (index === -1) {
+              return [...toInsert, ...existingPluginVariants]
+            }
+
+            return [
+              ...existingPluginVariants.slice(0, index + 1),
+              ...toInsert,
+              ...existingPluginVariants.slice(index + 1),
+            ]
+          },
+          without(toRemove, existingPluginVariants = get(resolved, plugin, [])) {
+            return existingPluginVariants.filter(v => !toRemove.includes(v))
+          },
+        })
+      } else {
+        resolved[plugin] = pluginVariants
+      }
+    })
+
+    return resolved
+  }, {})
+}
+
 export default function resolveConfig(configs) {
   const allConfigs = extractPluginConfigs(configs)
 
@@ -136,11 +195,7 @@ export default function resolveConfig(configs) {
       theme: resolveFunctionKeys(
         mergeExtensions(mergeThemes(map(allConfigs, t => get(t, 'theme', {}))))
       ),
-      variants: (firstVariants => {
-        return Array.isArray(firstVariants)
-          ? firstVariants
-          : defaults({}, ...map(allConfigs, 'variants'))
-      })(defaults({}, ...map(allConfigs)).variants),
+      variants: resolveVariants(allConfigs.map(c => c.variants)),
     },
     ...allConfigs
   )


Test patch:
diff --git a/__tests__/resolveConfig.test.js b/__tests__/resolveConfig.test.js
index 0354db3c..50e6b987 100644
--- a/__tests__/resolveConfig.test.js
+++ b/__tests__/resolveConfig.test.js
@@ -1736,3 +1736,65 @@ test('user theme extensions take precedence over plugin theme extensions with th
     plugins: userConfig.plugins,
   })
 })
+
+test('variants can be defined as a function', () => {
+  const userConfig = {
+    variants: {
+      backgroundColor: ({ variants }) => [...variants('backgroundColor'), 'disabled'],
+      padding: ({ before }) => before(['active']),
+      float: ({ before }) => before(['disabled'], 'focus'),
+      margin: ({ before }) => before(['hover'], 'focus'),
+      borderWidth: ({ after }) => after(['active']),
+      backgroundImage: ({ after }) => after(['disabled'], 'hover'),
+      opacity: ({ after }) => after(['hover'], 'focus'),
+      rotate: ({ without }) => without(['hover']),
+      cursor: ({ before, after, without }) =>
+        without(['responsive'], before(['checked'], 'hover', after(['hover'], 'focus'))),
+    },
+  }
+
+  const otherConfig = {
+    variants: {
+      backgroundColor: ({ variants }) => [...variants('backgroundColor'), 'active'],
+    },
+  }
+
+  const defaultConfig = {
+    prefix: '',
+    important: false,
+    separator: ':',
+    theme: {},
+    variants: {
+      backgroundColor: ['responsive', 'hover', 'focus'],
+      padding: ['responsive', 'focus'],
+      float: ['responsive', 'hover', 'focus'],
+      margin: ['responsive'],
+      borderWidth: ['responsive', 'focus'],
+      backgroundImage: ['responsive', 'hover', 'focus'],
+      opacity: ['responsive'],
+      rotate: ['responsive', 'hover', 'focus'],
+      cursor: ['responsive', 'focus'],
+    },
+  }
+
+  const result = resolveConfig([userConfig, otherConfig, defaultConfig])
+
+  expect(result).toEqual({
+    prefix: '',
+    important: false,
+    separator: ':',
+    theme: {},
+    variants: {
+      backgroundColor: ['responsive', 'hover', 'focus', 'active', 'disabled'],
+      padding: ['active', 'responsive', 'focus'],
+      float: ['responsive', 'hover', 'disabled', 'focus'],
+      margin: ['responsive', 'hover'],
+      borderWidth: ['responsive', 'focus', 'active'],
+      backgroundImage: ['responsive', 'hover', 'disabled', 'focus'],
+      opacity: ['hover', 'responsive'],
+      rotate: ['responsive', 'focus'],
+      cursor: ['focus', 'checked', 'hover'],
+    },
+    plugins: userConfig.plugins,
+  })
+})