@wix/zero-config-implementation 1.66.0 → 1.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { g as x } from "./index-BEdi2EUy.js";
1
+ import { g as x } from "./index-BewAGOQY.js";
2
2
  function h(r, a) {
3
3
  for (var i = 0; i < a.length; i++) {
4
4
  const o = a[i];
package/dist/index.d.ts CHANGED
@@ -422,6 +422,12 @@ export declare interface CSSParserAPI {
422
422
  * @param defaultValue - The initial value string of the custom property
423
423
  */
424
424
  getVarPropertyType: (varName: string, defaultValue: string) => string | undefined;
425
+ /**
426
+ * Returns every `:global(.modifier)` design-state class found in the CSS, paired
427
+ * with any native pseudo-class on the same base element within the same rule.
428
+ * Modifiers with no paired pseudo are custom (non-native) state triggers.
429
+ */
430
+ getStateClasses: () => StateClassRule[];
425
431
  }
426
432
 
427
433
  export declare interface CssPropertiesData {
@@ -1577,6 +1583,8 @@ PropsTwo extends ErrorProps,
1577
1583
  declare type MethodOptions<PluginArg extends Plugin> =
1578
1584
  ExternalPluginOptions<PluginArg>
1579
1585
 
1586
+ declare type NativePseudoClass = 'hover' | 'focus' | 'disabled' | 'invalid';
1587
+
1580
1588
  /**
1581
1589
  * Normalize each error in the `errors` option to `Error` instances
1582
1590
  */
@@ -2356,6 +2364,7 @@ export declare interface PropInfo {
2356
2364
  resolvedType: ResolvedType;
2357
2365
  description?: string;
2358
2366
  deprecated?: boolean;
2367
+ isStateTrigger?: boolean;
2359
2368
  }
2360
2369
 
2361
2370
  export declare interface PropSpyMeta {
@@ -2644,6 +2653,24 @@ CauseArg extends Cause,
2644
2653
  > = MainInstanceOptions<AggregateErrorsArg, CauseArg> &
2645
2654
  PluginsOptions<PluginsArg, ChildProps>
2646
2655
 
2656
+ /**
2657
+ * A `:global(.modifier)` design-state class found on a base element selector.
2658
+ * Produced by walking each CSS rule and pairing the global modifier with any
2659
+ * native pseudo-class present on the same base within that rule.
2660
+ */
2661
+ declare interface StateClassRule {
2662
+ /** DOM-matchable base selector (e.g. `.root`), used to map the modifier to an element. */
2663
+ baseSelector: string;
2664
+ /** Global modifier class name from `:global(.X)`, without the leading dot. */
2665
+ modifier: string;
2666
+ /**
2667
+ * Native pseudo-class paired with this modifier on the same base within the same
2668
+ * rule. When present, the modifier is the editor-trigger class for a native state;
2669
+ * when absent, the modifier denotes a custom (non-native) design state.
2670
+ */
2671
+ pseudoClass?: NativePseudoClass;
2672
+ }
2673
+
2647
2674
  /**
2648
2675
  * Unbound static method of a plugin
2649
2676
  */
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { B as t, D as e, E as o, I as n, N as c, P as l, R as i, a as p, V as E, b as m, c as x, d as f, e as d, f as u, h as C, i as I, j as k, k as y, l as A, m as D, n as P, o as R, p as h, q as B, r as M, s as T, t as b, w } from "./index-BEdi2EUy.js";
1
+ import { B as t, D as e, E as o, I as n, N as c, P as l, R as i, a as p, V as E, b as m, c as x, d as f, e as d, f as u, h as C, i as I, j as k, k as y, l as A, m as D, n as P, o as R, p as h, q as B, r as M, s as T, t as b, w } from "./index-BewAGOQY.js";
2
2
  import "react";
3
3
  export {
4
4
  t as BaseError,
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.66.0",
7
+ "version": "1.68.0",
8
8
  "description": "Core library for extracting component manifests from JS and CSS files",
9
9
  "type": "module",
10
10
  "main": "dist/index.js",
@@ -81,5 +81,5 @@
81
81
  ]
82
82
  }
83
83
  },
84
- "falconPackageHash": "2a3fd8ab08ce40ac87e3bf2f07ad6928e67ddb1262856b12f593624b"
84
+ "falconPackageHash": "5848ef0051ff536666dd46c6264f765b40f280afa13b5d120028a1a2"
85
85
  }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ExtractedElement } from '../information-extractors/react'
