Re-introduce automatic var injection shorthand (#15020)

This PR re-introduces the automatic var injection feature.

For some backstory, we used to support classes such as `bg-[--my-color]`
that resolved as-if you wrote `bg-[var(--my-color)]`.

The is issue is that some newer CSS properties accepts dashed-idents
(without the `var(…)`). This means that some properties accept
`view-timeline-name: --my-name;` (see:
https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name).

To make this a tiny bit worse, these properties _also_ accept
`var(--my-name-reference)` where the variable `--my-name-reference`
could reference a dashed-ident such as `--my-name`.

This makes the `bg-[--my-color]` ambiguous because we don't know if you
want `var(--my-color)` or `--my-color`.

With this PR, we bring back the automatic var injection feature as
syntactic sugar, but we use a different syntax to avoid the ambiguity.
Instead of `bg-[--my-color]`, you can now write `bg-(--my-color)` to get
the same effect as `bg-[var(--my-color)]`.

This also applies to modifiers, so `bg-red-500/[var(--my-opacity)]` can
be written as `bg-red-500/(--my-opacity)`. To go full circle, you can
rewrite `bg-[var(--my-color)]/[var(--my-opacity)]` as
`bg-(--my-color)/(--my-opacity)`.

---

This is implemented as syntactical sugar at the parsing stage and
handled when re-printing. Internally the system (and every plugin) still
see the proper `var(--my-color)` value.

Since this is also handled during printing of the candidate, codemods
don't need to be changed but they will provide the newly updated syntax.

E.g.: running this on the Catalyst codebase, you'll now see changes like
this:
<img width="542" alt="image"
src="https://github.com/user-attachments/assets/8f0e26f8-f4c9-4cdc-9f28-52307c38610e">

Whereas before we converted this to the much longer
`min-w-[var(--button-width)]`.

---

Additionally, this required some changes to the Oxide scanner to make
sure that `(` and `)` are valid characters for arbitrary-like values.

---------

Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
Robin Malfait 2024-11-18 15:47:48 +01:00 committed by GitHub
parent 9c3bfd6bb7
commit 3dc3bad781
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 352 additions and 76 deletions

View File

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Reintroduce `max-w-screen-*` utilities that read from the `--breakpoint` namespace as deprecated utilities ([#15013](https://github.com/tailwindlabs/tailwindcss/pull/15013))
- Support using CSS variables as arbitrary values without `var(…)` by using parentheses instead of square brackets (e.g. `bg-(--my-color)`) ([#15020](https://github.com/tailwindlabs/tailwindcss/pull/15020))
### Fixed

View File

@ -39,6 +39,24 @@ pub struct ExtractorOptions {
pub preserve_spaces_in_arbitrary: bool,
}
#[derive(Debug, PartialEq, Eq, Clone)]
enum Arbitrary {
/// Not inside any arbitrary value
None,
/// In arbitrary value mode with square brackets
///
/// E.g.: `bg-[…]`
/// ^
Brackets { start_idx: usize },
/// In arbitrary value mode with parens
///
/// E.g.: `bg-(…)`
/// ^
Parens { start_idx: usize },
}
pub struct Extractor<'a> {
opts: ExtractorOptions,
@ -48,9 +66,9 @@ pub struct Extractor<'a> {
idx_start: usize,
idx_end: usize,
idx_last: usize,
idx_arbitrary_start: usize,
in_arbitrary: bool,
arbitrary: Arbitrary,
in_candidate: bool,
in_escape: bool,
@ -105,9 +123,8 @@ impl<'a> Extractor<'a> {
idx_start: 0,
idx_end: 0,
idx_arbitrary_start: 0,
in_arbitrary: false,
arbitrary: Arbitrary::None,
in_candidate: false,
in_escape: false,
@ -461,7 +478,7 @@ impl<'a> Extractor<'a> {
#[inline(always)]
fn parse_arbitrary(&mut self) -> ParseAction<'a> {
// In this we could technically use memchr 6 times (then looped) to find the indexes / bounds of arbitrary valuesq
// In this we could technically use memchr 6 times (then looped) to find the indexes / bounds of arbitrary values
if self.in_escape {
return self.parse_escaped();
}
@ -479,9 +496,29 @@ impl<'a> Extractor<'a> {
self.bracket_stack.pop();
}
// Last bracket is different compared to what we expect, therefore we are not in a
// valid arbitrary value.
_ if !self.in_quotes() => return ParseAction::Skip,
// This is the last bracket meaning the end of arbitrary content
_ if !self.in_quotes() => {
if matches!(self.cursor.next, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') {
return ParseAction::Consume;
}
if let Arbitrary::Parens { start_idx } = self.arbitrary {
trace!("Arbitrary::End\t");
self.arbitrary = Arbitrary::None;
if self.cursor.pos - start_idx == 1 {
// We have an empty arbitrary value, which is not allowed
return ParseAction::Skip;
}
// We have a valid arbitrary value
return ParseAction::Consume;
}
// Last parenthesis is different compared to what we expect, therefore we are
// not in a valid arbitrary value.
return ParseAction::Skip;
}
// We're probably in quotes or nested brackets, so we keep going
_ => {}
@ -501,12 +538,14 @@ impl<'a> Extractor<'a> {
return ParseAction::Consume;
}
trace!("Arbitrary::End\t");
self.in_arbitrary = false;
if let Arbitrary::Brackets { start_idx } = self.arbitrary {
trace!("Arbitrary::End\t");
self.arbitrary = Arbitrary::None;
if self.cursor.pos - self.idx_arbitrary_start == 1 {
// We have an empty arbitrary value, which is not allowed
return ParseAction::Skip;
if self.cursor.pos - start_idx == 1 {
// We have an empty arbitrary value, which is not allowed
return ParseAction::Skip;
}
}
}
@ -531,9 +570,13 @@ impl<'a> Extractor<'a> {
b' ' if !self.opts.preserve_spaces_in_arbitrary => {
trace!("Arbitrary::SkipAndEndEarly\t");
// Restart the parser ahead of the arbitrary value
// It may pick up more candidates
return ParseAction::RestartAt(self.idx_arbitrary_start + 1);
if let Arbitrary::Brackets { start_idx } | Arbitrary::Parens { start_idx } =
self.arbitrary
{
// Restart the parser ahead of the arbitrary value It may pick up more
// candidates
return ParseAction::RestartAt(start_idx + 1);
}
}
// Arbitrary values allow any character inside them
@ -550,11 +593,12 @@ impl<'a> Extractor<'a> {
#[inline(always)]
fn parse_start(&mut self) -> ParseAction<'a> {
match self.cursor.curr {
// Enter arbitrary value mode
// Enter arbitrary property mode
b'[' => {
trace!("Arbitrary::Start\t");
self.in_arbitrary = true;
self.idx_arbitrary_start = self.cursor.pos;
self.arbitrary = Arbitrary::Brackets {
start_idx: self.cursor.pos,
};
ParseAction::Consume
}
@ -584,22 +628,31 @@ impl<'a> Extractor<'a> {
#[inline(always)]
fn parse_continue(&mut self) -> ParseAction<'a> {
match self.cursor.curr {
// Enter arbitrary value mode
// Enter arbitrary value mode. E.g.: `bg-[rgba(0, 0, 0)]`
// ^
b'[' if matches!(
self.cursor.prev,
b'@' | b'-' | b' ' | b':' | b'/' | b'!' | b'\0'
) =>
{
trace!("Arbitrary::Start\t");
self.in_arbitrary = true;
self.idx_arbitrary_start = self.cursor.pos;
self.arbitrary = Arbitrary::Brackets {
start_idx: self.cursor.pos,
};
}
// Can't enter arbitrary value mode
// This can't be a candidate
b'[' => {
trace!("Arbitrary::Skip_Start\t");
// Enter arbitrary value mode. E.g.: `bg-(--my-color)`
// ^
b'(' if matches!(self.cursor.prev, b'-' | b'/') => {
trace!("Arbitrary::Start\t");
self.arbitrary = Arbitrary::Parens {
start_idx: self.cursor.pos,
};
}
// Can't enter arbitrary value mode. This can't be a candidate.
b'[' | b'(' => {
trace!("Arbitrary::Skip_Start\t");
return ParseAction::Skip;
}
@ -684,7 +737,7 @@ impl<'a> Extractor<'a> {
#[inline(always)]
fn can_be_candidate(&mut self) -> bool {
self.in_candidate
&& !self.in_arbitrary
&& matches!(self.arbitrary, Arbitrary::None)
&& (0..=127).contains(&self.cursor.curr)
&& (self.idx_start == 0 || self.input[self.idx_start - 1] <= 127)
}
@ -696,13 +749,13 @@ impl<'a> Extractor<'a> {
self.idx_start = self.cursor.pos;
self.idx_end = self.cursor.pos;
self.in_candidate = false;
self.in_arbitrary = false;
self.arbitrary = Arbitrary::None;
self.in_escape = false;
}
#[inline(always)]
fn parse_char(&mut self) -> ParseAction<'a> {
if self.in_arbitrary {
if !matches!(self.arbitrary, Arbitrary::None) {
self.parse_arbitrary()
} else if self.in_candidate {
self.parse_continue()
@ -732,9 +785,8 @@ impl<'a> Extractor<'a> {
self.idx_start = pos;
self.idx_end = pos;
self.idx_arbitrary_start = 0;
self.in_arbitrary = false;
self.arbitrary = Arbitrary::None;
self.in_candidate = false;
self.in_escape = false;
@ -977,6 +1029,18 @@ mod test {
assert_eq!(candidates, vec!["m-[2px]"]);
}
#[test]
fn it_can_parse_utilities_with_arbitrary_var_shorthand() {
let candidates = run("m-(--my-var)", false);
assert_eq!(candidates, vec!["m-(--my-var)"]);
}
#[test]
fn it_can_parse_utilities_with_arbitrary_var_shorthand_as_modifier() {
let candidates = run("bg-(--my-color)/(--my-opacity)", false);
assert_eq!(candidates, vec!["bg-(--my-color)/(--my-opacity)"]);
}
#[test]
fn it_throws_away_arbitrary_values_that_are_unbalanced() {
let candidates = run("m-[calc(100px*2]", false);

View File

@ -100,7 +100,7 @@ test(
--- ./src/index.html ---
<h1>🤠👋</h1>
<div
class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)] max-w-[var(--breakpoint-md)] ml-[var(--breakpoint-md)]"
class="flex! sm:block! bg-linear-to-t bg-(--my-red) max-w-(--breakpoint-md) ml-(--breakpoint-md)"
></div>
<!-- Migrate to sm -->
<div class="blur-sm shadow-sm rounded-sm inset-shadow-sm drop-shadow-sm"></div>
@ -151,9 +151,9 @@ test(
candidate`flex!`,
candidate`sm:block!`,
candidate`bg-linear-to-t`,
candidate`bg-[var(--my-red)]`,
candidate`max-w-[var(--breakpoint-md)]`,
candidate`ml-[var(--breakpoint-md)`,
candidate`bg-(--my-red)`,
candidate`max-w-(--breakpoint-md)`,
candidate`ml-(--breakpoint-md)`,
])
},
)
@ -639,7 +639,7 @@ test(
'src/index.html',
// prettier-ignore
js`
<div class="bg-[var(--my-red)]"></div>
<div class="bg-(--my-red)"></div>
`,
)
@ -798,7 +798,7 @@ test(
'src/index.html',
// prettier-ignore
js`
<div class="bg-[var(--my-red)]"></div>
<div class="bg-(--my-red)"></div>
`,
)
@ -873,7 +873,7 @@ test(
'src/index.html',
// prettier-ignore
js`
<div class="bg-[var(--my-red)]"></div>
<div class="bg-(--my-red)"></div>
`,
)
@ -1447,7 +1447,7 @@ test(
"
--- ./src/index.html ---
<div
class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)]"
class="flex! sm:block! bg-linear-to-t bg-(--my-red)"
></div>
--- ./src/root.1.css ---
@ -1664,7 +1664,7 @@ test(
"
--- ./src/index.html ---
<div
class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)]"
class="flex! sm:block! bg-linear-to-t bg-(--my-red)"
></div>
--- ./src/index.css ---
@ -1799,7 +1799,7 @@ test(
"
--- ./src/index.html ---
<div
class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)]"
class="flex! sm:block! bg-linear-to-t bg-(--my-red)"
></div>
--- ./src/index.css ---

View File

@ -123,7 +123,7 @@ const candidates = [
['bg-[no-repeat_url(/image_13.png)]', 'bg-[no-repeat_url(/image_13.png)]'],
[
'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]',
'bg-[var(--spacing-0_5,var(--spacing-1_5,3rem))]',
'bg-(--spacing-0_5,var(--spacing-1_5,3rem))',
],
]

View File

@ -42,12 +42,16 @@ export function printCandidate(designSystem: DesignSystem, candidate: Candidate)
if (candidate.value) {
if (candidate.value.kind === 'arbitrary') {
if (candidate.value === null) {
base += ''
} else if (candidate.value.dataType) {
base += `-[${candidate.value.dataType}:${printArbitraryValue(candidate.value.value)}]`
} else {
base += `-[${printArbitraryValue(candidate.value.value)}]`
if (candidate.value !== null) {
let isVarValue = isVar(candidate.value.value)
let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
if (candidate.value.dataType) {
base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}`
} else {
base += `-${open}${printArbitraryValue(value)}${close}`
}
}
} else if (candidate.value.kind === 'named') {
base += `-${candidate.value.value}`
@ -63,8 +67,12 @@ export function printCandidate(designSystem: DesignSystem, candidate: Candidate)
// Handle modifier
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
if (candidate.modifier) {
let isVarValue = isVar(candidate.modifier.value)
let value = isVarValue ? candidate.modifier.value.slice(4, -1) : candidate.modifier.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
if (candidate.modifier.kind === 'arbitrary') {
base += `/[${printArbitraryValue(candidate.modifier.value)}]`
base += `/${open}${printArbitraryValue(value)}${close}`
} else if (candidate.modifier.kind === 'named') {
base += `/${candidate.modifier.value}`
}
@ -99,7 +107,11 @@ function printVariant(variant: Variant) {
base += variant.root
if (variant.value) {
if (variant.value.kind === 'arbitrary') {
base += `-[${printArbitraryValue(variant.value.value)}]`
let isVarValue = isVar(variant.value.value)
let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
base += `-${open}${printArbitraryValue(value)}${close}`
} else if (variant.value.kind === 'named') {
base += `-${variant.value.value}`
}
@ -246,9 +258,15 @@ function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
break
}
case 'separator':
case 'word': {
node.value = escapeUnderscore(node.value)
break
case 'word': {
// Dashed idents and variables `var(--my-var)` and `--my-var` should not
// have underscores escaped
if (node.value[0] !== '-' && node.value[1] !== '-') {
node.value = escapeUnderscore(node.value)
}
break
}
default:
never(node)
@ -256,6 +274,11 @@ function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
}
}
function isVar(value: string) {
let ast = ValueParser.parse(value)
return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var'
}
function never(value: never): never {
throw new Error(`Unexpected value: ${value}`)
}

View File

@ -9,10 +9,10 @@ test.each([
['[--my-color:--my-other-color]', '[--my-color:var(--my-other-color)]'],
// Arbitrary values for functional candidates
['bg-[--my-color]', 'bg-[var(--my-color)]'],
['bg-[color:--my-color]', 'bg-[color:var(--my-color)]'],
['border-[length:--my-length]', 'border-[length:var(--my-length)]'],
['border-[line-width:--my-width]', 'border-[line-width:var(--my-width)]'],
['bg-[--my-color]', 'bg-(--my-color)'],
['bg-[color:--my-color]', 'bg-(color:--my-color)'],
['border-[length:--my-length]', 'border-(length:--my-length)'],
['border-[line-width:--my-width]', 'border-(line-width:--my-width)'],
// Can clean up the workaround for opting out of automatic var injection
['bg-[_--my-color]', 'bg-[--my-color]'],
@ -21,19 +21,19 @@ test.each([
['border-[line-width:_--my-width]', 'border-[line-width:--my-width]'],
// Modifiers
['[color:--my-color]/[--my-opacity]', '[color:var(--my-color)]/[var(--my-opacity)]'],
['bg-red-500/[--my-opacity]', 'bg-red-500/[var(--my-opacity)]'],
['bg-[--my-color]/[--my-opacity]', 'bg-[var(--my-color)]/[var(--my-opacity)]'],
['bg-[color:--my-color]/[--my-opacity]', 'bg-[color:var(--my-color)]/[var(--my-opacity)]'],
['[color:--my-color]/[--my-opacity]', '[color:var(--my-color)]/(--my-opacity)'],
['bg-red-500/[--my-opacity]', 'bg-red-500/(--my-opacity)'],
['bg-[--my-color]/[--my-opacity]', 'bg-(--my-color)/(--my-opacity)'],
['bg-[color:--my-color]/[--my-opacity]', 'bg-(color:--my-color)/(--my-opacity)'],
// Can clean up the workaround for opting out of automatic var injection
['[color:--my-color]/[_--my-opacity]', '[color:var(--my-color)]/[--my-opacity]'],
['bg-red-500/[_--my-opacity]', 'bg-red-500/[--my-opacity]'],
['bg-[--my-color]/[_--my-opacity]', 'bg-[var(--my-color)]/[--my-opacity]'],
['bg-[color:--my-color]/[_--my-opacity]', 'bg-[color:var(--my-color)]/[--my-opacity]'],
['bg-[--my-color]/[_--my-opacity]', 'bg-(--my-color)/[--my-opacity]'],
['bg-[color:--my-color]/[_--my-opacity]', 'bg-(color:--my-color)/[--my-opacity]'],
// Variants
['supports-[--test]:flex', 'supports-[var(--test)]:flex'],
['supports-[--test]:flex', 'supports-(--test):flex'],
['supports-[_--test]:flex', 'supports-[--test]:flex'],
// Some properties never had var() injection in v3.

View File

@ -19,11 +19,11 @@ test.each([
// Convert to `var(…)` if we can resolve the path
['[color:theme(colors.red.500)]', '[color:var(--color-red-500)]'], // Arbitrary property
['[color:theme(colors.red.500)]/50', '[color:var(--color-red-500)]/50'], // Arbitrary property + modifier
['bg-[theme(colors.red.500)]', 'bg-[var(--color-red-500)]'], // Arbitrary value
['bg-[theme(colors.red.500)]', 'bg-(--color-red-500)'], // Arbitrary value
['bg-[size:theme(spacing.4)]', 'bg-[size:calc(var(--spacing)*4)]'], // Arbitrary value + data type hint
// Convert to `var(…)` if we can resolve the path, but keep fallback values
['bg-[theme(colors.red.500,red)]', 'bg-[var(--color-red-500,red)]'],
['bg-[theme(colors.red.500,red)]', 'bg-(--color-red-500,red)'],
// Keep `theme(…)` if we can't resolve the path
['bg-[theme(colors.foo.1000)]', 'bg-[theme(colors.foo.1000)]'],
@ -66,13 +66,13 @@ test.each([
// Arbitrary property, with more complex modifier (we only allow whole numbers
// as bare modifiers). Convert the complex numbers to arbitrary values instead.
['[color:theme(colors.red.500/12.34%)]', '[color:var(--color-red-500)]/[12.34%]'],
['[color:theme(colors.red.500/var(--opacity))]', '[color:var(--color-red-500)]/[var(--opacity)]'],
['[color:theme(colors.red.500/var(--opacity))]', '[color:var(--color-red-500)]/(--opacity)'],
['[color:theme(colors.red.500/.12345)]', '[color:var(--color-red-500)]/[12.345]'],
['[color:theme(colors.red.500/50.25%)]', '[color:var(--color-red-500)]/[50.25%]'],
// Arbitrary value
['bg-[theme(colors.red.500/75%)]', 'bg-[var(--color-red-500)]/75'],
['bg-[theme(colors.red.500/12.34%)]', 'bg-[var(--color-red-500)]/[12.34%]'],
['bg-[theme(colors.red.500/75%)]', 'bg-(--color-red-500)/75'],
['bg-[theme(colors.red.500/12.34%)]', 'bg-(--color-red-500)/[12.34%]'],
// Arbitrary property that already contains a modifier
['[color:theme(colors.red.500/50%)]/50', '[color:theme(--color-red-500/50%)]/50'],
@ -109,8 +109,8 @@ test.each([
['[--foo:theme(transitionDuration.500)]', '[--foo:theme(transitionDuration.500)]'],
// Renamed theme keys
['max-w-[theme(screens.md)]', 'max-w-[var(--breakpoint-md)]'],
['w-[theme(maxWidth.md)]', 'w-[var(--container-md)]'],
['max-w-[theme(screens.md)]', 'max-w-(--breakpoint-md)'],
['w-[theme(maxWidth.md)]', 'w-(--container-md)'],
// Invalid cases
['[--foo:theme(colors.red.500/50/50)]', '[--foo:theme(colors.red.500/50/50)]'],

View File

@ -167,6 +167,35 @@ it('should parse a simple utility with an arbitrary variant', () => {
`)
})
it('should parse an arbitrary variant using the automatic var shorthand', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])
let variants = new Variants()
variants.functional('supports', () => {})
expect(run('supports-(--test):flex', { utilities, variants })).toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "static",
"raw": "supports-(--test):flex",
"root": "flex",
"variants": [
{
"kind": "functional",
"modifier": null,
"root": "supports",
"value": {
"kind": "arbitrary",
"value": "var(--test)",
},
},
],
},
]
`)
})
it('should parse a simple utility with a parameterized variant', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])
@ -511,6 +540,29 @@ it('should parse a utility with an arbitrary value', () => {
`)
})
it('should parse a utility with an arbitrary value with parens', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])
expect(run('bg-(--my-color)', { utilities })).toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": null,
"raw": "bg-(--my-color)",
"root": "bg",
"value": {
"dataType": null,
"kind": "arbitrary",
"value": "var(--my-color)",
},
"variants": [],
},
]
`)
})
it('should parse a utility with an arbitrary value including a typehint', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])
@ -534,6 +586,52 @@ it('should parse a utility with an arbitrary value including a typehint', () =>
`)
})
it('should parse a utility with an arbitrary value with parens including a typehint', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])
expect(run('bg-(color:--my-color)', { utilities })).toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": null,
"raw": "bg-(color:--my-color)",
"root": "bg",
"value": {
"dataType": "color",
"kind": "arbitrary",
"value": "var(--my-color)",
},
"variants": [],
},
]
`)
})
it('should parse a utility with an arbitrary value with parens and a fallback', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])
expect(run('bg-(color:--my-color,#0088cc)', { utilities })).toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": null,
"raw": "bg-(color:--my-color,#0088cc)",
"root": "bg",
"value": {
"dataType": "color",
"kind": "arbitrary",
"value": "var(--my-color,#0088cc)",
},
"variants": [],
},
]
`)
})
it('should parse a utility with an arbitrary value with a modifier', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])
@ -757,6 +855,32 @@ it('should parse a utility with an implicit variable as the modifier', () => {
`)
})
it('should parse a utility with an implicit variable as the modifier using the shorthand', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])
expect(run('bg-red-500/(--value)', { utilities })).toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": {
"kind": "arbitrary",
"value": "var(--value)",
},
"raw": "bg-red-500/(--value)",
"root": "bg",
"value": {
"fraction": null,
"kind": "named",
"value": "red-500",
},
"variants": [],
},
]
`)
})
it('should parse a utility with an implicit variable as the modifier that is important', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])

