diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4256ce9377..d1e7cdf28db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292)) - Prevent `theme('colors.foo')` in JS plugins from returning an internal disambiguation object when a CSS theme key shares a prefix with a sibling key like `--color-foo-bar` ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299)) - Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302)) +- Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303)) ## [4.3.2] - 2026-06-26 diff --git a/packages/tailwindcss/src/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts index e66abd90bb78..1cf5838f716e 100644 --- a/packages/tailwindcss/src/selector-parser.test.ts +++ b/packages/tailwindcss/src/selector-parser.test.ts @@ -445,7 +445,7 @@ describe('parse', () => { ]) }) - it('parses universal selector after an attribute selector', () => { + it('parses the universal selector after an attribute selector', () => { expect(parse('[data-foo]*')).toEqual([ { kind: 'compound', @@ -457,6 +457,42 @@ describe('parse', () => { ]) }) + it('parses another attribute selector after an attribute selector', () => { + expect(parse('[data-foo][data-bar]')).toEqual([ + { + kind: 'compound', + nodes: [ + { kind: 'selector', value: '[data-foo]' }, + { kind: 'selector', value: '[data-bar]' }, + ], + }, + ]) + }) + + it('parses a type selector before an attribute selector', () => { + expect(parse('div[data-foo]')).toEqual([ + { + kind: 'compound', + nodes: [ + { kind: 'selector', value: 'div' }, + { kind: 'selector', value: '[data-foo]' }, + ], + }, + ]) + }) + + it('parses a type selector after an attribute selector', () => { + expect(parse('[data-foo]div')).toEqual([ + { + kind: 'compound', + nodes: [ + { kind: 'selector', value: '[data-foo]' }, + { kind: 'selector', value: 'div' }, + ], + }, + ]) + }) + it('should parse selector lists as real selectors', () => { expect(parse('.foo[attr], .bar#id, .baz + .qux')).toEqual([ { diff --git a/packages/tailwindcss/src/selector-parser.ts b/packages/tailwindcss/src/selector-parser.ts index fcc78b2e90f7..b7632834a41f 100644 --- a/packages/tailwindcss/src/selector-parser.ts +++ b/packages/tailwindcss/src/selector-parser.ts @@ -196,20 +196,14 @@ export function parse(input: string) { let currentChar = input.charCodeAt(i) switch (currentChar) { - // E.g.: + // Handle selector lists // // ```css - // .foo .bar + // .foo, .bar {} // ^ - // - // .foo > .bar - // ^^^ // ``` case COMMA: { - // Flush remaining buffer, mark it as a selector - // - // Combinators are handled separately, and functions end with `)` which - // means that the `buffer` will be empty at that point. + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) buffer = '' @@ -244,19 +238,30 @@ export function parse(input: string) { break } + // Handle combinators + // + // E.g.: + // + // ```css + // .foo .bar + // ^ + // + // .foo > .bar + // ^^^ + // ``` case GREATER_THAN: case NEWLINE: case SPACE: case PLUS: case TAB: case TILDE: { - // 1. Handle everything before the combinator as a selector + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) buffer = '' } - // 2. Look ahead and find the end of the combinator + // Look ahead and find the end of the combinator let start = i let end = i + 1 for (; end < input.length; end++) { @@ -288,7 +293,7 @@ export function parse(input: string) { break } - // Start of a function call. + // Start of a function call // // E.g.: // @@ -312,7 +317,7 @@ export function parse(input: string) { let start = i + 1 let nesting = 0 - // Find the closing bracket. + // Find the closing bracket for (let j = i + 1; j < input.length; j++) { peekChar = input.charCodeAt(j) if (peekChar === OPEN_PAREN) { @@ -347,7 +352,7 @@ export function parse(input: string) { break } - // End of a function call. + // End of a function call // // E.g.: // @@ -356,7 +361,7 @@ export function parse(input: string) { // ^ // ``` case CLOSE_PAREN: { - // Handle everything before the closing paren a selector + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) buffer = '' @@ -376,7 +381,7 @@ export function parse(input: string) { break } - // Split compound selectors. + // Split compound selectors // // E.g.: // @@ -392,8 +397,8 @@ export function parse(input: string) { break } - // Handle everything before the combinator as a selector and - // start a new selector + // Handle everything before the combinator as a selector and start a new + // selector if (buffer.length > 0) { append(selector(buffer)) } @@ -401,7 +406,7 @@ export function parse(input: string) { break } - // Start of an attribute selector. + // Start of an attribute selector // // NOTE: Right now we don't care about the individual parts of the // attribute selector, we just want to find the matching closing bracket. @@ -410,16 +415,16 @@ export function parse(input: string) { // future, then we can use the `AttributeSelectorParser` here (and even // inline it if needed) case OPEN_BRACKET: { - // Handle everything before the combinator as a selector + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) + buffer = '' } - buffer = '' let start = i let nesting = 0 - // Find the closing bracket. + // Find the closing bracket for (let j = i + 1; j < input.length; j++) { peekChar = input.charCodeAt(j) if (peekChar === OPEN_BRACKET) { @@ -435,12 +440,11 @@ export function parse(input: string) { } } - // Adjust `buffer` to include the string. - buffer += input.slice(start, i + 1) + append(selector(input.slice(start, i + 1))) break } - // Start of a string. + // Start of a string case SINGLE_QUOTE: case DOUBLE_QUOTE: { let start = i @@ -452,43 +456,43 @@ export function parse(input: string) { // // ```css // "This is a string with a 'quote' in it" - // ^ ^ -> These are not the end of the string. + // ^ ^ -> These are not the end of the string // ``` for (let j = i + 1; j < input.length; j++) { peekChar = input.charCodeAt(j) - // Current character is a `\` therefore the next character is escaped. + // Current character is a `\` therefore the next character is escaped if (peekChar === BACKSLASH) { j += 1 } - // End of the string. + // End of the string else if (peekChar === currentChar) { i = j break } } - // Adjust `buffer` to include the string. + // Adjust `buffer` to include the string buffer += input.slice(start, i + 1) break } - // Nesting `&` is always a new selector. - // Universal `*` is always a new selector. + // Nesting `&` is always a new selector + // Universal `*` is always a new selector case AMPERSAND: case ASTERISK: { - // 1. Handle everything before the combinator as a selector + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) buffer = '' } - // 2. Handle the `&` or `*` as a selector on its own + // Handle the `&` or `*` as a selector on its own append(selector(input[i])) break } - // Escaped characters. + // Escaped characters case BACKSLASH: { buffer += input[i] + input[i + 1] i += 1 @@ -502,7 +506,7 @@ export function parse(input: string) { } } - // Collect the remainder as a word + // Collect the remainder as a selector if (buffer.length > 0) { append(selector(buffer)) }