From 8588dcc12df16994aa97662285be6f267e188fc9 Mon Sep 17 00:00:00 2001 From: Vincent Riemer <1398555+vincentriemer@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:01:44 -0800 Subject: [PATCH] Support attribute selectors in stylex.when and conditional styles "Styling-agnostic" react component systems, such as base-ui, expose state based styling hooks through the use of dom element attributes. Actually listening to these in StyleX, while possible through usage of the `:is()` psedudoselector, is overly verbose and uncessary. This diff addresses this by adding support for attribute selectors in stylex.when calls and conditional styles. ### Motivation - Allow `stylex.when` and conditional style objects to accept attribute selectors (including value-matching forms) rather than only attribute-existence markers. - Treat attribute selectors like pseudo-classes in the preprocessing and validation pipeline so downstream CSS generation remains correct. - Surface attribute-selector usage in types and docs so consumers get proper editor/compile-time guidance. ### Description - Accept keys starting with `[` in validation and preprocessing by updating `basic-validation.js`, `flatten-raw-style-obj.js`, and `PreRule.pseudos` to treat attribute selectors like pseudos. - Extend `when` helpers to accept attribute selectors by adding a `WhenSelector` union, updating `validatePseudoSelector` to allow `[`...`]`, and applying the selector when building `:where` / `:has` clauses in `when.js`. - Update Flow/TypeScript definitions in `StyleXTypes.js` and `StyleXTypes.d.ts` to permit attribute selectors for `stylex.when.*` signatures. - Add/update tests and documentation to use value-based attribute selectors (tests and snapshots in `transform-stylex-when-test.js` and `flatten-raw-style-obj-test.js`, and docs in `when.mdx`). ### Testing - Ran `yarn workspace @stylexjs/babel-plugin test -- transform-stylex-when-test --updateSnapshot`, where the test suite passed and snapshots were updated but the overall command returned non-zero due to global coverage thresholds being unmet. - The changed test file `transform-stylex-when-test.js` passed (all tests green) and snapshots were updated successfully. - Ran `npm run lint`, which completed successfully. - Pre-commit formatting/lint hooks ran as part of the commit and completed successfully. --- .../__tests__/transform-stylex-create-test.js | 41 ++++++ .../__tests__/transform-stylex-when-test.js | 138 +++++++++++++++++- .../src/shared/preprocess-rules/PreRule.js | 4 +- .../__tests__/flatten-raw-style-obj-test.js | 21 +++ .../preprocess-rules/basic-validation.js | 3 +- .../preprocess-rules/flatten-raw-style-obj.js | 5 +- .../babel-plugin/src/shared/when/when.js | 22 ++- .../stylex/src/types/StyleXTypes.d.ts | 13 +- .../@stylexjs/stylex/src/types/StyleXTypes.js | 10 +- .../docs/content/docs/api/javascript/when.mdx | 5 +- .../old-docs/docs/api/javascript/when.mdx | 2 +- 11 files changed, 237 insertions(+), 27 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-create-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-create-test.js index 6c7dffd51..ab603aa00 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-create-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-create-test.js @@ -1780,6 +1780,47 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('attribute selector with pseudo-class (nested, same value)', () => { + const { code, metadata } = transform(` + import * as stylex from '@stylexjs/stylex'; + export const styles = stylex.create({ + root: { + color: { + ':hover': { + '[data-state="open"]': 'red', + }, + '[data-state="open"]': { + ':hover': 'red', + }, + }, + }, + }); + `); + expect(code).toMatchInlineSnapshot(` + "import * as stylex from '@stylexjs/stylex'; + export const styles = { + root: { + kMwMTN: "x113j3rq", + $$css: true + } + };" + `); + expect(metadata).toMatchInlineSnapshot(` + { + "stylex": [ + [ + "x113j3rq", + { + "ltr": ".x113j3rq:hover[data-state="open"]{color:red}", + "rtl": null, + }, + 6130, + ], + ], + } + `); + }); + test('pseudo-class with array fallbacks', () => { const { code, metadata } = transform(` import * as stylex from '@stylexjs/stylex'; diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-when-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-when-test.js index 808ce15fa..3bef185ac 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-when-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-when-test.js @@ -293,14 +293,14 @@ describe('@stylexjs/babel-plugin', () => { }, }); `), - ).toThrow('Pseudo selector must start with ":"'); + ).toThrow('Pseudo selector must start with ":" or "["'); }); test('rejects pseudo-elements', () => { expect(() => transform(` import { when, create } from '@stylexjs/stylex'; - + const styles = create({ container: { backgroundColor: { @@ -312,6 +312,140 @@ describe('@stylexjs/babel-plugin', () => { `), ).toThrow('Pseudo selector cannot start with "::"'); }); + + test('validates attribute selector format', () => { + expect(() => + transform(` + import { when, create } from '@stylexjs/stylex'; + + const styles = create({ + container: { + backgroundColor: { + default: 'blue', + [when.ancestor('[data-state="open"')]: 'red', + }, + }, + }); + `), + ).toThrow('Attribute selector must end with "]"'); + }); + + test('rejects invalid selector format', () => { + expect(() => + transform(` + import { when, create } from '@stylexjs/stylex'; + + const styles = create({ + container: { + backgroundColor: { + default: 'blue', + [when.ancestor('invalid')]: 'red', + }, + }, + }); + `), + ).toThrow('Pseudo selector must start with ":" or "["'); + }); + }); + + describe('[transform] when functions with attribute selectors', () => { + test('when.ancestor with attribute selector', () => { + const { code, metadata } = transform(` + import { when, create } from '@stylexjs/stylex'; + + const styles = create({ + container: { + backgroundColor: { + default: 'blue', + [when.ancestor('[data-panel-state="open"]')]: 'red', + }, + }, + }); + + console.log(styles.container); + `); + + expect(code).toMatchInlineSnapshot(` + "import { when, create } from '@stylexjs/stylex'; + const styles = { + container: { + kWkggS: "x1t391ir x11omtej", + $$css: true + } + }; + console.log(styles.container);" + `); + + expect(metadata.stylex).toMatchInlineSnapshot(` + [ + [ + "x1t391ir", + { + "ltr": ".x1t391ir{background-color:blue}", + "rtl": null, + }, + 3000, + ], + [ + "x11omtej", + { + "ltr": ".x11omtej.x11omtej:where(.x-default-marker[data-panel-state="open"] *){background-color:red}", + "rtl": null, + }, + 3040, + ], + ] + `); + }); + + test('when.descendant with attribute selector', () => { + const { code, metadata } = transform(` + import { when, create } from '@stylexjs/stylex'; + + const styles = create({ + container: { + backgroundColor: { + default: 'blue', + [when.descendant('[data-panel-state="open"]')]: 'green', + }, + }, + }); + + console.log(styles.container); + `); + + expect(code).toMatchInlineSnapshot(` + "import { when, create } from '@stylexjs/stylex'; + const styles = { + container: { + kWkggS: "x1t391ir x1doj7mj", + $$css: true + } + }; + console.log(styles.container);" + `); + + expect(metadata.stylex).toMatchInlineSnapshot(` + [ + [ + "x1t391ir", + { + "ltr": ".x1t391ir{background-color:blue}", + "rtl": null, + }, + 3000, + ], + [ + "x1doj7mj", + { + "ltr": ".x1doj7mj.x1doj7mj:where(:has(.x-default-marker[data-panel-state="open"])){background-color:green}", + "rtl": null, + }, + 3040, + ], + ] + `); + }); }); describe('[transform] using stylex.defaultMarker', () => { test('named import', () => { diff --git a/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/PreRule.js b/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/PreRule.js index c1181d111..750c92974 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/PreRule.js +++ b/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/PreRule.js @@ -59,7 +59,9 @@ export class PreRule implements IPreRule { } get pseudos(): $ReadOnlyArray { - const unsortedPseudos = this.keyPath.filter((key) => key.startsWith(':')); + const unsortedPseudos = this.keyPath.filter( + (key) => key.startsWith(':') || key.startsWith('['), + ); return sortPseudos(unsortedPseudos); } diff --git a/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/__tests__/flatten-raw-style-obj-test.js b/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/__tests__/flatten-raw-style-obj-test.js index e4e3cdf1a..5185d2be3 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/__tests__/flatten-raw-style-obj-test.js +++ b/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/__tests__/flatten-raw-style-obj-test.js @@ -483,6 +483,27 @@ describe('Flatten Style Object with legacy shorthand expansion', () => { ], ]); }); + test('Attribute selector conditions', () => { + expect( + flattenRawStyleObject( + { + color: { + default: 'blue', + '[data-panel-state="open"]': 'red', + }, + }, + options, + ), + ).toEqual([ + [ + 'color', + PreRuleSet.create([ + new PreRule('color', 'blue', ['color', 'default']), + new PreRule('color', 'red', ['color', '[data-panel-state="open"]']), + ]), + ], + ]); + }); }); describe('Multiple levels of nesting', () => { test('Fallback styles within nested objects', () => { diff --git a/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/basic-validation.js b/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/basic-validation.js index 53367e81e..142e25a01 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/basic-validation.js +++ b/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/basic-validation.js @@ -33,7 +33,7 @@ export function validateNamespace( continue; } if (isPlainObject(val)) { - if (key.startsWith('@') || key.startsWith(':')) { + if (key.startsWith('@') || key.startsWith(':') || key.startsWith('[')) { if (conditions.includes(key)) { throw new Error(messages.DUPLICATE_CONDITIONAL); } @@ -62,6 +62,7 @@ function validateConditionalStyles( !( key.startsWith('@') || key.startsWith(':') || + key.startsWith('[') || // This is a placeholder for `defineConsts` values that are later inlined key.startsWith('var(--') || key === 'default' diff --git a/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/flatten-raw-style-obj.js b/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/flatten-raw-style-obj.js index 22f1637b9..9002d14cd 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/flatten-raw-style-obj.js +++ b/packages/@stylexjs/babel-plugin/src/shared/preprocess-rules/flatten-raw-style-obj.js @@ -135,7 +135,8 @@ export function _flattenRawStyleObject( if ( typeof value === 'object' && !key.startsWith(':') && - !key.startsWith('@') + !key.startsWith('@') && + !key.startsWith('[') ) { const equivalentPairs: { [string]: { [string]: AnyPreRule } } = {}; for (const condition in value) { @@ -169,7 +170,7 @@ export function _flattenRawStyleObject( // Object Values for pseudos and at-rules. e.g. { ':hover': { color: 'red' } } if ( typeof value === 'object' && - (key.startsWith(':') || key.startsWith('@')) + (key.startsWith(':') || key.startsWith('@') || key.startsWith('[')) ) { const pairs = _flattenRawStyleObject(value, [...keyPath, _key], options); for (const [property, preRule] of pairs) { diff --git a/packages/@stylexjs/babel-plugin/src/shared/when/when.js b/packages/@stylexjs/babel-plugin/src/shared/when/when.js index 2baca1ae8..a19976993 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/when/when.js +++ b/packages/@stylexjs/babel-plugin/src/shared/when/when.js @@ -46,18 +46,24 @@ function getDefaultMarkerClassName( return `${prefix}default-marker`; } +type WhenSelector = StringPrefix<':'> | StringPrefix<'['>; + /** - * Validates that a pseudo selector starts with ':' but not '::' + * Validates that a pseudo selector starts with ':' but not '::', + * or is an attribute selector that starts with '[' and ends with ']' */ function validatePseudoSelector(pseudo: string): void { - if (!pseudo.startsWith(':')) { - throw new Error('Pseudo selector must start with ":"'); + if (!(pseudo.startsWith(':') || pseudo.startsWith('['))) { + throw new Error('Pseudo selector must start with ":" or "["'); } if (pseudo.startsWith('::')) { throw new Error( 'Pseudo selector cannot start with "::" (pseudo-elements are not supported)', ); } + if (pseudo.startsWith('[') && !pseudo.endsWith(']')) { + throw new Error('Attribute selector must end with "]"'); + } } /** * Creates selector that observes if the given pseudo-class is @@ -68,7 +74,7 @@ function validatePseudoSelector(pseudo: string): void { * @returns A :where() clause for the ancestor observer */ export function ancestor( - pseudo: StringPrefix<':'>, + pseudo: WhenSelector, options: string | StyleXOptions = defaultOptions, ): string { validatePseudoSelector(pseudo); @@ -86,7 +92,7 @@ export function ancestor( * @returns A :has() clause for the descendant observer */ export function descendant( - pseudo: StringPrefix<':'>, + pseudo: WhenSelector, options: string | StyleXOptions = defaultOptions, ): string { validatePseudoSelector(pseudo); @@ -104,7 +110,7 @@ export function descendant( * @returns A :where() clause for the previous sibling observer */ export function siblingBefore( - pseudo: StringPrefix<':'>, + pseudo: WhenSelector, options: string | StyleXOptions = defaultOptions, ): string { validatePseudoSelector(pseudo); @@ -122,7 +128,7 @@ export function siblingBefore( * @returns A :has() clause for the next sibling observer */ export function siblingAfter( - pseudo: StringPrefix<':'>, + pseudo: WhenSelector, options: string | StyleXOptions = defaultOptions, ): string { validatePseudoSelector(pseudo); @@ -140,7 +146,7 @@ export function siblingAfter( * @returns A :where() clause for the any sibling observer */ export function anySibling( - pseudo: StringPrefix<':'>, + pseudo: WhenSelector, options: string | StyleXOptions = defaultOptions, ): string { validatePseudoSelector(pseudo); diff --git a/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts b/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts index 320c88bc7..1f596f624 100644 --- a/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts +++ b/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts @@ -313,13 +313,16 @@ export type StyleX$DefineMarker = () => MapNamespace<{ }>; export type StyleX$When = { - ancestor: ( + ancestor: < + const Pseudo extends `:${string}` | `[${string}]`, + MarkerSymbol extends symbol = symbol, + >( _pseudo?: Pseudo, _customMarker?: MapNamespace<{ readonly marker: MarkerSymbol }>, // @ts-expect-error - Trying to use a symbol in a string is not normally allowed ) => `:where-ancestor(${Pseudo}, ${MarkerSymbol})`; descendant: < - const Pseudo extends string, + const Pseudo extends `:${string}` | `[${string}]`, MarkerSymbol extends symbol = symbol, >( _pseudo?: Pseudo, @@ -327,7 +330,7 @@ export type StyleX$When = { // @ts-expect-error - Trying to use a symbol in a string is not normally allowed ) => `:where-descendant(${Pseudo}, ${MarkerSymbol})`; siblingBefore: < - const Pseudo extends string, + const Pseudo extends `:${string}` | `[${string}]`, MarkerSymbol extends symbol = symbol, >( _pseudo?: Pseudo, @@ -335,7 +338,7 @@ export type StyleX$When = { // @ts-expect-error - Trying to use a symbol in a string is not normally allowed ) => `:where-sibling-before(${Pseudo}, ${MarkerSymbol})`; siblingAfter: < - const Pseudo extends string, + const Pseudo extends `:${string}` | `[${string}]`, MarkerSymbol extends symbol = symbol, >( _pseudo?: Pseudo, @@ -343,7 +346,7 @@ export type StyleX$When = { // @ts-expect-error - Trying to use a symbol in a string is not normally allowed ) => `:where-sibling-after(${Pseudo}, ${MarkerSymbol})`; anySibling: < - const Pseudo extends string, + const Pseudo extends `:${string}` | `[${string}]`, MarkerSymbol extends symbol = symbol, >( _pseudo?: Pseudo, diff --git a/packages/@stylexjs/stylex/src/types/StyleXTypes.js b/packages/@stylexjs/stylex/src/types/StyleXTypes.js index 99898e2d6..57f41ec7a 100644 --- a/packages/@stylexjs/stylex/src/types/StyleXTypes.js +++ b/packages/@stylexjs/stylex/src/types/StyleXTypes.js @@ -261,23 +261,23 @@ export type StyleX$DefineMarker = () => MapNamespace<{ export type StyleX$When = { ancestor: ( - _pseudo?: StringPrefix<':'>, + _pseudo?: StringPrefix<':'> | StringPrefix<'['>, _customMarker?: MapNamespace<{ +marker: 'custom-marker' }>, ) => ':where-ancestor', descendant: ( - _pseudo?: StringPrefix<':'>, + _pseudo?: StringPrefix<':'> | StringPrefix<'['>, _customMarker?: MapNamespace<{ +marker: 'custom-marker' }>, ) => ':where-descendant', siblingBefore: ( - _pseudo?: StringPrefix<':'>, + _pseudo?: StringPrefix<':'> | StringPrefix<'['>, _customMarker?: MapNamespace<{ +marker: 'custom-marker' }>, ) => ':where-sibling-before', siblingAfter: ( - _pseudo?: StringPrefix<':'>, + _pseudo?: StringPrefix<':'> | StringPrefix<'['>, _customMarker?: MapNamespace<{ +marker: 'custom-marker' }>, ) => ':where-sibling-after', anySibling: ( - _pseudo?: StringPrefix<':'>, + _pseudo?: StringPrefix<':'> | StringPrefix<'['>, _customMarker?: MapNamespace<{ +marker: 'custom-marker' }>, ) => ':where-any-sibling', }; diff --git a/packages/docs/content/docs/api/javascript/when.mdx b/packages/docs/content/docs/api/javascript/when.mdx index 9bbe609df..f5d6a7bec 100644 --- a/packages/docs/content/docs/api/javascript/when.mdx +++ b/packages/docs/content/docs/api/javascript/when.mdx @@ -4,8 +4,9 @@ title: 'stylex.when.*' A suite of APIs for creating descendant and sibling selectors. These let you style an element based on the state of its ancestors, descendants, or siblings -in the DOM tree. You can only observe a pseudo-class state (`:hover`, `:focus`, -etc.) on an element that has been marked with a marker class. +in the DOM tree. You can observe pseudo-class states (`:hover`, `:focus`, etc.) +or attribute selectors (e.g., `[data-panel-state="open"]`) on an element that +has been marked with a marker class. > Note: lookahead selectors (`stylex.when.siblingAfter`, > `stylex.when.anySibling`, and `stylex.when.descendant`) rely on the CSS diff --git a/packages/old-docs/docs/api/javascript/when.mdx b/packages/old-docs/docs/api/javascript/when.mdx index 9daf4b945..ace5380c8 100644 --- a/packages/old-docs/docs/api/javascript/when.mdx +++ b/packages/old-docs/docs/api/javascript/when.mdx @@ -8,7 +8,7 @@ sidebar_position: 6 # `stylex.when.*` -A suite of APIs for creating descendant and sibling selectors. These APIs allow you to style an element based on the state of its ancestors, descendants, or siblings in the DOM tree. You can only observe a pseudo-class state (`:hover`, `:focus`, etc.) on an element that has been marked with a marker class. +A suite of APIs for creating descendant and sibling selectors. These APIs allow you to style an element based on the state of its ancestors, descendants, or siblings in the DOM tree. You can observe pseudo-class states (`:hover`, `:focus`, etc.) or attribute selectors (e.g., `[data-panel-state="open"]`) on an element that has been marked with a marker class. :::note Browser Support