Skip to content

Commit 300524b

Browse files
Normalize attribute selectors for data-* and aria-* modifiers (#14040)
Fixes #14026 See #14037 for the v3 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 <[email protected]>
1 parent 6ab2893 commit 300524b

File tree

3 files changed

+126
-2
lines changed

3 files changed

+126
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Add missing utilities that exist in v3, such as `resize`, `fill-none`, `accent-none`, `drop-shadow-none`, and negative `hue-rotate` and `backdrop-hue-rotate` utilities ([#13971](https://github.com/tailwindlabs/tailwindcss/pull/13971))
1717
- Don’t allow at-rule-only variants to be compounded ([#14015](https://github.com/tailwindlabs/tailwindcss/pull/14015))
1818
- Ensure compound variants work with variants with multiple selectors ([#14016](https://github.com/tailwindlabs/tailwindcss/pull/14016))
19+
- Attribute selectors in `data-*` and `aria-*` modifiers are now wrapped in quotation marks by default, allowing numbers and spaces in them ([#14040])(https://github.com/tailwindlabs/tailwindcss/pull/14037)
1920

2021
### Added
2122

packages/tailwindcss/src/variants.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,16 +1756,21 @@ test('aria', () => {
17561756
run([
17571757
'aria-checked:flex',
17581758
'aria-[invalid=spelling]:flex',
1759+
'aria-[valuenow=1]:flex',
17591760

17601761
'group-aria-[modal]:flex',
17611762
'group-aria-checked:flex',
1763+
'group-aria-[valuenow=1]:flex',
17621764
'group-aria-[modal]/parent-name:flex',
17631765
'group-aria-checked/parent-name:flex',
1766+
'group-aria-[valuenow=1]/parent-name:flex',
17641767

17651768
'peer-aria-[modal]:flex',
17661769
'peer-aria-checked:flex',
1770+
'peer-aria-[valuenow=1]:flex',
17671771
'peer-aria-[modal]/parent-name:flex',
17681772
'peer-aria-checked/parent-name:flex',
1773+
'peer-aria-[valuenow=1]/parent-name:flex',
17691774
]),
17701775
).toMatchInlineSnapshot(`
17711776
".group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *) {
@@ -1776,6 +1781,10 @@ test('aria', () => {
17761781
display: flex;
17771782
}
17781783
1784+
.group-aria-\\[valuenow\\=1\\]\\:flex:is(:where(.group)[aria-valuenow="1"] *) {
1785+
display: flex;
1786+
}
1787+
17791788
.group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *) {
17801789
display: flex;
17811790
}
@@ -1784,6 +1793,10 @@ test('aria', () => {
17841793
display: flex;
17851794
}
17861795
1796+
.group-aria-\\[valuenow\\=1\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-valuenow="1"] *) {
1797+
display: flex;
1798+
}
1799+
17871800
.peer-aria-\\[modal\\]\\:flex:is(:where(.peer)[aria-modal] ~ *) {
17881801
display: flex;
17891802
}
@@ -1792,6 +1805,10 @@ test('aria', () => {
17921805
display: flex;
17931806
}
17941807
1808+
.peer-aria-\\[valuenow\\=1\\]\\:flex:is(:where(.peer)[aria-valuenow="1"] ~ *) {
1809+
display: flex;
1810+
}
1811+
17951812
.peer-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-modal] ~ *) {
17961813
display: flex;
17971814
}
@@ -1800,12 +1817,20 @@ test('aria', () => {
18001817
display: flex;
18011818
}
18021819
1820+
.peer-aria-\\[valuenow\\=1\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-valuenow="1"] ~ *) {
1821+
display: flex;
1822+
}
1823+
18031824
.aria-checked\\:flex[aria-checked="true"] {
18041825
display: flex;
18051826
}
18061827
18071828
.aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"] {
18081829
display: flex;
1830+
}
1831+
1832+
.aria-\\[valuenow\\=1\\]\\:flex[aria-valuenow="1"] {
1833+
display: flex;
18091834
}"
18101835
`)
18111836
expect(run(['aria-checked/foo:flex', 'aria-[invalid=spelling]/foo:flex'])).toEqual('')
@@ -1816,12 +1841,26 @@ test('data', () => {
18161841
run([
18171842
'data-disabled:flex',
18181843
'data-[potato=salad]:flex',
1844+
'data-[foo=1]:flex',
1845+
'data-[foo=bar_baz]:flex',
1846+
"data-[foo$='bar'_i]:flex",
1847+
'data-[foo$=bar_baz_i]:flex',
18191848

18201849
'group-data-[disabled]:flex',
18211850
'group-data-[disabled]/parent-name:flex',
1851+
'group-data-[foo=1]:flex',
1852+
'group-data-[foo=1]/parent-name:flex',
1853+
'group-data-[foo=bar baz]/parent-name:flex',
1854+
"group-data-[foo$='bar'_i]/parent-name:flex",
1855+
'group-data-[foo$=bar_baz_i]/parent-name:flex',
18221856

18231857
'peer-data-[disabled]:flex',
18241858
'peer-data-[disabled]/parent-name:flex',
1859+
'peer-data-[foo=1]:flex',
1860+
'peer-data-[foo=1]/parent-name:flex',
1861+
'peer-data-[foo=bar baz]/parent-name:flex',
1862+
"peer-data-[foo$='bar'_i]/parent-name:flex",
1863+
'peer-data-[foo$=bar_baz_i]/parent-name:flex',
18251864
]),
18261865
).toMatchInlineSnapshot(`
18271866
".group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *) {
@@ -1832,6 +1871,26 @@ test('data', () => {
18321871
display: flex;
18331872
}
18341873
1874+
.group-data-\\[foo\\=1\\]\\:flex:is(:where(.group)[data-foo="1"] *) {
1875+
display: flex;
1876+
}
1877+
1878+
.group-data-\\[foo\\=1\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo="1"] *) {
1879+
display: flex;
1880+
}
1881+
1882+
.group-data-\\[foo\\=bar\\ baz\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo="bar baz"] *) {
1883+
display: flex;
1884+
}
1885+
1886+
.group-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo$="bar" i] *) {
1887+
display: flex;
1888+
}
1889+
1890+
.group-data-\\[foo\\$\\=bar_baz_i\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo$="bar baz" i] *) {
1891+
display: flex;
1892+
}
1893+
18351894
.peer-data-\\[disabled\\]\\:flex:is(:where(.peer)[data-disabled] ~ *) {
18361895
display: flex;
18371896
}
@@ -1840,12 +1899,48 @@ test('data', () => {
18401899
display: flex;
18411900
}
18421901
1902+
.peer-data-\\[foo\\=1\\]\\:flex:is(:where(.peer)[data-foo="1"] ~ *) {
1903+
display: flex;
1904+
}
1905+
1906+
.peer-data-\\[foo\\=1\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-foo="1"] ~ *) {
1907+
display: flex;
1908+
}
1909+
1910+
.peer-data-\\[foo\\=bar\\ baz\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-foo="bar baz"] ~ *) {
1911+
display: flex;
1912+
}
1913+
1914+
.peer-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-foo$="bar" i] ~ *) {
1915+
display: flex;
1916+
}
1917+
1918+
.peer-data-\\[foo\\$\\=bar_baz_i\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-foo$="bar baz" i] ~ *) {
1919+
display: flex;
1920+
}
1921+
18431922
.data-disabled\\:flex[data-disabled] {
18441923
display: flex;
18451924
}
18461925
18471926
.data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] {
18481927
display: flex;
1928+
}
1929+
1930+
.data-\\[foo\\=1\\]\\:flex[data-foo="1"] {
1931+
display: flex;
1932+
}
1933+
1934+
.data-\\[foo\\=bar_baz\\]\\:flex[data-foo="bar baz"] {
1935+
display: flex;
1936+
}
1937+
1938+
.data-\\[foo\\$\\=\\'bar\\'_i\\]\\:flex[data-foo$="bar" i] {
1939+
display: flex;
1940+
}
1941+
1942+
.data-\\[foo\\$\\=bar_baz_i\\]\\:flex[data-foo$="bar baz" i] {
1943+
display: flex;
18491944
}"
18501945
`)
18511946
expect(run(['data-disabled/foo:flex', 'data-[potato=salad]/foo:flex'])).toEqual('')

packages/tailwindcss/src/variants.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ export function createVariants(theme: Theme): Variants {
513513
if (!variant.value || variant.modifier) return null
514514

515515
if (variant.value.kind === 'arbitrary') {
516-
ruleNode.nodes = [rule(`&[aria-${variant.value.value}]`, ruleNode.nodes)]
516+
ruleNode.nodes = [rule(`&[aria-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes)]
517517
} else {
518518
ruleNode.nodes = [rule(`&[aria-${variant.value.value}="true"]`, ruleNode.nodes)]
519519
}
@@ -534,7 +534,7 @@ export function createVariants(theme: Theme): Variants {
534534
variants.functional('data', (ruleNode, variant) => {
535535
if (!variant.value || variant.modifier) return null
536536

537-
ruleNode.nodes = [rule(`&[data-${variant.value.value}]`, ruleNode.nodes)]
537+
ruleNode.nodes = [rule(`&[data-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes)]
538538
})
539539

540540
variants.functional('nth', (ruleNode, variant) => {
@@ -904,3 +904,31 @@ export function createVariants(theme: Theme): Variants {
904904

905905
return variants
906906
}
907+
908+
function quoteAttributeValue(value: string) {
909+
if (value.includes('=')) {
910+
value = value.replace(/(=.*)/g, (_fullMatch, match) => {
911+
// If the value is already quoted, skip.
912+
if (match[1] === "'" || match[1] === '"') {
913+
return match
914+
}
915+
916+
// Handle regex flags on unescaped values
917+
if (match.length > 2) {
918+
let trailingCharacter = match[match.length - 1]
919+
if (
920+
match[match.length - 2] === ' ' &&
921+
(trailingCharacter === 'i' ||
922+
trailingCharacter === 'I' ||
923+
trailingCharacter === 's' ||
924+
trailingCharacter === 'S')
925+
) {
926+
return `="${match.slice(1, -2)}" ${match[match.length - 1]}`
927+
}
928+
}
929+
930+
return `="${match.slice(1)}"`
931+
})
932+
}
933+
return value
934+
}

0 commit comments

Comments
 (0)