Skip to content

Commit 680c55c

Browse files
Normalize attribute selector for data-* and aria-* modifiers (#14037)
Fixes #14026 See #14040 for the v4 fix When translating `data-` and `aria-` modifiers with attribute selectors, we currently do not wrap the target attribute in quotes. This only works for keywords (purely alphabetic words) but breaks as soon as there are numbers or things like spaces in the attribute: ```html <div data-id="foo" class="data-[id=foo]:underline">underlined</div> <div data-id="f1" class="data-[id=1]:underline">not underlined</div> <div data-id="foo bar" class="data-[id=foo_bar]:underline">not underlined</div> ``` Since it's fairly common to have attribute selectors with `data-` and `aria-` modifiers, this PR will now wrap the attribute in quotes unless these are already wrapped. | Tailwind Modifier | CSS Selector | | ------------- | ------------- | | `.data-[id=foo]` | `[data-id='foo']` | | `.data-[id='foo']` | `[data-id='foo']` | | `.data-[id=foo_i]` | `[data-id='foo i']` | | `.data-[id='foo'_i]` | `[data-id='foo' i]` | | `.data-[id=123]` | `[data-id='123']` | --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 866860e commit 680c55c

File tree

4 files changed

+100
-13
lines changed

4 files changed

+100
-13
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
- Fix class detection in Slim templates with attached attributes and ID ([#14019](https://github.com/tailwindlabs/tailwindcss/pull/14019))
11+
- Attribute selectors in `data-*` and `aria-*` modifiers are now wrapped in quotation marks by default, allowing numbers and spaces in them ([#14037])(https://github.com/tailwindlabs/tailwindcss/pull/14037)
1112

1213
## [3.4.6] - 2024-07-16
1314

src/corePlugins.js

+15-11
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
import { formatBoxShadowValue, parseBoxShadowValue } from './util/parseBoxShadowValue'
2222
import { removeAlphaVariables } from './util/removeAlphaVariables'
2323
import { flagEnabled } from './featureFlags'
24-
import { normalize } from './util/dataTypes'
24+
import { normalize, normalizeAttributeSelectors } from './util/dataTypes'
2525
import { INTERNAL_FEATURES } from './lib/setupContextUtils'
2626

2727
export let variantPlugins = {
@@ -472,41 +472,45 @@ export let variantPlugins = {
472472
},
473473

474474
ariaVariants: ({ matchVariant, theme }) => {
475-
matchVariant('aria', (value) => `&[aria-${normalize(value)}]`, { values: theme('aria') ?? {} })
475+
matchVariant('aria', (value) => `&[aria-${normalizeAttributeSelectors(normalize(value))}]`, {
476+
values: theme('aria') ?? {},
477+
})
476478
matchVariant(
477479
'group-aria',
478480
(value, { modifier }) =>
479481
modifier
480-
? `:merge(.group\\/${modifier})[aria-${normalize(value)}] &`
481-
: `:merge(.group)[aria-${normalize(value)}] &`,
482+
? `:merge(.group\\/${modifier})[aria-${normalizeAttributeSelectors(normalize(value))}] &`
483+
: `:merge(.group)[aria-${normalizeAttributeSelectors(normalize(value))}] &`,
482484
{ values: theme('aria') ?? {} }
483485
)
484486
matchVariant(
485487
'peer-aria',
486488
(value, { modifier }) =>
487489
modifier
488-
? `:merge(.peer\\/${modifier})[aria-${normalize(value)}] ~ &`
489-
: `:merge(.peer)[aria-${normalize(value)}] ~ &`,
490+
? `:merge(.peer\\/${modifier})[aria-${normalizeAttributeSelectors(normalize(value))}] ~ &`
491+
: `:merge(.peer)[aria-${normalizeAttributeSelectors(normalize(value))}] ~ &`,
490492
{ values: theme('aria') ?? {} }
491493
)
492494
},
493495

494496
dataVariants: ({ matchVariant, theme }) => {
495-
matchVariant('data', (value) => `&[data-${normalize(value)}]`, { values: theme('data') ?? {} })
497+
matchVariant('data', (value) => `&[data-${normalizeAttributeSelectors(normalize(value))}]`, {
498+
values: theme('data') ?? {},
499+
})
496500
matchVariant(
497501
'group-data',
498502
(value, { modifier }) =>
499503
modifier
500-
? `:merge(.group\\/${modifier})[data-${normalize(value)}] &`
501-
: `:merge(.group)[data-${normalize(value)}] &`,
504+
? `:merge(.group\\/${modifier})[data-${normalizeAttributeSelectors(normalize(value))}] &`
505+
: `:merge(.group)[data-${normalizeAttributeSelectors(normalize(value))}] &`,
502506
{ values: theme('data') ?? {} }
503507
)
504508
matchVariant(
505509
'peer-data',
506510
(value, { modifier }) =>
507511
modifier
508-
? `:merge(.peer\\/${modifier})[data-${normalize(value)}] ~ &`
509-
: `:merge(.peer)[data-${normalize(value)}] ~ &`,
512+
? `:merge(.peer\\/${modifier})[data-${normalizeAttributeSelectors(normalize(value))}] ~ &`
513+
: `:merge(.peer)[data-${normalizeAttributeSelectors(normalize(value))}] ~ &`,
510514
{ values: theme('data') ?? {} }
511515
)
512516
},

src/util/dataTypes.js

+28
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,34 @@ export function normalize(value, context = null, isRoot = true) {
8181
return value
8282
}
8383

84+
export function normalizeAttributeSelectors(value) {
85+
// Wrap values in attribute selectors with quotes
86+
if (value.includes('=')) {
87+
value = value.replace(/(=.*)/g, (_fullMatch, match) => {
88+
if (match[1] === "'" || match[1] === '"') {
89+
return match
90+
}
91+
92+
// Handle regex flags on unescaped values
93+
if (match.length > 2) {
94+
let trailingCharacter = match[match.length - 1]
95+
if (
96+
match[match.length - 2] === ' ' &&
97+
(trailingCharacter === 'i' ||
98+
trailingCharacter === 'I' ||
99+
trailingCharacter === 's' ||
100+
trailingCharacter === 'S')
101+
) {
102+
return `="${match.slice(1, -2)}" ${match[match.length - 1]}`
103+
}
104+
}
105+
106+
return `="${match.slice(1)}"`
107+
})
108+
}
109+
return value
110+
}
111+
84112
/**
85113
* Add spaces around operators inside math functions
86114
* like calc() that do not follow an operator, '(', or `,`.

tests/arbitrary-variants.test.js

+56-2
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,32 @@ test('keeps escaped underscores with multiple arbitrary variants', () => {
442442
})
443443
})
444444

445+
test('does not add quotes on arbitrary variants', () => {
446+
let config = {
447+
content: [
448+
{
449+
raw: '<div class="[&[data-foo=\'1\']+.bar]:underline"></div>',
450+
},
451+
],
452+
corePlugins: { preflight: false },
453+
}
454+
455+
let input = css`
456+
@tailwind base;
457+
@tailwind components;
458+
@tailwind utilities;
459+
`
460+
461+
return run(input, config).then((result) => {
462+
expect(result.css).toMatchFormattedCss(css`
463+
${defaults}
464+
.\[\&\[data-foo\=\'1\'\]\+\.bar\]\:underline[data-foo='1']+.bar {
465+
text-decoration-line: underline;
466+
}
467+
`)
468+
})
469+
})
470+
445471
test('keeps escaped underscores in arbitrary variants mixed with normal variants', () => {
446472
let config = {
447473
content: [
@@ -601,6 +627,7 @@ it('should support aria variants', () => {
601627
<div>
602628
<div class="aria-checked:underline"></div>
603629
<div class="aria-[sort=ascending]:underline"></div>
630+
<div class="aria-[valuenow=1]:underline"></div>
604631
<div class="aria-[labelledby='a_b']:underline"></div>
605632
<div class="group-aria-checked:underline"></div>
606633
<div class="peer-aria-checked:underline"></div>
@@ -610,6 +637,8 @@ it('should support aria variants', () => {
610637
<div class="peer-aria-[sort=ascending]:underline"></div>
611638
<div class="group-aria-[labelledby='a_b']:underline"></div>
612639
<div class="peer-aria-[labelledby='a_b']:underline"></div>
640+
<div class="group-aria-[valuenow=1]:underline"></div>
641+
<div class="peer-aria-[valuenow=1]:underline"></div>
613642
<div class="group-aria-[sort=ascending]/foo:underline"></div>
614643
<div class="peer-aria-[sort=ascending]/foo:underline"></div>
615644
</div>
@@ -629,16 +658,19 @@ it('should support aria variants', () => {
629658
.aria-checked\:underline[aria-checked='true'],
630659
.aria-\[labelledby\=\'a_b\'\]\:underline[aria-labelledby='a b'],
631660
.aria-\[sort\=ascending\]\:underline[aria-sort='ascending'],
661+
.aria-\[valuenow\=1\]\:underline[aria-valuenow='1'],
632662
.group\/foo[aria-checked='true'] .group-aria-checked\/foo\:underline,
633663
.group[aria-checked='true'] .group-aria-checked\:underline,
634664
.group[aria-labelledby='a b'] .group-aria-\[labelledby\=\'a_b\'\]\:underline,
635665
.group\/foo[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\/foo\:underline,
636666
.group[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\:underline,
667+
.group[aria-valuenow='1'] .group-aria-\[valuenow\=1\]\:underline,
637668
.peer\/foo[aria-checked='true'] ~ .peer-aria-checked\/foo\:underline,
638669
.peer[aria-checked='true'] ~ .peer-aria-checked\:underline,
639670
.peer[aria-labelledby='a b'] ~ .peer-aria-\[labelledby\=\'a_b\'\]\:underline,
640671
.peer\/foo[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\/foo\:underline,
641-
.peer[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\:underline {
672+
.peer[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\:underline,
673+
.peer[aria-valuenow='1'] ~ .peer-aria-\[valuenow\=1\]\:underline {
642674
text-decoration-line: underline;
643675
}
644676
`)
@@ -657,8 +689,11 @@ it('should support data variants', () => {
657689
raw: html`
658690
<div>
659691
<div class="data-checked:underline"></div>
660-
<div class="data-[position=top]:underline"></div>
661692
<div class="data-[foo='bar_baz']:underline"></div>
693+
<div class="data-[id$='foo'_s]:underline"></div>
694+
<div class="data-[id$=foo_bar_s]:underline"></div>
695+
<div class="data-[id=0]:underline"></div>
696+
<div class="data-[position=top]:underline"></div>
662697
<div class="group-data-checked:underline"></div>
663698
<div class="peer-data-checked:underline"></div>
664699
<div class="group-data-checked/foo:underline"></div>
@@ -667,6 +702,12 @@ it('should support data variants', () => {
667702
<div class="peer-data-[position=top]:underline"></div>
668703
<div class="group-data-[foo='bar_baz']:underline"></div>
669704
<div class="peer-data-[foo='bar_baz']:underline"></div>
705+
<div class="group-data-[id$='foo'_s]:underline"></div>
706+
<div class="group-data-[id$=foo_bar_s]:underline"></div>
707+
<div class="group-data-[id=0]:underline"></div>
708+
<div class="peer-data-[id$='foo'_s]:underline"></div>
709+
<div class="peer-data-[id$=foo_bar_s]:underline"></div>
710+
<div class="peer-data-[id=0]:underline"></div>
670711
<div class="group-data-[position=top]/foo:underline"></div>
671712
<div class="peer-data-[position=top]/foo:underline"></div>
672713
</div>
@@ -685,15 +726,24 @@ it('should support data variants', () => {
685726
.underline,
686727
.data-checked\:underline[data-ui~='checked'],
687728
.data-\[foo\=\'bar_baz\'\]\:underline[data-foo='bar baz'],
729+
.data-\[id\$\=\'foo\'_s\]\:underline[data-id$='foo' s],
730+
.data-\[id\$\=foo_bar_s\]\:underline[data-id$='foo bar' s],
731+
.data-\[id\=0\]\:underline[data-id='0'],
688732
.data-\[position\=top\]\:underline[data-position='top'],
689733
.group\/foo[data-ui~='checked'] .group-data-checked\/foo\:underline,
690734
.group[data-ui~='checked'] .group-data-checked\:underline,
691735
.group[data-foo='bar baz'] .group-data-\[foo\=\'bar_baz\'\]\:underline,
736+
.group[data-id$='foo' s] .group-data-\[id\$\=\'foo\'_s\]\:underline,
737+
.group[data-id$='foo bar' s] .group-data-\[id\$\=foo_bar_s\]\:underline,
738+
.group[data-id='0'] .group-data-\[id\=0\]\:underline,
692739
.group\/foo[data-position='top'] .group-data-\[position\=top\]\/foo\:underline,
693740
.group[data-position='top'] .group-data-\[position\=top\]\:underline,
694741
.peer\/foo[data-ui~='checked'] ~ .peer-data-checked\/foo\:underline,
695742
.peer[data-ui~='checked'] ~ .peer-data-checked\:underline,
696743
.peer[data-foo='bar baz'] ~ .peer-data-\[foo\=\'bar_baz\'\]\:underline,
744+
.peer[data-id$='foo' s] ~ .peer-data-\[id\$\=\'foo\'_s\]\:underline,
745+
.peer[data-id$='foo bar' s] ~ .peer-data-\[id\$\=foo_bar_s\]\:underline,
746+
.peer[data-id='0'] ~ .peer-data-\[id\=0\]\:underline,
697747
.peer\/foo[data-position='top'] ~ .peer-data-\[position\=top\]\/foo\:underline,
698748
.peer[data-position='top'] ~ .peer-data-\[position\=top\]\:underline {
699749
text-decoration-line: underline;
@@ -799,6 +849,7 @@ test('has-* variants with arbitrary values', () => {
799849
<div class="has-[+_h2]:grid"></div>
800850
<div class="has-[>_h1_+_h2]:contents"></div>
801851
<div class="has-[h2]:has-[.banana]:hidden"></div>
852+
<div class="has-[[data-foo='1']+div]:font-bold"></div>
802853
</div>
803854
`,
804855
},
@@ -836,6 +887,9 @@ test('has-* variants with arbitrary values', () => {
836887
.has-\[h2\]\:has-\[\.banana\]\:hidden:has(.banana):has(h2) {
837888
display: none;
838889
}
890+
.has-\[\[data-foo\=\'1\'\]\+div\]\:font-bold:has([data-foo='1'] + div) {
891+
font-weight: 700;
892+
}
839893
`)
840894
})
841895
})

0 commit comments

Comments
 (0)