Skip to content

Commit

Permalink
Move opacity modifier support into plugin theme() function (#14348)
Browse files Browse the repository at this point in the history
This PR moves support for opacity modifies from the CSS `theme()`
function into the plugin `theme()` implementation, this will allow
plugins to use this, too:

```ts
let plugin = plugin(function ({ addUtilities, theme }) {
    addUtilities({
      '.percentage': {
        color: theme('colors.red.500 / 50%'),
      },
      '.fraction': {
        color: theme('colors.red.500 / 0.5'),
      },
      '.variable': {
        color: theme('colors.red.500 / var(--opacity)'),
      },
    })
  })
}
```

There's a small behavioral change for the CSS `theme()` function. Since
tuples are resolved by default for the CSS `theme()` function only,
these will no longer have opacity applied to their first values. This is
probably fine given the reduced complexity as I don't expect the first
values of tuples to be colors and the fix would mean we would have to
parse the modifier in different places.
  • Loading branch information
philipp-spiess committed Sep 5, 2024
1 parent d9558bb commit 8f8803d
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 41 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Added

- Add opacity modifier support to the `theme()` function in plugins ([#14348](https://github.com/tailwindlabs/tailwindcss/pull/14348))

## [4.0.0-alpha.22] - 2024-09-04

Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss/src/compat/config/resolve-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DesignSystem } from '../../design-system'
import type { PluginWithConfig } from '../../plugin-api'
import { createThemeFn } from '../../theme-fn'
import { createThemeFn } from '../plugin-functions'
import { deepMerge, isPlainObject } from './deep-merge'
import {
type ResolvedConfig,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,51 @@
import { deepMerge } from './compat/config/deep-merge'
import type { UserConfig } from './compat/config/types'
import type { DesignSystem } from './design-system'
import type { Theme, ThemeKey } from './theme'
import { DefaultMap } from './utils/default-map'
import { toKeyPath } from './utils/to-key-path'
import type { DesignSystem } from '../design-system'
import type { Theme, ThemeKey } from '../theme'
import { withAlpha } from '../utilities'
import { DefaultMap } from '../utils/default-map'
import { toKeyPath } from '../utils/to-key-path'
import { deepMerge } from './config/deep-merge'
import type { UserConfig } from './config/types'

export function createThemeFn(
designSystem: DesignSystem,
configTheme: () => UserConfig['theme'],
resolveValue: (value: any) => any,
) {
return function theme(path: string, defaultValue?: any) {
let keypath = toKeyPath(path)
let cssValue = readFromCss(designSystem.theme, keypath)

if (typeof cssValue !== 'object') {
return cssValue
// Extract an eventual modifier from the path. e.g.:
// - "colors.red.500 / 50%" -> "50%"
// - "foo/bar/baz/50%" -> "50%"
let lastSlash = path.lastIndexOf('/')
let modifier: string | null = null
if (lastSlash !== -1) {
modifier = path.slice(lastSlash + 1).trim()
path = path.slice(0, lastSlash).trim()
}

let configValue = resolveValue(get(configTheme() ?? {}, keypath) ?? null)
let resolvedValue = (() => {
let keypath = toKeyPath(path)
let cssValue = readFromCss(designSystem.theme, keypath)

if (typeof cssValue !== 'object') {
return cssValue
}

let configValue = resolveValue(get(configTheme() ?? {}, keypath) ?? null)

if (configValue !== null && typeof configValue === 'object' && !Array.isArray(configValue)) {
return deepMerge({}, [configValue, cssValue], (_, b) => b)
}

// Values from CSS take precedence over values from the config
return cssValue ?? configValue
})()

if (configValue !== null && typeof configValue === 'object' && !Array.isArray(configValue)) {
return deepMerge({}, [configValue, cssValue], (_, b) => b)
// Apply the opacity modifier if present
if (modifier && typeof resolvedValue === 'string') {
resolvedValue = withAlpha(resolvedValue, modifier)
}

// Values from CSS take precedence over values from the config
return cssValue ?? configValue ?? defaultValue
return resolvedValue ?? defaultValue
}
}

Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { walk, type AstNode } from './ast'
import type { PluginAPI } from './plugin-api'
import { withAlpha } from './utilities'
import * as ValueParser from './value-parser'
import { type ValueAstNode } from './value-parser'

Expand Down Expand Up @@ -77,27 +76,19 @@ function cssThemeFn(
path: string,
fallbackValues: ValueAstNode[],
): ValueAstNode[] {
let modifier: string | null = null
// Extract an eventual modifier from the path. e.g.:
// - "colors.red.500 / 50%" -> "50%"
// - "foo/bar/baz/50%" -> "50%"
let lastSlash = path.lastIndexOf('/')
if (lastSlash !== -1) {
modifier = path.slice(lastSlash + 1).trim()
path = path.slice(0, lastSlash).trim()
}

let resolvedValue: string | null = null
let themeValue = pluginApi.theme(path)

if (Array.isArray(themeValue) && themeValue.length === 2) {
let isArray = Array.isArray(themeValue)
if (isArray && themeValue.length === 2) {
// When a tuple is returned, return the first element
resolvedValue = themeValue[0]
// We otherwise only ignore string values here, objects (and namespace maps)
// are treated as non-resolved values for the CSS `theme()` function.
} else if (Array.isArray(themeValue)) {
} else if (isArray) {
// Arrays get serialized into a comma-separated lists
resolvedValue = themeValue.join(', ')
} else if (typeof themeValue === 'string') {
// Otherwise only allow string values here, objects (and namespace maps)
// are treated as non-resolved values for the CSS `theme()` function.
resolvedValue = themeValue
}

Expand All @@ -107,14 +98,10 @@ function cssThemeFn(

if (!resolvedValue) {
throw new Error(
`Could not resolve value for theme function: \`theme(${path}${modifier ? ` / ${modifier}` : ''})\`. Consider checking if the path is correct or provide a fallback value to silence this error.`,
`Could not resolve value for theme function: \`theme(${path})\`. Consider checking if the path is correct or provide a fallback value to silence this error.`,
)
}

if (modifier) {
resolvedValue = withAlpha(resolvedValue, modifier)
}

// We need to parse the values recursively since this can resolve with another
// `theme()` function definition.
return ValueParser.parse(resolvedValue)
Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { substituteAtApply } from './apply'
import { comment, decl, rule, toCss, walk, WalkAction, type Rule } from './ast'
import type { UserConfig } from './compat/config/types'
import { compileCandidates } from './compile'
import { substituteFunctions, THEME_FUNCTION_INVOCATION } from './css-functions'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { substituteFunctions, THEME_FUNCTION_INVOCATION } from './functions'
import { registerPlugins, type CssPluginOptions, type Plugin } from './plugin-api'
import { Theme } from './theme'
import { segment } from './utils/segment'
Expand Down
45 changes: 44 additions & 1 deletion packages/tailwindcss/src/plugin-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,49 @@ describe('theme', async () => {
`)
})

test('plugin theme can have opacity modifiers', async ({ expect }) => {
let input = css`
@tailwind utilities;
@theme {
--color-red-500: #ef4444;
}
@plugin "my-plugin";
`

let compiler = await compile(input, {
loadPlugin: async () => {
return plugin(function ({ addUtilities, theme }) {
addUtilities({
'.percentage': {
color: theme('colors.red.500 / 50%'),
},
'.fraction': {
color: theme('colors.red.500 / 0.5'),
},
'.variable': {
color: theme('colors.red.500 / var(--opacity)'),
},
})
})
},
})

expect(compiler.build(['percentage', 'fraction', 'variable'])).toMatchInlineSnapshot(`
".fraction {
color: color-mix(in srgb, #ef4444 50%, transparent);
}
.percentage {
color: color-mix(in srgb, #ef4444 50%, transparent);
}
.variable {
color: color-mix(in srgb, #ef4444 calc(var(--opacity) * 100%), transparent);
}
:root {
--color-red-500: #ef4444;
}
"
`)
})
test('theme value functions are resolved correctly regardless of order', async ({ expect }) => {
let input = css`
@tailwind utilities;
Expand Down Expand Up @@ -354,7 +397,7 @@ describe('theme', async () => {
`)
})

test('CSS theme values are mreged with JS theme values', async ({ expect }) => {
test('CSS theme values are merged with JS theme values', async ({ expect }) => {
let input = css`
@tailwind utilities;
@plugin "my-plugin";
Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss/src/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { createCompatConfig } from './compat/config/create-compat-config'
import { resolveConfig, type ConfigFile } from './compat/config/resolve-config'
import type { ResolvedConfig, UserConfig } from './compat/config/types'
import { darkModePlugin } from './compat/dark-mode'
import { createThemeFn } from './compat/plugin-functions'
import type { DesignSystem } from './design-system'
import { createThemeFn } from './theme-fn'
import { withAlpha, withNegative } from './utilities'
import { inferDataType } from './utils/infer-data-type'
import { segment } from './utils/segment'
Expand Down

0 comments on commit 8f8803d

Please sign in to comment.