From 680c55c11cd0960da7efd57d0c2f90b821be8946 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 24 Jul 2024 18:46:21 +0200 Subject: [PATCH] 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
underlined
not underlined
not underlined
``` 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 --- CHANGELOG.md | 1 + src/corePlugins.js | 26 ++++++++------ src/util/dataTypes.js | 28 +++++++++++++++ tests/arbitrary-variants.test.js | 58 ++++++++++++++++++++++++++++++-- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be64b34e3f0..b350588acd37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/corePlugins.js b/src/corePlugins.js index f3441f4b88a3..8f274153879e 100644 --- a/src/corePlugins.js +++ b/src/corePlugins.js @@ -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 = { @@ -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') ?? {} } ) }, diff --git a/src/util/dataTypes.js b/src/util/dataTypes.js index 85bdbd2059de..e1db13754e45 100644 --- a/src/util/dataTypes.js +++ b/src/util/dataTypes.js @@ -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 `,`. diff --git a/tests/arbitrary-variants.test.js b/tests/arbitrary-variants.test.js index 0ab9e234c336..c60bb92ad307 100644 --- a/tests/arbitrary-variants.test.js +++ b/tests/arbitrary-variants.test.js @@ -442,6 +442,32 @@ test('keeps escaped underscores with multiple arbitrary variants', () => { }) }) +test('does not add quotes on arbitrary variants', () => { + let config = { + content: [ + { + raw: '
', + }, + ], + 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: [ @@ -601,6 +627,7 @@ it('should support aria variants', () => {
+
@@ -610,6 +637,8 @@ it('should support aria variants', () => {
+
+
@@ -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; } `) @@ -657,8 +689,11 @@ it('should support data variants', () => { raw: html`
-
+
+
+
+
@@ -667,6 +702,12 @@ it('should support data variants', () => {
+
+
+
+
+
+
@@ -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; @@ -799,6 +849,7 @@ test('has-* variants with arbitrary values', () => {
+
`, }, @@ -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; + } `) }) })