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