Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit cea8c97

Browse files
authored
Improve parsing of shadow values (#20080)
This PR fixes an issue where a `calc(…)` value used in a shadow value was incorrectly marked as the color of that shadow. We have this feature where we can have colored shadows, this requires us to replace the color in a value with a `var(--tw-shadow-color, <original-value-here>)` such that we can swap out the color. For this, we have to parse the value and figure out what the color part. This PR uses the `ValueParser` to parse values, then figure out what the color part is. The biggest reason for this is that we know what a "function" is and what a normal "word" is, which allows us to do more fine-grained checks on a per-type basis. We tracked a few more functions that we _know_ produce color values, and a few cases that we know produce length values so therefore can't be the color. Some examples: - `calc(…)`, `min(…)`, `max(…)`, `clamp(…)`, `--spacing(…)` — these all produce length-values. - `color(…)`, `color-mix(…)`, `rgba?(…)`, `--alpha(…)` … — these all produce color values. Notice that we added `--spacing(…)` and `--alpha(…)` as well, these are custom functions that Tailwind CSS provides, but we know what it will eventually map to internally. Last but not least, we also detect named colors and hex-based colors. Fixes: #20065 Closes: #20074 ## Test plan 1. Added an integration test, before the fix the test would've looked lik this: ```diff .drop-shadow-calc { - --tw-drop-shadow-size: drop-shadow(0 0 calc(1 * var(--spacing)) var(--tw-drop-shadow-color, black)); + --tw-drop-shadow-size: drop-shadow(0 0 var(--tw-drop-shadow-color, calc(1 * var(--spacing))) black); --tw-drop-shadow: drop-shadow(var(--drop-shadow-calc)); filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); } ``` 2. Added more tests related to the shadow replacement logic itself where we try to infer the color (or the length-values for x, y, blur, spread) 3. All other existing tests pass
1 parent 36417cb commit cea8c97

5 files changed

Lines changed: 183 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052))
1414
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
1515
- Ensure `not-*` correctly negates `@container` queries, including `style(…)` queries ([#20059](https://github.com/tailwindlabs/tailwindcss/pull/20059))
16+
- Ensure `drop-shadow-*` color utilities work with custom shadow values containing `calc(…)` ([#20080](https://github.com/tailwindlabs/tailwindcss/pull/20080))
1617

1718
## [4.3.0] - 2026-05-08
1819

packages/tailwindcss/src/utilities.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23847,6 +23847,7 @@ test('filter', async () => {
2384723847
'drop-shadow-red-500/50',
2384823848
'drop-shadow-none',
2384923849
'drop-shadow-inherit',
23850+
'drop-shadow-calc',
2385023851
'saturate-0',
2385123852
'saturate-[1.75]',
2385223853
'saturate-[var(--value)]',
@@ -23857,10 +23858,12 @@ test('filter', async () => {
2385723858
],
2385823859
css`
2385923860
@theme {
23861+
--spacing: 0.25rem;
2386023862
--blur-xl: 24px;
2386123863
--color-red-500: #ef4444;
2386223864
--drop-shadow: 0 1px 1px rgb(0 0 0 / 0.05);
2386323865
--drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
23866+
--drop-shadow-calc: 0 0 calc(1 * var(--spacing)) black;
2386423867
}
2386523868
@theme inline {
2386623869
--drop-shadow-multi: 0 1px 1px rgb(0 0 0 / 0.05), 0 9px 7px rgb(0 0 0 / 0.1);
@@ -23891,10 +23894,12 @@ test('filter', async () => {
2389123894
}
2389223895

2389323896
:root, :host {
23897+
--spacing: .25rem;
2389423898
--blur-xl: 24px;
2389523899
--color-red-500: #ef4444;
2389623900
--drop-shadow: 0 1px 1px #0000000d;
2389723901
--drop-shadow-xl: 0 9px 7px #0000001a;
23902+
--drop-shadow-calc: 0 0 calc(1 * var(--spacing)) black;
2389823903
}
2389923904

2390023905
.blur-\\[4px\\] {
@@ -23951,6 +23956,12 @@ test('filter', async () => {
2395123956
filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, );
2395223957
}
2395323958

23959+
.drop-shadow-calc {
23960+
--tw-drop-shadow-size: drop-shadow(0 0 calc(1 * var(--spacing)) var(--tw-drop-shadow-color, black));
23961+
--tw-drop-shadow: drop-shadow(var(--drop-shadow-calc));
23962+
filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, );
23963+
}
23964+
2395423965
.drop-shadow-multi {
2395523966
--tw-drop-shadow-size: drop-shadow(0 1px 1px var(--tw-drop-shadow-color, #0000000d)) drop-shadow(0 9px 7px var(--tw-drop-shadow-color, #0000001a));
2395623967
--tw-drop-shadow: drop-shadow(0 1px 1px #0000000d) drop-shadow(0 9px 7px #0000001a);

packages/tailwindcss/src/utils/is-color.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,7 @@ export function isColor(value: string): boolean {
202202
value.charCodeAt(0) === HASH || IS_COLOR_FN.test(value) || NAMED_COLORS.has(value.toLowerCase())
203203
)
204204
}
205+
206+
export function isNamedColor(value: string): boolean {
207+
return NAMED_COLORS.has(value.toLowerCase())
208+
}

packages/tailwindcss/src/utils/replace-shadow-colors.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2+
import { cartesian } from '../cartesian'
23
import { replaceAlpha } from '../utilities'
34
import { replaceShadowColors } from './replace-shadow-colors'
45

@@ -40,6 +41,59 @@ describe('without replacer', () => {
4041
expect(parsed).toMatchInlineSnapshot(`"1px 2px 3px 4px var(--tw-shadow-color, currentcolor)"`)
4142
})
4243

44+
it('should find the color regardless of its position', () => {
45+
for (let [x, y, blur, spread, color] of cartesian(
46+
['calc(var(--spacing) * 1)', '1', '--spacing(1)'], // x
47+
['calc(var(--spacing) * 2)', '2', '--spacing(2)'], // y
48+
['calc(var(--spacing) * 3)', '3', '--spacing(3)'], // blur
49+
['calc(var(--spacing) * 4)', '4', '--spacing(4)'], // spread
50+
['black', 'rgb(0, 0, 0)', '#000', '--alpha(var(--color) / 50%)', 'var(--uknown-color)'], // color
51+
)) {
52+
let expectedColor = `var(--tw-shadow-color, ${color})`
53+
54+
{
55+
let input = `${x} ${color} ${y} ${blur} ${spread}`
56+
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
57+
}
58+
59+
{
60+
let input = `${x} ${y} ${color} ${blur} ${spread}`
61+
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
62+
}
63+
64+
{
65+
let input = `${x} ${y} ${blur} ${color} ${spread}`
66+
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
67+
}
68+
}
69+
})
70+
71+
// When using `var(…)`, we don't know the types of the used variables, but we
72+
// might be able to find the color itself.
73+
it.each([
74+
'black', // Named color
75+
'#000', // Hex color
76+
'rgb(0, 0, 0)', // Color functions
77+
'--alpha(var(--color) / 50%)', // Known custom functions
78+
])('should find the color (%s)', (color) => {
79+
let expectedColor = `var(--tw-shadow-color, ${color})`
80+
81+
{
82+
let input = `var(--x) var(--y) ${color}`
83+
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
84+
}
85+
86+
{
87+
let input = `var(--x) var(--y) var(--blur) ${color}`
88+
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
89+
}
90+
91+
{
92+
let input = `var(--x) var(--y) var(--blur) var(--spread) ${color}`
93+
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
94+
}
95+
})
96+
4397
it('should handle multiple shadows', () => {
4498
let parsed = replaceShadowColors(
4599
['var(--my-shadow)', '1px 1px var(--my-color)', '0 0 1px var(--my-color)'].join(', '),

packages/tailwindcss/src/utils/replace-shadow-colors.ts

Lines changed: 113 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,134 @@
1+
import * as ValueParser from '../value-parser'
2+
import { walk, WalkAction } from '../walk'
3+
import { isNamedColor } from './is-color'
14
import { segment } from './segment'
25

36
const KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset'])
4-
const LENGTH = /^-?(\d+|\.\d+)(.*?)$/g
7+
const LENGTH_FUNCTIONS = new Set(['calc', 'clamp', 'max', 'min', '--spacing'])
8+
const COLOR_FUNCTIONS = new Set([
9+
'color',
10+
'color-mix',
11+
'contrast-color',
12+
'device-cmyk',
13+
'hsl',
14+
'hsla',
15+
'hwb',
16+
'lab',
17+
'lch',
18+
'light-dark',
19+
'oklab',
20+
'oklch',
21+
'rgb',
22+
'rgba',
23+
'--alpha',
24+
])
25+
const LENGTH = /^-?(\d+|\.\d+)(.*?)$/
526

627
export function replaceShadowColors(input: string, replacement: (color: string) => string) {
28+
function replaceAst(node: ValueParser.ValueAstNode): ValueParser.ValueAstNode[] {
29+
let color = ValueParser.toCss([node])
30+
let updatedColor = replacement(color)
31+
let ast = ValueParser.parse(updatedColor)
32+
return ast
33+
}
34+
735
let shadows = segment(input, ',').map((shadow) => {
836
shadow = shadow.trim()
9-
let parts = segment(shadow, ' ').filter((part) => part.trim() !== '')
10-
let color = null
11-
let offsetX = null
12-
let offsetY = null
13-
14-
for (let part of parts) {
15-
if (KEYWORDS.has(part)) {
16-
continue
17-
} else if (LENGTH.test(part)) {
18-
if (offsetX === null) {
19-
offsetX = part
20-
} else if (offsetY === null) {
21-
offsetY = part
37+
let ast = ValueParser.parse(shadow)
38+
39+
let unknown: ValueParser.ValueAstNode | null = null
40+
let unknowns = 0
41+
let lengths = 0
42+
let replaced = false
43+
44+
walk(ast, (node) => {
45+
switch (node.kind) {
46+
case 'word': {
47+
// Skip known keywords
48+
if (KEYWORDS.has(node.value.toLowerCase())) {
49+
return WalkAction.Continue
50+
}
51+
52+
// Must be a length
53+
if (LENGTH.test(node.value.toLowerCase())) {
54+
lengths++
55+
return WalkAction.Continue
56+
}
57+
58+
// Must be a color
59+
if (node.value[0] === '#' || isNamedColor(node.value)) {
60+
replaced = true
61+
return WalkAction.ReplaceStop(replaceAst(node))
62+
}
63+
64+
// We're not sure yet
65+
unknown = node
66+
unknowns++
67+
break
68+
}
69+
70+
case 'function': {
71+
// Must be a color
72+
if (COLOR_FUNCTIONS.has(node.value.toLowerCase())) {
73+
replaced = true
74+
return WalkAction.ReplaceStop(replaceAst(node))
75+
}
76+
77+
// Must be a length
78+
if (LENGTH_FUNCTIONS.has(node.value.toLowerCase())) {
79+
lengths++
80+
return WalkAction.Skip
81+
}
82+
83+
// We're not sure yet
84+
unknown = node
85+
unknowns++
86+
87+
// We're not interested in the arguments of the function
88+
return WalkAction.Skip
2289
}
2390

24-
// Reset index, since the regex is stateful.
25-
LENGTH.lastIndex = 0
26-
} else if (color === null) {
27-
color = part
91+
// Ignore separators
92+
case 'separator':
93+
return WalkAction.Continue
94+
95+
default:
96+
node satisfies never
2897
}
98+
})
99+
100+
// We definitely found a color, nothing else to do
101+
if (replaced) {
102+
return ValueParser.toCss(ast)
29103
}
30104

31105
// If the x and y offsets were not detected, the shadow is either invalid or
32106
// using a variable to represent more than one field in the shadow value, so
33107
// we can't know what to replace.
34-
if (offsetX === null || offsetY === null) return shadow
35-
36-
let replacementColor = replacement(color ?? 'currentcolor')
37-
38-
if (color !== null) {
39-
// If a color was found, replace the color.
40-
return shadow.replace(color, replacementColor)
108+
if (lengths < 2) {
109+
return shadow
41110
}
111+
42112
// If no color was found, assume the shadow is relying on the browser
43113
// default shadow color and append the replacement color.
44-
return `${shadow} ${replacementColor}`
114+
if (unknowns === 0) {
115+
return `${shadow} ${replacement('currentcolor')}`
116+
}
117+
118+
// A single left-over, we assume that this is the color
119+
if (unknowns === 1) {
120+
walk(ast, (node) => {
121+
if (node === unknown) {
122+
replaced = true
123+
return WalkAction.ReplaceStop(replaceAst(node))
124+
}
125+
126+
// Keep the walk top-level only, no need to go into functions
127+
return WalkAction.Skip
128+
})
129+
}
130+
131+
return replaced ? ValueParser.toCss(ast) : shadow
45132
})
46133

47134
return shadows.join(', ')

0 commit comments

Comments
 (0)