3
+ import type { CoupledProp } from '../information-extractors/react/types'
4
+ import { type CustomClassTrigger, buildCustomStatesBlock } from './custom-states-builder'
5
+
6
+ function elementWithClass(classAttribute: string): ExtractedElement {
7
+ return { attributes: { class: classAttribute } } as unknown as ExtractedElement
8
+ }
9
+
10
+ function booleanStateProp(name: string): CoupledProp {
11
+ return { name, type: 'boolean', isStateTrigger: true } as unknown as CoupledProp
12
+ }
13
+
14
+ function classTrigger(baseSelector: string, modifier: string, isCssModule = false): CustomClassTrigger {
15
+ return { baseSelector, modifier, isCssModule }
16
+ }
17
+
18
+ describe('buildCustomStatesBlock', () => {
19
+ it('emits a custom-class state with className only (no props, no pseudoClass)', () => {
20
+ const states = buildCustomStatesBlock({
21
+ element: elementWithClass('custom-states'),
22
+ propTriggers: [],
23
+ classTriggers: [classTrigger('.custom-states', 'custom-states--featured')],
24
+ })
25
+
26
+ expect(states).toEqual({ featured: { displayName: 'Featured', className: 'custom-states--featured' } })
27
+ })
28
+
29
+ it('derives a multi-word state name from the modifier and capital-cases the displayName', () => {
30
+ const states = buildCustomStatesBlock({
31
+ element: elementWithClass('widget'),
32
+ propTriggers: [],
33
+ classTriggers: [classTrigger('.widget', 'widget--in-progress')],
34
+ })
35
+
36
+ expect(states?.['in-progress']).toEqual({
37
+ displayName: 'In Progress',
38
+ className: 'widget--in-progress',
39
+ })
40
+ })
41
+
42
+ it('does not apply a block-base class trigger to a BEM child element (plain CSS, exact match)', () => {
43
+ const states = buildCustomStatesBlock({
44
+ element: elementWithClass('custom-states__label'),
45
+ propTriggers: [],
46
+ classTriggers: [classTrigger('.custom-states', 'custom-states--featured')],
47
+ })
48
+
49
+ expect(states).toBeUndefined()
50
+ })
51
+
52
+ it('matches a base class rendered as a double-underscore CSS-module hash (`root__HASH`)', () => {
53
+ // Vite emits `[name]__[hash]` for `styles.root`; the hash carries uppercase, so it is the
54
+ // base element — not a BEM child. (Regression: this used to be dropped as a child.)
55
+ const states = buildCustomStatesBlock({
56
+ element: elementWithClass('root__IWjkn wixui-test-comp-card test-comp-card'),
57
+ propTriggers: [],
58
+ classTriggers: [classTrigger('.root', 'test-comp-card--featured', true)],
59
+ })
60
+
61
+ expect(states).toEqual({ featured: { displayName: 'Featured', className: 'test-comp-card--featured' } })
62
+ })
63
+
64
+ it('still excludes a genuine BEM child (`root__label`) from a block-base trigger in a module', () => {
65
+ const states = buildCustomStatesBlock({
66
+ element: elementWithClass('root__label'),
67
+ propTriggers: [],
68
+ classTriggers: [classTrigger('.root', 'test-comp-card--featured', true)],
69
+ })
70
+
71
+ expect(states).toBeUndefined()
72
+ })
73
+
74
+ it('returns undefined when no trigger targets the element', () => {
75
+ const states = buildCustomStatesBlock({
76
+ element: elementWithClass('custom-states'),
77
+ propTriggers: [],
78
+ classTriggers: [],
79
+ })
80
+
81
+ expect(states).toBeUndefined()
82
+ })
83
+
84
+ it('emits a props-only state for a prop trigger with no matching class (no className synthesized)', () => {
85
+ const states = buildCustomStatesBlock({
86
+ element: elementWithClass('panel'),
87
+ propTriggers: [booleanStateProp('isExpanded')],
88
+ classTriggers: [],
89
+ })
90
+
91
+ expect(states).toEqual({
92
+ 'is-expanded': { displayName: 'Is Expanded', props: { isExpanded: true } },
93
+ })
94
+ })
95
+
96
+ it('keeps the full prop name in the state key and displayName (no is/has stripping)', () => {
97
+ const states = buildCustomStatesBlock({
98
+ element: elementWithClass('widget'),
99
+ propTriggers: [booleanStateProp('isLoading')],
100
+ classTriggers: [],
101
+ })
102
+
103
+ expect(states?.['is-loading']).toEqual({
104
+ displayName: 'Is Loading',
105
+ props: { isLoading: true },
106
+ })
107
+ })
108
+
109
+ it('merges a prop trigger with a class trigger that resolves to the same state name', () => {
110
+ const states = buildCustomStatesBlock({
111
+ element: elementWithClass('prop-states'),
112
+ propTriggers: [booleanStateProp('isLoading')],
113
+ classTriggers: [classTrigger('.prop-states', 'prop-states--is-loading')],
114
+ })
115
+
116
+ expect(states).toEqual({
117
+ 'is-loading': {
118
+ displayName: 'Is Loading',
119
+ className: 'prop-states--is-loading',
120
+ props: { isLoading: true },
121
+ },
122
+ })
123
+ })
124
+
125
+ it('ignores plain (non-ElementState) props and non-boolean state triggers', () => {
126
+ const states = buildCustomStatesBlock({
127
+ element: elementWithClass('custom-states'),
128
+ propTriggers: [
129
+ { name: 'showLabel', type: 'boolean' } as unknown as CoupledProp,
130
+ { name: 'variant', type: "'a' | 'b'", isStateTrigger: true } as unknown as CoupledProp,
131
+ ],
132
+ classTriggers: [],
133
+ })
134
+
135
+ expect(states).toBeUndefined()
136
+ })
137
+ })
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Builds the custom (non-native) `states` entries for an element.
3
+ *
4
+ * Two triggers feed this, per the agreed design — props are looked at first:
5
+ * - Prop — a boolean prop wrapped in `ElementState<>` (TS extractor sets
6
+ * `isStateTrigger`) IS a custom state, emitting
7
+ * `props: { <prop>: true }`. The CSS is then searched for a
8
+ * class trigger matching that state's name — when found it
9
+ * supplies the editor-trigger `className`; otherwise the entry
10
+ * is props-only (`className` is never synthesized).
11
+ * - Custom class — a `:global(.modifier)` class on the element with no paired
12
+ * native pseudo (CSS extractor `getStateClasses`) and not
13
+ * claimed by a prop trigger. Emits the modifier as a
14
+ * className-only state.
15
+ *
16
+ * Native states are built separately and take precedence on any key collision.
17
+ */
18
+
19
+ import type { States } from '@wix/react-component-schema'
20
+ import { kebabCase } from 'case-anything'
21
+ import type { StateClassRule } from '../information-extractors/css/types'
22
+ import type { CoupledProp, ExtractedElement } from '../information-extractors/react'
23
+ import { isGlobalSemanticClass, stateNameFromModifier } from '../utils/css-class'
24
+ import { formatDisplayName } from './utils'
25
+
26
+ /** A custom-class trigger tagged with whether its source file is a CSS module. */
27
+ export type CustomClassTrigger = StateClassRule & { isCssModule: boolean }
28
+
29
+ interface BuildCustomStatesParams {
30
+ element: ExtractedElement
31
+ /** Prop triggers in scope for this element (root-level `ElementState<>` props; `[]` for inner elements). */
32
+ propTriggers: CoupledProp[]
33
+ /** Custom-class triggers (modifiers with no paired native pseudo) across all CSS files. */
34
+ classTriggers: CustomClassTrigger[]
35
+ }
36
+
37
+ export function buildCustomStatesBlock({
38
+ element,
39
+ propTriggers,
40
+ classTriggers,
41
+ }: BuildCustomStatesParams): States | undefined {
42
+ const states: States = {}
43
+
44
+ // Class triggers that target this element, keyed by their derived state name,
45
+ // so prop triggers can look up their matching class.
46
+ const elementClassTriggers = new Map<string, CustomClassTrigger>()
47
+ for (const rule of classTriggers) {
48
+ if (!elementMatchesBase(element, rule.baseSelector, rule.isCssModule)) continue
49
+ elementClassTriggers.set(stateNameFromModifier(rule.modifier), rule)
50
+ }
51
+
52
+ // Prop triggers first: each boolean `ElementState<>` prop is a custom state.
53
+ // Then search the CSS for the class trigger matching that state's name — when
54
+ // found it supplies the editor-trigger className; otherwise props-only.
55
+ for (const prop of propTriggers) {
56
+ if (!prop.isStateTrigger || prop.type !== 'boolean') continue
57
+ const stateName = stateNameFromProp(prop.name)
58
+ const matchingClassTrigger = elementClassTriggers.get(stateName)
59
+ states[stateName] = {
60
+ displayName: formatDisplayName(stateName),
61
+ ...(matchingClassTrigger && { className: matchingClassTrigger.modifier }),
62
+ props: { [prop.name]: true },
63
+ }
64
+ }
65
+
66
+ // Remaining class triggers — those not claimed by a prop — are className-only states.
67
+ for (const [stateName, rule] of elementClassTriggers) {
68
+ if (states[stateName] !== undefined) continue
69
+ states[stateName] = {
70
+ displayName: formatDisplayName(stateName),
71
+ className: rule.modifier,
72
+ }
73
+ }
74
+
75
+ return Object.keys(states).length > 0 ? states : undefined
76
+ }
77
+
78
+ /**
79
+ * Derives the state name from a boolean prop by kebab-casing the full prop name
80
+ * (`isLoading` → `is-loading`, so the displayName reads "Is Loading"). The prop
81
+ * name is kept whole — no `is`/`has` prefix stripping — so the state key mirrors
82
+ * the prop that triggers it.
83
+ */
84
+ function stateNameFromProp(propName: string): string {
85
+ return kebabCase(propName)
86
+ }
87
+
88
+ /**
89
+ * Checks whether an element's rendered class list matches a simple single-class
90
+ * base selector (e.g. `.root`, `.custom-states`).
91
+ *
92
+ * Exact class match always counts. For CSS-module files the Vite hash patterns also
93
+ * count: `localName_HASH`, `_localName_HASH`, and the double-underscore `localName__HASH`
94
+ * form. The catch is that `localName__HASH` is syntactically identical to a BEM child
95
+ * class (`localName__child`) — which denotes a *different* element and must NOT match the
96
+ * block's base selector. We disambiguate by shape: a real BEM child is an all-lowercase
97
+ * semantic name (`isGlobalSemanticClass`), whereas a module hash carries uppercase / other
98
+ * non-kebab characters. So only genuine BEM children are excluded; hashed base classes match.
99
+ */
100
+ function elementMatchesBase(element: ExtractedElement, baseSelector: string, isCssModule: boolean): boolean {
101
+ const match = baseSelector.match(/^\.([\w-]+)$/)
102
+ if (!match) return false
103
+
104
+ const localName = match[1]
105
+ const classList = element.attributes.class?.split(/\s+/).filter(Boolean) ?? []
106
+ return classList.some((className) => {
107
+ if (className === localName) return true
108
+ if (!isCssModule) return false
109
+ // Genuine BEM child (`localName__<lowercase-element>`) → a different element, exclude it.
110
+ // A double-underscore module hash (`localName__<hash>`) is NOT semantic, so it falls through.
111
+ const isBemChild =
112
+ (className.startsWith(`${localName}__`) || className.startsWith(`_${localName}__`)) &&
113
+ isGlobalSemanticClass(className.replace(/^_/, ''))
114
+ if (isBemChild) return false
115
+ return className.startsWith(`${localName}_`) || className.startsWith(`_${localName}_`)
116
+ })
117
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  EditorElement,
7
7
  EditorReactComponent,
8
8
  ElementItem,
9
+ States,
9
10
  } from '@wix/react-component-schema'
