diff --git a/CHANGELOG.md b/CHANGELOG.md index b97a201dfdc1..99ae3f8f5a1a 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 5fb6b4f2273e..d27da76387c3 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 909cfef22b6a..8a08bb787035 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 +}