Skip to content

Commit

Permalink
Normalize attribute selector for data-* and aria-* modifiers (#14037
Browse files Browse the repository at this point in the history
)

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>
  • Loading branch information
philipp-spiess and RobinMalfait committed Jul 24, 2024
1 parent 866860e commit 680c55c
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 13 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

- Fix class detection in Slim templates with attached attributes and ID ([#14019](https://github.com/tailwindlabs/tailwindcss/pull/14019))
- 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)

## [3.4.6] - 2024-07-16

Expand Down
26 changes: 15 additions & 11 deletions src/corePlugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import { formatBoxShadowValue, parseBoxShadowValue } from './util/parseBoxShadowValue'
import { removeAlphaVariables } from './util/removeAlphaVariables'
import { flagEnabled } from './featureFlags'
import { normalize } from './util/dataTypes'
import { normalize, normalizeAttributeSelectors } from './util/dataTypes'
import { INTERNAL_FEATURES } from './lib/setupContextUtils'

export let variantPlugins = {
Expand Down Expand Up @@ -472,41 +472,45 @@ export let variantPlugins = {
},

ariaVariants: ({ matchVariant, theme }) => {
matchVariant('aria', (value) => `&[aria-${normalize(value)}]`, { values: theme('aria') ?? {} })
matchVariant('aria', (value) => `&[aria-${normalizeAttributeSelectors(normalize(value))}]`, {
values: theme('aria') ?? {},
})
matchVariant(
'group-aria',
(value, { modifier }) =>
modifier
? `:merge(.group\\/${modifier})[aria-${normalize(value)}] &`
: `:merge(.group)[aria-${normalize(value)}] &`,
? `:merge(.group\\/${modifier})[aria-${normalizeAttributeSelectors(normalize(value))}] &`
: `:merge(.group)[aria-${normalizeAttributeSelectors(normalize(value))}] &`,
{ values: theme('aria') ?? {} }
)
matchVariant(
'peer-aria',
(value, { modifier }) =>
modifier
? `:merge(.peer\\/${modifier})[aria-${normalize(value)}] ~ &`
: `:merge(.peer)[aria-${normalize(value)}] ~ &`,
? `:merge(.peer\\/${modifier})[aria-${normalizeAttributeSelectors(normalize(value))}] ~ &`
: `:merge(.peer)[aria-${normalizeAttributeSelectors(normalize(value))}] ~ &`,
{ values: theme('aria') ?? {} }
)
},

dataVariants: ({ matchVariant, theme }) => {
matchVariant('data', (value) => `&[data-${normalize(value)}]`, { values: theme('data') ?? {} })
matchVariant('data', (value) => `&[data-${normalizeAttributeSelectors(normalize(value))}]`, {
values: theme('data') ?? {},
})
matchVariant(
'group-data',
(value, { modifier }) =>
modifier
? `:merge(.group\\/${modifier})[data-${normalize(value)}] &`
: `:merge(.group)[data-${normalize(value)}] &`,
? `:merge(.group\\/${modifier})[data-${normalizeAttributeSelectors(normalize(value))}] &`
: `:merge(.group)[data-${normalizeAttributeSelectors(normalize(value))}] &`,
{ values: theme('data') ?? {} }
)
matchVariant(
'peer-data',
(value, { modifier }) =>
modifier
? `:merge(.peer\\/${modifier})[data-${normalize(value)}] ~ &`
: `:merge(.peer)[data-${normalize(value)}] ~ &`,
? `:merge(.peer\\/${modifier})[data-${normalizeAttributeSelectors(normalize(value))}] ~ &`
: `:merge(.peer)[data-${normalizeAttributeSelectors(normalize(value))}] ~ &`,
{ values: theme('data') ?? {} }
)
},
Expand Down
28 changes: 28 additions & 0 deletions src/util/dataTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,34 @@ export function normalize(value, context = null, isRoot = true) {
return value
}

export function normalizeAttributeSelectors(value) {
// Wrap values in attribute selectors with quotes
if (value.includes('=')) {
value = value.replace(/(=.*)/g, (_fullMatch, match) => {
if (match[1] === "'" || match[1] === '"') {
return match
}

// Handle regex flags on unescaped values
if (match.length > 2) {
let trailingCharacter = match[match.length - 1]
if (
match[match.length - 2] === ' ' &&
(trailingCharacter === 'i' ||
trailingCharacter === 'I' ||
trailingCharacter === 's' ||
trailingCharacter === 'S')
) {
return `="${match.slice(1, -2)}" ${match[match.length - 1]}`
}
}

return `="${match.slice(1)}"`
})
}
return value
}

/**
* Add spaces around operators inside math functions
* like calc() that do not follow an operator, '(', or `,`.
Expand Down
58 changes: 56 additions & 2 deletions tests/arbitrary-variants.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,32 @@ test('keeps escaped underscores with multiple arbitrary variants', () => {
})
})

test('does not add quotes on arbitrary variants', () => {
let config = {
content: [
{
raw: '<div class="[&[data-foo=\'1\']+.bar]:underline"></div>',
},
],
corePlugins: { preflight: false },
}

let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`

return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.\[\&\[data-foo\=\'1\'\]\+\.bar\]\:underline[data-foo='1']+.bar {
text-decoration-line: underline;
}
`)
})
})

test('keeps escaped underscores in arbitrary variants mixed with normal variants', () => {
let config = {
content: [
Expand Down Expand Up @@ -601,6 +627,7 @@ it('should support aria variants', () => {
<div>
<div class="aria-checked:underline"></div>
<div class="aria-[sort=ascending]:underline"></div>
<div class="aria-[valuenow=1]:underline"></div>
<div class="aria-[labelledby='a_b']:underline"></div>
<div class="group-aria-checked:underline"></div>
<div class="peer-aria-checked:underline"></div>
Expand All @@ -610,6 +637,8 @@ it('should support aria variants', () => {
<div class="peer-aria-[sort=ascending]:underline"></div>
<div class="group-aria-[labelledby='a_b']:underline"></div>
<div class="peer-aria-[labelledby='a_b']:underline"></div>
<div class="group-aria-[valuenow=1]:underline"></div>
<div class="peer-aria-[valuenow=1]:underline"></div>
<div class="group-aria-[sort=ascending]/foo:underline"></div>
<div class="peer-aria-[sort=ascending]/foo:underline"></div>
</div>
Expand All @@ -629,16 +658,19 @@ it('should support aria variants', () => {
.aria-checked\:underline[aria-checked='true'],
.aria-\[labelledby\=\'a_b\'\]\:underline[aria-labelledby='a b'],
.aria-\[sort\=ascending\]\:underline[aria-sort='ascending'],
.aria-\[valuenow\=1\]\:underline[aria-valuenow='1'],
.group\/foo[aria-checked='true'] .group-aria-checked\/foo\:underline,
.group[aria-checked='true'] .group-aria-checked\:underline,
.group[aria-labelledby='a b'] .group-aria-\[labelledby\=\'a_b\'\]\:underline,
.group\/foo[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\/foo\:underline,
.group[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\:underline,
.group[aria-valuenow='1'] .group-aria-\[valuenow\=1\]\:underline,
.peer\/foo[aria-checked='true'] ~ .peer-aria-checked\/foo\:underline,
.peer[aria-checked='true'] ~ .peer-aria-checked\:underline,
.peer[aria-labelledby='a b'] ~ .peer-aria-\[labelledby\=\'a_b\'\]\:underline,
.peer\/foo[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\/foo\:underline,
.peer[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\:underline {
.peer[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\:underline,
.peer[aria-valuenow='1'] ~ .peer-aria-\[valuenow\=1\]\:underline {
text-decoration-line: underline;
}
`)
Expand All @@ -657,8 +689,11 @@ it('should support data variants', () => {
raw: html`
<div>
<div class="data-checked:underline"></div>
<div class="data-[position=top]:underline"></div>
<div class="data-[foo='bar_baz']:underline"></div>
<div class="data-[id$='foo'_s]:underline"></div>
<div class="data-[id$=foo_bar_s]:underline"></div>
<div class="data-[id=0]:underline"></div>
<div class="data-[position=top]:underline"></div>
<div class="group-data-checked:underline"></div>
<div class="peer-data-checked:underline"></div>
<div class="group-data-checked/foo:underline"></div>
Expand All @@ -667,6 +702,12 @@ it('should support data variants', () => {
<div class="peer-data-[position=top]:underline"></div>
<div class="group-data-[foo='bar_baz']:underline"></div>
<div class="peer-data-[foo='bar_baz']:underline"></div>
<div class="group-data-[id$='foo'_s]:underline"></div>
<div class="group-data-[id$=foo_bar_s]:underline"></div>
<div class="group-data-[id=0]:underline"></div>
<div class="peer-data-[id$='foo'_s]:underline"></div>
<div class="peer-data-[id$=foo_bar_s]:underline"></div>
<div class="peer-data-[id=0]:underline"></div>
<div class="group-data-[position=top]/foo:underline"></div>
<div class="peer-data-[position=top]/foo:underline"></div>
</div>
Expand All @@ -685,15 +726,24 @@ it('should support data variants', () => {
.underline,
.data-checked\:underline[data-ui~='checked'],
.data-\[foo\=\'bar_baz\'\]\:underline[data-foo='bar baz'],
.data-\[id\$\=\'foo\'_s\]\:underline[data-id$='foo' s],
.data-\[id\$\=foo_bar_s\]\:underline[data-id$='foo bar' s],
.data-\[id\=0\]\:underline[data-id='0'],
.data-\[position\=top\]\:underline[data-position='top'],
.group\/foo[data-ui~='checked'] .group-data-checked\/foo\:underline,
.group[data-ui~='checked'] .group-data-checked\:underline,
.group[data-foo='bar baz'] .group-data-\[foo\=\'bar_baz\'\]\:underline,
.group[data-id$='foo' s] .group-data-\[id\$\=\'foo\'_s\]\:underline,
.group[data-id$='foo bar' s] .group-data-\[id\$\=foo_bar_s\]\:underline,
.group[data-id='0'] .group-data-\[id\=0\]\:underline,
.group\/foo[data-position='top'] .group-data-\[position\=top\]\/foo\:underline,
.group[data-position='top'] .group-data-\[position\=top\]\:underline,
.peer\/foo[data-ui~='checked'] ~ .peer-data-checked\/foo\:underline,
.peer[data-ui~='checked'] ~ .peer-data-checked\:underline,
.peer[data-foo='bar baz'] ~ .peer-data-\[foo\=\'bar_baz\'\]\:underline,
.peer[data-id$='foo' s] ~ .peer-data-\[id\$\=\'foo\'_s\]\:underline,
.peer[data-id$='foo bar' s] ~ .peer-data-\[id\$\=foo_bar_s\]\:underline,
.peer[data-id='0'] ~ .peer-data-\[id\=0\]\:underline,
.peer\/foo[data-position='top'] ~ .peer-data-\[position\=top\]\/foo\:underline,
.peer[data-position='top'] ~ .peer-data-\[position\=top\]\:underline {
text-decoration-line: underline;
Expand Down Expand Up @@ -799,6 +849,7 @@ test('has-* variants with arbitrary values', () => {
<div class="has-[+_h2]:grid"></div>
<div class="has-[>_h1_+_h2]:contents"></div>
<div class="has-[h2]:has-[.banana]:hidden"></div>
<div class="has-[[data-foo='1']+div]:font-bold"></div>
</div>
`,
},
Expand Down Expand Up @@ -836,6 +887,9 @@ test('has-* variants with arbitrary values', () => {
.has-\[h2\]\:has-\[\.banana\]\:hidden:has(.banana):has(h2) {
display: none;
}
.has-\[\[data-foo\=\'1\'\]\+div\]\:font-bold:has([data-foo='1'] + div) {
font-weight: 700;
}
`)
})
})
Expand Down

0 comments on commit 680c55c

Please sign in to comment.