View File

@ -14,6 +14,9 @@ type ArbitraryUtilityValue = {
* ```
* bg-[color:var(--my-color)]
* ^^^^^
*
* bg-(color:--my-color)
* ^^^^^
* ```
*/
dataType: string | null
@ -25,6 +28,9 @@ type ArbitraryUtilityValue = {
*
* bg-[var(--my_variable)]
* ^^^^^^^^^^^^^^^^^^
*
* bg-(--my_variable)
* ^^^^^^^^^^^^^^
* ```
*/
value: string
@ -340,9 +346,9 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
// ^^ -> Root
// ^^^^^^^^^ -> Arbitrary value
//
// bg-red-[#0088cc]
// ^^^^^^ -> Root
// ^^^^^^^^^ -> Arbitrary value
// border-l-[#0088cc]
// ^^^^^^^^ -> Root
// ^^^^^^^^^ -> Arbitrary value
// ```
if (baseWithoutModifier[baseWithoutModifier.length - 1] === ']') {
let idx = baseWithoutModifier.indexOf('-[')
@ -359,6 +365,43 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
roots = [[root, value]]
}
// If the base of the utility ends with a `)`, then we know it's an arbitrary
// value that encapsulates a CSS variable. This also means that everything
// before the `(…)` part should be the root of the utility.
//
// E.g.:
//
// bg-(--my-var)
// ^^ -> Root
// ^^^^^^^^^^ -> Arbitrary value
// ```
else if (baseWithoutModifier[baseWithoutModifier.length - 1] === ')') {
let idx = baseWithoutModifier.indexOf('-(')
if (idx === -1) return
let root = baseWithoutModifier.slice(0, idx)
// The root of the utility should exist as-is in the utilities map. If not,
// it's an invalid utility and we can skip continue parsing.
if (!designSystem.utilities.has(root, 'functional')) return
let value = baseWithoutModifier.slice(idx + 2, -1)
let parts = segment(value, ':')
let dataType = null
if (parts.length === 2) {
dataType = parts[0]
value = parts[1]
}
// An arbitrary value with `(…)` should always start with `--` since it
// represents a CSS variable.
if (value[0] !== '-' && value[1] !== '-') return
roots = [[root, dataType === null ? `[var(${value})]` : `[${dataType}:var(${value})]`]]
}
// Not an arbitrary value
else {
roots = findRoots(baseWithoutModifier, (root: string) => {
@ -446,6 +489,15 @@ function parseModifier(modifier: string): CandidateModifier {
}
}
if (modifier[0] === '(' && modifier[modifier.length - 1] === ')') {
let arbitraryValue = modifier.slice(1, -1)
return {
kind: 'arbitrary',
value: decodeArbitraryValue(`var(${arbitraryValue})`),
}
}
return {
kind: 'named',
value: modifier,
@ -546,6 +598,18 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
}
}
if (value[0] === '(' && value[value.length - 1] === ')') {
return {
kind: 'functional',
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: {
kind: 'arbitrary',
value: decodeArbitraryValue(`var(${value.slice(1, -1)})`),
},
}
}
return {
kind: 'functional',
root,

View File

@ -127,7 +127,7 @@ describe('compiling CSS', () => {
@tailwind utilities;
`,
[
'bg-[no-repeat_url(./my_file.jpg)',
'bg-[no-repeat_url(./my_file.jpg)]',
'ml-[var(--spacing-1_5,_var(--spacing-2_5,_1rem))]',
'ml-[theme(--spacing-1_5,theme(--spacing-2_5,_1rem)))]',
],
@ -146,8 +146,8 @@ describe('compiling CSS', () => {
margin-left: var(--spacing-1_5, var(--spacing-2_5, 1rem));
}
.bg-\\[no-repeat_url\\(\\.\\/my_file\\.jpg\\) {
background-color: no-repeat url("./")my file. jpg;
.bg-\\[no-repeat_url\\(\\.\\/my_file\\.jpg\\)\\] {
background-color: no-repeat url("./my_file.jpg");
}"
`)
})

View File

@ -365,7 +365,7 @@ test('Functional utilities from plugins are listed in hovers and completions', a
expect(classNames).not.toContain('custom-2-unknown')
// matchUtilities with a any modifiers
// matchUtilities with any modifiers
expect(classNames).toContain('custom-3-red')
expect(classMap.get('custom-3-red')?.modifiers).toEqual([])