10
11
  import { CSS_PROPERTIES, ELEMENTS } from '@wix/react-component-schema'
11
12
  import { camelCase } from 'case-anything'
@@ -21,10 +22,32 @@ import type {
21
22
  import { getDefaultDisplayForTag, resolveDisplayValue } from '../information-extractors/react'
22
23
  import { findPreferredSemanticClass } from '../utils/css-class'
23
24
  import { resolveLonghand } from './css-longhand-resolver'
25
+ import { type CustomClassTrigger, buildCustomStatesBlock } from './custom-states-builder'
24
26
  import { buildDataItem } from './data-item-builder'
25
27
  import { buildStatesBlock } from './states-builder'
26
28
  import { formatDisplayName } from './utils'
27
29
 
30
+ /**
31
+ * Merges native and custom state blocks. Native states take precedence on any
32
+ * key collision (e.g. an author wrapping `isDisabled` in `ElementState<>` does not
33
+ * shadow the inferred native `disabled` state).
34
+ */
35
+ function mergeStates(native: States | undefined, custom: States | undefined): States | undefined {
36
+ if (!native && !custom) return undefined
37
+ const merged: States = { ...custom, ...native }
38
+ return Object.keys(merged).length > 0 ? merged : undefined
39
+ }
40
+
41
+ /** Collects custom-class state triggers (modifiers with no paired native pseudo) across all CSS files. */
42
+ function collectCustomClassTriggers(component: ComponentInfoWithCss): CustomClassTrigger[] {
43
+ return component.css.flatMap((cssInfo) =>
44
+ cssInfo.api
45
+ .getStateClasses()
46
+ .filter((rule) => rule.pseudoClass === undefined)
47
+ .map((rule) => ({ ...rule, isCssModule: cssInfo.isCssModule })),
48
+ )
49
+ }
50
+
28
51
  // Props to exclude from data items (same as in element-data.ts)
29
52
  const EXCLUDED_PROPS = new Set(['id', 'className', 'elementProps', 'wix'])
30
53
 
@@ -43,8 +66,22 @@ function buildEditorElement(
43
66
  const childElements = rootElement?.children ?? []
44
67
  const rootCustomProps = rootElement ? (nearestCommonAncestorCustomProps.get(rootElement.traceId) ?? {}) : {}
45
68
 
69
+ const customClassTriggers = collectCustomClassTriggers(component)
70
+
71
+ // Native states need a semantic block name to synthesize `<block>--<state>`; custom
72
+ // class triggers don't (they carry the className verbatim and match the element
73
+ // directly), so they're built even when the element resolves no semantic class.
46
74
  const rootSemanticClass = getSemanticBlockName(rootElement)
47
- const rootStates = rootElement && rootSemanticClass ? buildStatesBlock(rootElement, rootSemanticClass) : undefined
75
+ const rootStates = rootElement
76
+ ? mergeStates(
77
+ rootSemanticClass ? buildStatesBlock(rootElement, rootSemanticClass) : undefined,
78
+ buildCustomStatesBlock({
79
+ element: rootElement,
80
+ propTriggers: Object.values(component.props).filter((prop) => prop.isStateTrigger),
81
+ classTriggers: customClassTriggers,
82
+ }),
83
+ )
84
+ : undefined
48
85
 
49
86
  return {
50
87
  selector: buildSelector(rootElement),
@@ -53,6 +90,7 @@ function buildEditorElement(
53
90
  elements: buildElements(
54
91
  childElements,
55
92
  nearestCommonAncestorCustomProps,
93
+ customClassTriggers,
56
94
  component.innerElementProps,
57
95
  component.propUsages,
58
96
  ),
@@ -100,6 +138,7 @@ function buildData(
100
138
  function buildElements(
101
139
  elements: ExtractedElement[],
102
140
  nearestCommonAncestorCustomProps: Map<string, Record<string, CssCustomPropertyItem>>,
141
+ customClassTriggers: CustomClassTrigger[],
103
142
  innerElementProps?: CoupledComponentInfo['innerElementProps'],
104
143
  propUsages?: TrackingStores['propUsages'],
105
144
  ): Record<string, ElementItem> {
@@ -112,7 +151,12 @@ function buildElements(
112
151
  const cssCustomProps = nearestCommonAncestorCustomProps.get(element.traceId) ?? {}
113
152
 
114
153
  const semanticClass = getSemanticBlockName(element)
115
- const states = semanticClass ? buildStatesBlock(element, semanticClass) : undefined
154
+ // Inner-element custom states come from custom-class triggers only; prop
155
+ // triggers (`ElementState<>`) are component-level and attach to the root.
156
+ const states = mergeStates(
157
+ semanticClass ? buildStatesBlock(element, semanticClass) : undefined,
158
+ buildCustomStatesBlock({ element, propTriggers: [], classTriggers: customClassTriggers }),
159
+ )
116
160
 
117
161
  result[element.name] = {
118
162
  elementType: ELEMENTS.ELEMENT_TYPE.inlineElement,
@@ -130,7 +174,13 @@ function buildElements(
130
174
  // Recursively build nested elements
131
175
  elements:
132
176
  element.children.length > 0
133
- ? buildElements(element.children, nearestCommonAncestorCustomProps, innerElementProps, propUsages)
177
+ ? buildElements(
178
+ element.children,
179
+ nearestCommonAncestorCustomProps,
180
+ customClassTriggers,
181
+ innerElementProps,
182
+ propUsages,
183
+ )
134
184
  : undefined,
135
185
  },
136
186
  }
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseCss } from './parse'
3
+
4
+ describe('parseCss — getStateClasses', () => {
5
+ it('extracts a custom-class modifier with no paired pseudo', () => {
6
+ const stateClasses = parseCss(`
7
+ .root:global(.custom-states--featured) {
8
+ border: 2px solid orange;
9
+ }
10
+ `).getStateClasses()
11
+
12
+ expect(stateClasses).toEqual([
13
+ { baseSelector: '.root', modifier: 'custom-states--featured', pseudoClass: undefined },
14
+ ])
15
+ })
16
+
17
+ it('pairs a global modifier with a native pseudo on the same base in the same rule', () => {
18
+ const stateClasses = parseCss(`
19
+ .root:global(.toggle--hover),
20
+ .root:hover {
21
+ filter: brightness(1.05);
22
+ }
23
+ `).getStateClasses()
24
+
25
+ expect(stateClasses).toEqual([{ baseSelector: '.root', modifier: 'toggle--hover', pseudoClass: 'hover' }])
26
+ })
27
+
28
+ it('maps :focus-visible and :focus-within to the focus state', () => {
29
+ const stateClasses = parseCss(`
30
+ .root:global(.toggle--focus),
31
+ .root:focus-within {
32
+ outline: 2px solid;
33
+ }
34
+ `).getStateClasses()
35
+
36
+ expect(stateClasses).toEqual([{ baseSelector: '.root', modifier: 'toggle--focus', pseudoClass: 'focus' }])
37
+ })
38
+
39
+ it('keeps modifiers on distinct base elements separate', () => {
40
+ const stateClasses = parseCss(`
41
+ .root:global(.card--featured) { color: red; }
42
+ .label:global(.card__label--featured) { font-weight: 700; }
43
+ `).getStateClasses()
44
+
45
+ expect(stateClasses).toEqual([
46
+ { baseSelector: '.root', modifier: 'card--featured', pseudoClass: undefined },
47
+ { baseSelector: '.label', modifier: 'card__label--featured', pseudoClass: undefined },
48
+ ])
49
+ })
50
+
51
+ it('treats a global base class with a global modifier as base + modifier (BEM extension)', () => {
52
+ const stateClasses = parseCss(`
53
+ :global(.card):global(.card--featured) {
54
+ border: 2px solid orange;
55
+ }
56
+ `).getStateClasses()
57
+
58
+ expect(stateClasses).toEqual([{ baseSelector: '.card', modifier: 'card--featured', pseudoClass: undefined }])
59
+ })
60
+
61
+ it('resolves a global base for an inner BEM element (`__`) carrying a modifier', () => {
62
+ const stateClasses = parseCss(`
63
+ :global(.card__label):global(.card__label--highlight) {
64
+ font-weight: 700;
65
+ }
66
+ `).getStateClasses()
67
+
68
+ expect(stateClasses).toEqual([
69
+ { baseSelector: '.card__label', modifier: 'card__label--highlight', pseudoClass: undefined },
70
+ ])
71
+ })
72
+
73
+ it('pairs a native pseudo with a modifier when the base element is global', () => {
74
+ const stateClasses = parseCss(`
75
+ :global(.card):global(.card--hover),
76
+ :global(.card):hover {
77
+ filter: brightness(1.05);
78
+ }
79
+ `).getStateClasses()
80
+
81
+ expect(stateClasses).toEqual([{ baseSelector: '.card', modifier: 'card--hover', pseudoClass: 'hover' }])
82
+ })
83
+
84
+ it('anchors a modifier to the compound it sits on, ignoring a descendant combinator', () => {
85
+ // SCSS `.header { &:global(.card__title--selected) { .label { … } } }` compiles to a
86
+ // descendant selector; the modifier belongs to `.header`, not `.header .label`.
87
+ const stateClasses = parseCss(`
88
+ .header:global(.card__title--selected) .label {
89
+ text-decoration: underline;
90
+ }
91
+ `).getStateClasses()
92
+
93
+ expect(stateClasses).toEqual([
94
+ { baseSelector: '.header', modifier: 'card__title--selected', pseudoClass: undefined },
95
+ ])
96
+ })
97
+
98
+ it('pairs only the same-named modifier with a pseudo when a base carries both native and custom modifiers', () => {
99
+ // One rule, descendant-anchored: `--hover` pairs with `:hover`; `--selected` stays custom.
100
+ const stateClasses = parseCss(`
101
+ .header:hover .label,
102
+ .header:global(.card__title--hover) .label,
103
+ .header:global(.card__title--selected) .label {
104
+ text-decoration: underline;
105
+ }
106
+ `).getStateClasses()
107
+
108
+ expect(stateClasses).toEqual([
109
+ { baseSelector: '.header', modifier: 'card__title--hover', pseudoClass: 'hover' },
110
+ { baseSelector: '.header', modifier: 'card__title--selected', pseudoClass: undefined },
111
+ ])
112
+ })
113
+
114
+ it('ignores rules with no global modifier', () => {
115
+ const stateClasses = parseCss(`
116
+ .root:hover { cursor: pointer; }
117
+ .root { color: black; }
118
+ `).getStateClasses()
119
+
120
+ expect(stateClasses).toEqual([])
121
+ })
122
+ })