Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
Custom color opacity in theme function
### Discussed in https://github.com/tailwindlabs/tailwindcss/discussions/8987

<div type='discussions-op-text'>

<sup>Originally posted by **dawaltconley** July 29, 2022</sup>
Hi, I'm trying to reference custom colors with variable opacity elsewhere in my theme config and cannot figure out how. From the little bit of testing I've done it could be a bug or missing feature, but I wanted to ask here in case I'm missing something obvious.

Here are the relevant parts of my config:

```js
module.exports = {
  theme: {
    extend: {
      colors: {
        foobar: ({ opacityValue }) => `rgb(255 100 0 / ${opacityValue})`,
      },
      typography: ({ theme }) => ({
        theme: {
          css: {
            '--tw-prose-headings': theme('colors.red[500] / 50%'), // this works
            '--tw-prose-bullets': theme('colors.foobar / 80%'), // this does not
          },
        },
      }),
    },
  },
}
```

I've also tried using the new `<alpha-value>` syntax. The `text-foobar/80` etc classes _do_ work in my template files, but `opacityValue` seems to be `undefined` when referencing it in the config. i.e. the above outputs `rgb(255 100 0 / undefined)` for `--tw-prose-bullets`.</div>

### My analysis of this issue

The code that resolves alpha values in `theme()` function is:

https://github.com/tailwindlabs/tailwindcss/blob/14542d94f7078dc8d065ddcc6d403cd0a467f1be/src/util/resolveConfig.js#L176-L186

This code expects there to be more that 2 components in the path; let's see why.

#### 3-part color

##### Initial state
```js
index = 0;
val = {
  // …
  colors: someResolvingFunction,
  // …
};
path = ['colors', 'red', '500']; // also path.alpha = '50%';
```

##### While loop 1
```js
val = val[path[index++]]`
// index = 1; val = someResolvingFunction
let shouldResolveAsFn =
          isFunction(val) && (path.alpha === undefined || index < path.length - 1)
// shouldResolveAsFn = true, since index < path.length - 1 is true as index = 1, path.length - 1 = 2.
val = shouldResolveAsFn ? val(resolvePath, configUtils) : val
// val = {
//  …,
//  red: {
//    …,
//    500: '#ef4444',
//    …
//  },
//  …
//  }
```

##### While loop 2
```js
val = val[path[index++]]`
// index = 2; val = val['red'] = { …, 500: '#ef4444', … }
let shouldResolveAsFn =
          isFunction(val) && (path.alpha === undefined || index < path.length - 1)
// shouldResolveAsFn = false, since isFunction(val) = false
val = shouldResolveAsFn ? val(resolvePath, configUtils) : val
// val = { …, 500: '#ef4444', … }
```

##### While loop 3
```js
val = val[path[index++]]`
// index = 3; val = val['500'] = { …, 500: '#ef4444', … }['500'] = '#ef4444'
let shouldResolveAsFn =
          isFunction(val) && (path.alpha === undefined || index < path.length - 1)
// shouldResolveAsFn = false, since isFunction(val) = false
val = shouldResolveAsFn ? val(resolvePath, configUtils) : val
// val = '#ef4444'
```
While loop stops since the condition `index < path.length` now evaluates to `false` and our final `val` value is `#ef4444`, all good ✔.

#### 2-part color

##### Initial state
```js
index = 0;
val = {
  // …
  colors: someResolvingFunction,
  // …
};
path = ['colors', 'foobar']; // also path.alpha = '80%';
```

##### While loop 1
```js
val = val[path[index++]]`
// index = 1; val = someResolvingFunction
let shouldResolveAsFn =
          isFunction(val) && (path.alpha === undefined || index < path.length - 1)
// shouldResolveAsFn = false, since index < path.length - 1 is false as index = 1, path.length - 1 = 1.
val = shouldResolveAsFn ? val(resolvePath, configUtils) : val
// val = someResolvingFunction
```

##### While loop 2
```js
val = val[path[index++]]`
// index = 2; val = someResolvingFunction['foobar'] = undefined
// val is now undefined so no value is printed.
```
While loop stops since the condition `val !== undefined` now evaluates to `false` and our final `val` value is `undefined`, not good ❌.

### Summary

This is because in the first while loop of 2-part color, the `index < path.length - 1` condition stops `someResolvingFunction` from being evaluated as a function and instead, the function is passed through as-is.


Hints:

Patch:
diff --git a/src/util/resolveConfig.js b/src/util/resolveConfig.js
--- a/src/util/resolveConfig.js
+++ b/src/util/resolveConfig.js
@@ -180,7 +180,7 @@ function resolveFunctionKeys(object) {
         val = val[path[index++]]
 
         let shouldResolveAsFn =
-          isFunction(val) && (path.alpha === undefined || index < path.length - 1)
+          isFunction(val) && (path.alpha === undefined || index <= path.length - 1)
 
         val = shouldResolveAsFn ? val(resolvePath, configUtils) : val
       }


Test patch:
diff --git a/tests/opacity.test.js b/tests/opacity.test.js
--- a/tests/opacity.test.js
+++ b/tests/opacity.test.js
@@ -761,3 +761,70 @@ it('Theme functions can reference values with slashes in brackets', () => {
     `)
   })
 })
+
+it('works with opacity values defined as a placeholder or a function in when colors is a function', () => {
+  let config = {
+    content: [
+      {
+        raw: html`
+          <div
+            class="bg-foo10 bg-foo20 bg-foo30 bg-foo40 bg-foo11 bg-foo21 bg-foo31 bg-foo41"
+          ></div>
+        `,
+      },
+    ],
+    theme: {
+      colors: () => ({
+        foobar1: ({ opacityValue }) => `rgb(255 100 0 / ${opacityValue ?? '100%'})`,
+        foobar2: `rgb(255 100 0 / <alpha-value>)`,
+        foobar3: {
+          100: ({ opacityValue }) => `rgb(255 100 0 / ${opacityValue ?? '100%'})`,
+          200: `rgb(255 100 0 / <alpha-value>)`,
+        },
+      }),
+      extend: {
+        backgroundColor: ({ theme }) => ({
+          foo10: theme('colors.foobar1'),
+          foo20: theme('colors.foobar2'),
+          foo30: theme('colors.foobar3.100'),
+          foo40: theme('colors.foobar3.200'),
+          foo11: theme('colors.foobar1 / 50%'),
+          foo21: theme('colors.foobar2 / 50%'),
+          foo31: theme('colors.foobar3.100 / 50%'),
+          foo41: theme('colors.foobar3.200 / 50%'),
+        }),
+      },
+    },
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchCss(css`
+      .bg-foo10 {
+        background-color: rgb(255 100 0 / 100%);
+      }
+      .bg-foo20 {
+        --tw-bg-opacity: 1;
+        background-color: rgb(255 100 0 / var(--tw-bg-opacity));
+      }
+      .bg-foo30 {
+        background-color: rgb(255 100 0 / 100%);
+      }
+      .bg-foo40 {
+        --tw-bg-opacity: 1;
+        background-color: rgb(255 100 0 / var(--tw-bg-opacity));
+      }
+      .bg-foo11 {
+        background-color: rgb(255 100 0 / 50%);
+      }
+      .bg-foo21 {
+        background-color: rgb(255 100 0 / 50%);
+      }
+      .bg-foo31 {
+        background-color: rgb(255 100 0 / 50%);
+      }
+      .bg-foo41 {
+        background-color: rgb(255 100 0 / 50%);
+      }
+    `)
+  })
+})