From 300524b8935fa0a0ca956f342b3405254dd23cf4 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 24 Jul 2024 16:55:07 +0200 Subject: [PATCH] Normalize attribute selectors for `data-*` and `aria-*` modifiers (#14040) Fixes #14026 See https://github.com/tailwindlabs/tailwindcss/pull/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
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 + packages/tailwindcss/src/variants.test.ts | 95 +++++++++++++++++++++++ packages/tailwindcss/src/variants.ts | 32 +++++++- 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b97a201df..99ae3f8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) - Don’t allow at-rule-only variants to be compounded ([#14015](https://github.com/tailwindlabs/tailwindcss/pull/14015)) - Ensure compound variants work with variants with multiple selectors ([#14016](https://github.com/tailwindlabs/tailwindcss/pull/14016)) +- 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) ### Added diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 5fb6b4f22..d27da7638 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -1756,16 +1756,21 @@ test('aria', () => { run([ 'aria-checked:flex', 'aria-[invalid=spelling]:flex', + 'aria-[valuenow=1]:flex', 'group-aria-[modal]:flex', 'group-aria-checked:flex', + 'group-aria-[valuenow=1]:flex', 'group-aria-[modal]/parent-name:flex', 'group-aria-checked/parent-name:flex', + 'group-aria-[valuenow=1]/parent-name:flex', 'peer-aria-[modal]:flex', 'peer-aria-checked:flex', + 'peer-aria-[valuenow=1]:flex', 'peer-aria-[modal]/parent-name:flex', 'peer-aria-checked/parent-name:flex', + 'peer-aria-[valuenow=1]/parent-name:flex', ]), ).toMatchInlineSnapshot(` ".group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *) { @@ -1776,6 +1781,10 @@ test('aria', () => { display: flex; } + .group-aria-\\[valuenow\\=1\\]\\:flex:is(:where(.group)[aria-valuenow="1"] *) { + display: flex; + } + .group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *) { display: flex; } @@ -1784,6 +1793,10 @@ test('aria', () => { display: flex; } + .group-aria-\\[valuenow\\=1\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-valuenow="1"] *) { + display: flex; + } + .peer-aria-\\[modal\\]\\:flex:is(:where(.peer)[aria-modal] ~ *) { display: flex; } @@ -1792,6 +1805,10 @@ test('aria', () => { display: flex; } + .peer-aria-\\[valuenow\\=1\\]\\:flex:is(:where(.peer)[aria-valuenow="1"] ~ *) { + display: flex; + } + .peer-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-modal] ~ *) { display: flex; } @@ -1800,12 +1817,20 @@ test('aria', () => { display: flex; } + .peer-aria-\\[valuenow\\=1\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-valuenow="1"] ~ *) { + display: flex; + } + .aria-checked\\:flex[aria-checked="true"] { display: flex; } .aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"] { display: flex; + } + + .aria-\\[valuenow\\=1\\]\\:flex[aria-valuenow="1"] { + display: flex; }" `) expect(run(['aria-checked/foo:flex', 'aria-[invalid=spelling]/foo:flex'])).toEqual('') @@ -1816,12 +1841,26 @@ test('data', () => { run([ 'data-disabled:flex', 'data-[potato=salad]:flex', + 'data-[foo=1]:flex', + 'data-[foo=bar_baz]:flex', + "data-[foo$='bar'_i]:flex", + 'data-[foo$=bar_baz_i]:flex', 'group-data-[disabled]:flex', 'group-data-[disabled]/parent-name:flex', + 'group-data-[foo=1]:flex', + 'group-data-[foo=1]/parent-name:flex', + 'group-data-[foo=bar baz]/parent-name:flex', + "group-data-[foo$='bar'_i]/parent-name:flex", + 'group-data-[foo$=bar_baz_i]/parent-name:flex', 'peer-data-[disabled]:flex', 'peer-data-[disabled]/parent-name:flex', + 'peer-data-[foo=1]:flex', + 'peer-data-[foo=1]/parent-name:flex', + 'peer-data-[foo=bar baz]/parent-name:flex', + "peer-data-[foo$='bar'_i]/parent-name:flex", + 'peer-data-[foo$=bar_baz_i]/parent-name:flex', ]), ).toMatchInlineSnapshot(` ".group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *) { @@ -1832,6 +1871,26 @@ test('data', () => { display: flex; } + .group-data-\\[foo\\=1\\]\\:flex:is(:where(.group)[data-foo="1"] *) { + display: flex; + } + + .group-data-\\[foo\\=1\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo="1"] *) { + display: flex; + } + + .group-data-\\[foo\\=bar\\ baz\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo="bar baz"] *) { + display: flex; + } + + .group-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo$="bar" i] *) { + display: flex; + } + + .group-data-\\[foo\\$\\=bar_baz_i\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo$="bar baz" i] *) { + display: flex; + } + .peer-data-\\[disabled\\]\\:flex:is(:where(.peer)[data-disabled] ~ *) { display: flex; } @@ -1840,12 +1899,48 @@ test('data', () => { display: flex; } + .peer-data-\\[foo\\=1\\]\\:flex:is(:where(.peer)[data-foo="1"] ~ *) { + display: flex; + } + + .peer-data-\\[foo\\=1\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-foo="1"] ~ *) { + display: flex; + } + + .peer-data-\\[foo\\=bar\\ baz\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-foo="bar baz"] ~ *) { + display: flex; + } + + .peer-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-foo$="bar" i] ~ *) { + display: flex; + } + + .peer-data-\\[foo\\$\\=bar_baz_i\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-foo$="bar baz" i] ~ *) { + display: flex; + } + .data-disabled\\:flex[data-disabled] { display: flex; } .data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] { display: flex; + } + + .data-\\[foo\\=1\\]\\:flex[data-foo="1"] { + display: flex; + } + + .data-\\[foo\\=bar_baz\\]\\:flex[data-foo="bar baz"] { + display: flex; + } + + .data-\\[foo\\$\\=\\'bar\\'_i\\]\\:flex[data-foo$="bar" i] { + display: flex; + } + + .data-\\[foo\\$\\=bar_baz_i\\]\\:flex[data-foo$="bar baz" i] { + display: flex; }" `) expect(run(['data-disabled/foo:flex', 'data-[potato=salad]/foo:flex'])).toEqual('') diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 909cfef22..8a08bb787 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -513,7 +513,7 @@ export function createVariants(theme: Theme): Variants { if (!variant.value || variant.modifier) return null if (variant.value.kind === 'arbitrary') { - ruleNode.nodes = [rule(`&[aria-${variant.value.value}]`, ruleNode.nodes)] + ruleNode.nodes = [rule(`&[aria-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes)] } else { ruleNode.nodes = [rule(`&[aria-${variant.value.value}="true"]`, ruleNode.nodes)] } @@ -534,7 +534,7 @@ export function createVariants(theme: Theme): Variants { variants.functional('data', (ruleNode, variant) => { if (!variant.value || variant.modifier) return null - ruleNode.nodes = [rule(`&[data-${variant.value.value}]`, ruleNode.nodes)] + ruleNode.nodes = [rule(`&[data-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes)] }) variants.functional('nth', (ruleNode, variant) => { @@ -904,3 +904,31 @@ export function createVariants(theme: Theme): Variants { return variants } + +function quoteAttributeValue(value: string) { + if (value.includes('=')) { + value = value.replace(/(=.*)/g, (_fullMatch, match) => { + // If the value is already quoted, skip. + 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 +}