@wix/zero-config-implementation 1.65.0 → 1.67.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.
@@ -2,7 +2,8 @@ import { CSS_PROPERTIES } from '@wix/react-component-schema'
2
2
  import { camelCase } from 'case-anything'
3
3
  import { generate, lexer, parse, walk } from 'css-tree'
4
4
  import type { Atrule, CssNode, Declaration, FunctionNode, Rule, Selector } from 'css-tree'
5
- import type { CSSParserAPI, CSSProperty } from './types'
5
+ import { stateNameFromModifier } from '../../utils/css-class'
6
+ import type { CSSParserAPI, CSSProperty, NativePseudoClass, StateClassRule } from './types'
6
7
 
7
8
  type WalkContext = {
8
9
  atrule?: Atrule | null
@@ -20,8 +21,13 @@ function isInsideKeyframes(currentContext: WalkContext): boolean {
20
21
  export function parseCss(cssString: string): CSSParserAPI {
21
22
  const parsedSelectors = new Map<string, Selector>()
22
23
  const { propertiesMap: allProperties, varUsagesByProperty } = parseAllProperties(cssString, parsedSelectors)
24
+ const stateClasses = parseStateClasses(cssString)
23
25
 
24
26
  return {
27
+ getStateClasses(): StateClassRule[] {
28
+ return stateClasses
29
+ },
30
+
25
31
  getPropertiesForSelector(selector: string): CSSProperty[] {
26
32
  return allProperties.get(selector) ?? []
27
33
  },
@@ -198,6 +204,176 @@ function extractVarNames(valueNode: CssNode): string[] {
198
204
  return varNames
199
205
  }
200
206
 
207
+ // Native pseudo-classes mapped to their design-state key. `:focus-visible` and
208
+ // `:focus-within` both collapse to `focus` (the schema has a single focus state).
209
+ const NATIVE_PSEUDO_BY_NAME: Record<string, NativePseudoClass> = {
210
+ hover: 'hover',
211
+ focus: 'focus',
212
+ 'focus-visible': 'focus',
213
+ 'focus-within': 'focus',
214
+ disabled: 'disabled',
215
+ invalid: 'invalid',
216
+ }
217
+
218
+ const GLOBAL_MODIFIER_PATTERN = /^:global\(\s*\.([\w-]+)\s*\)$/
219
+
220
+ interface AnalyzedCompound {
221
+ /** DOM-matchable base of the compound the modifier/pseudo is attached to, e.g. `.root`. */
222
+ base: string
223
+ /** Native pseudo-class on this compound, if any. */
224
+ pseudoClass?: NativePseudoClass
225
+ /** Global modifier class names extracted from `:global(.X)` parts on this compound. */
226
+ modifiers: string[]
227
+ }
228
+
229
+ type SelectorPart = { kind: 'local'; selector: string } | { kind: 'global'; className: string }
230
+
231
+ /**
232
+ * Decides whether a `:global(.X)` class on a selector is a state *modifier* (vs. the
233
+ * base element it modifies):
234
+ * - If the selector has a local (CSS-module) class, that local class is always the
235
+ * base, so every global class is a modifier — the current, common form
236
+ * (`.root:global(.toggle--hover)`).
237
+ * - Otherwise the element itself is authored as a global class, and the base is the
238
+ * BEM root: a global class is a modifier only when it `--`-extends another global
239
+ * class on the selector (`:global(.card):global(.card--featured)` → base `.card`,
240
+ * modifier `card--featured`). A lone `:global(.card):hover` keeps `.card` as base.
241
+ */
242
+ function isModifierClass(className: string, hasLocalBase: boolean, globalClassNames: string[]): boolean {
243
+ if (hasLocalBase) return true
244
+ return globalClassNames.some((other) => other !== className && className.startsWith(`${other}--`))
245
+ }
246
+
247
+ /**
248
+ * Analyzes a single compound selector (no combinators) into its DOM-matchable base,
249
+ * a native pseudo-class (if present), and any `:global(.modifier)` class names. Returns
250
+ * null for a compound carrying a pseudo-element (unmatchable) or with no base.
251
+ *
252
+ * The element a modifier is applied to may itself be authored as a global class, so
253
+ * the base/modifier split is resolved across all of the compound's classes rather
254
+ * than assuming the base is local — see `isModifierClass`.
255
+ */
256
+ function analyzeCompound(componentNodes: CssNode[]): AnalyzedCompound | null {
257
+ let pseudoClass: NativePseudoClass | undefined
258
+ const parts: SelectorPart[] = []
259
+
260
+ for (const component of componentNodes) {
261
+ if (component.type === 'PseudoElementSelector') return null
262
+ if (component.type === 'PseudoClassSelector') {
263
+ if (component.name === 'global') {
264
+ const match = generate(component).match(GLOBAL_MODIFIER_PATTERN)
265
+ if (match) parts.push({ kind: 'global', className: match[1] })
266
+ continue
267
+ }
268
+ pseudoClass ??= NATIVE_PSEUDO_BY_NAME[component.name]
269
+ continue
270
+ }
271
+ parts.push({ kind: 'local', selector: generate(component) })
272
+ }
273
+
274
+ const hasLocalBase = parts.some((part) => part.kind === 'local')
275
+ const globalClassNames = parts.flatMap((part) => (part.kind === 'global' ? [part.className] : []))
276
+
277
+ const baseParts: string[] = []
278
+ const modifiers: string[] = []
279
+ for (const part of parts) {
280
+ if (part.kind === 'local') {
281
+ baseParts.push(part.selector)
282
+ } else if (isModifierClass(part.className, hasLocalBase, globalClassNames)) {
283
+ modifiers.push(part.className)
284
+ } else {
285
+ baseParts.push(`.${part.className}`)
286
+ }
287
+ }
288
+
289
+ const base = baseParts.join('')
290
+ if (!base) return null
291
+
292
+ return { base, pseudoClass, modifiers }
293
+ }
294
+
295
+ /**
296
+ * Splits a selector into compound selectors at combinators and analyzes each. A
297
+ * `:global(.modifier)` (or native pseudo) belongs to the compound it is attached to,
298
+ * so ancestor/descendant compounds in the same selector are ignored for the base —
299
+ * e.g. `.header:global(.x--selected) .label` resolves to base `.header`, not
300
+ * `.header .label`. Returns one entry per compound that carries a modifier or pseudo.
301
+ */
302
+ function analyzeStateSelector(selector: Selector): AnalyzedCompound[] {
303
+ const compounds: CssNode[][] = [[]]
304
+ for (const component of selector.children) {
305
+ if (component.type === 'Combinator') {
306
+ compounds.push([])
307
+ } else {
308
+ compounds[compounds.length - 1].push(component)
309
+ }
310
+ }
311
+
312
+ const results: AnalyzedCompound[] = []
313
+ for (const compoundNodes of compounds) {
314
+ const analyzed = analyzeCompound(compoundNodes)
315
+ if (analyzed && (analyzed.modifiers.length > 0 || analyzed.pseudoClass)) {
316
+ results.push(analyzed)
317
+ }
318
+ }
319
+ return results
320
+ }
321
+
322
+ /**
323
+ * Walks every CSS rule and extracts `:global(.modifier)` design-state classes,
324
+ * pairing each modifier with any native pseudo-class found on the same base within
325
+ * the same rule. This realizes the contract that a selector matching both a pseudo
326
+ * and a global modifier marks a native state's editor-trigger class, while a global
327
+ * modifier with no paired pseudo marks a custom state.
328
+ */
329
+ function parseStateClasses(cssString: string): StateClassRule[] {
330
+ const results: StateClassRule[] = []
331
+
332
+ try {
333
+ const ast = parse(cssString)
334
+
335
+ walk(ast, {
336
+ visit: 'Rule',
337
+ enter(rule: Rule) {
338
+ if (rule.prelude.type !== 'SelectorList') return
339
+
340
+ const pseudoByBase = new Map<string, NativePseudoClass>()
341
+ const modifiersByBase = new Map<string, Set<string>>()
342
+
343
+ for (const selectorNode of rule.prelude.children) {
344
+ if (selectorNode.type !== 'Selector') continue
345
+ for (const analyzed of analyzeStateSelector(selectorNode)) {
346
+ if (analyzed.pseudoClass && !pseudoByBase.has(analyzed.base)) {
347
+ pseudoByBase.set(analyzed.base, analyzed.pseudoClass)
348
+ }
349
+ if (analyzed.modifiers.length > 0) {
350
+ const modifiers = modifiersByBase.get(analyzed.base) ?? new Set<string>()
351
+ for (const modifier of analyzed.modifiers) modifiers.add(modifier)
352
+ modifiersByBase.set(analyzed.base, modifiers)
353
+ }
354
+ }
355
+ }
356
+
357
+ for (const [baseSelector, modifiers] of modifiersByBase) {
358
+ const basePseudo = pseudoByBase.get(baseSelector)
359
+ for (const modifier of modifiers) {
360
+ // A modifier is the editor-trigger class for a NATIVE state only when its
361
+ // own state name matches the base's pseudo (`x--hover` ↔ `:hover`). A custom
362
+ // modifier sharing a base with a native pseudo (`x--selected` next to `:hover`)
363
+ // stays custom — it must not inherit the unrelated pseudo.
364
+ const pseudoClass = basePseudo && stateNameFromModifier(modifier) === basePseudo ? basePseudo : undefined
365
+ results.push({ baseSelector, modifier, pseudoClass })
366
+ }
367
+ }
368
+ },
369
+ })
370
+ } catch (error) {
371
+ console.error('CSS state-class parsing error:', error)
372
+ }
373
+
374
+ return results
375
+ }
376
+
201
377
  /**
202
378
  * Builds a DOM-queryable selector by filtering out pseudo-classes and pseudo-elements.
203
379
  * Returns null if the selector contains pseudo-elements (unmatchable against real DOM).
@@ -15,6 +15,26 @@ export interface MatchedCssData {
15
15
  customProperties: Record<string, string>
16
16
  }
17
17
 
18
+ export type NativePseudoClass = 'hover' | 'focus' | 'disabled' | 'invalid'
19
+
20
+ /**
21
+ * A `:global(.modifier)` design-state class found on a base element selector.
22
+ * Produced by walking each CSS rule and pairing the global modifier with any
23
+ * native pseudo-class present on the same base within that rule.
24
+ */
25
+ export interface StateClassRule {
26
+ /** DOM-matchable base selector (e.g. `.root`), used to map the modifier to an element. */
27
+ baseSelector: string
28
+ /** Global modifier class name from `:global(.X)`, without the leading dot. */
29
+ modifier: string
30
+ /**
31
+ * Native pseudo-class paired with this modifier on the same base within the same
32
+ * rule. When present, the modifier is the editor-trigger class for a native state;
33
+ * when absent, the modifier denotes a custom (non-native) design state.
34
+ */
35
+ pseudoClass?: NativePseudoClass
36
+ }
37
+
18
38
  /**
19
39
  * API returned by parseCss function for querying parsed CSS
20
40
  */
@@ -66,4 +86,11 @@ export interface CSSParserAPI {
66
86
  * @param defaultValue - The initial value string of the custom property
67
87
  */
68
88
  getVarPropertyType: (varName: string, defaultValue: string) => string | undefined
89
+
90
+ /**
91
+ * Returns every `:global(.modifier)` design-state class found in the CSS, paired
92
+ * with any native pseudo-class on the same base element within the same rule.
93
+ * Modifiers with no paired pseudo are custom (non-native) state triggers.
94
+ */
95
+ getStateClasses: () => StateClassRule[]
69
96
  }
@@ -31,3 +31,6 @@ export {
31
31
  getDefaultDisplayForTag,
32
32
  } from './css-properties'
33
33
  export type { CssPropertiesData } from './css-properties'
34
+
35
+ export { inferSupportedNativeStates } from './state-markers'
36
+ export type { SupportedNativeStates } from './state-markers'
@@ -20,6 +20,8 @@ export interface PropTrackerData {
20
20
  role?: string
21
21
  boundProps: string[]
22
22
  concatenatedAttrs: Map<string, string> // attrName → propName (for concatenated values)
23
+ /** on*-handler prop names whose value is a function on this element (e.g. ['onClick', 'onChange']). */
24
+ eventHandlers: string[]
23
25
  }
24
26
 
25
27
  export interface PropTrackerExtractorState {
@@ -110,10 +112,15 @@ export function createPropTrackerExtractor(): {
110
112
 
111
113
  const boundProps = new Set<string>()
112
114
  const concatenatedAttrs = new Map<string, string>()
115
+ const eventHandlers: string[] = []
113
116
 
114
117
  Object.entries(props).forEach(([key, value]) => {
115
118
  if (key.startsWith('__') || key === TRACE_ATTR) return
116
119
 
120
+ if (typeof value === 'function' && key.length > 2 && key.startsWith('on') && key[2] === key[2].toUpperCase()) {
121
+ eventHandlers.push(key)
122
+ }
123
+
117
124
  const spies = extractSpies(value)
118
125
 
119
126
  spies.forEach((spy) => {
@@ -141,6 +148,7 @@ export function createPropTrackerExtractor(): {
141
148
  role: htmlProps.role,
142
149
  boundProps: [...boundProps],
143
150
  concatenatedAttrs,
151
+ eventHandlers,
144
152
  }
145
153
  store.set(traceId, 'prop-tracker', data)
146
154
  },
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ExtractedElement } from './core/tree-builder'
3
+ import type { PropTrackerData } from './prop-tracker'
4
+ import { inferSupportedNativeStates } from './state-markers'
5
+
6
+ function makeElement(input: {
7
+ tag: string
8
+ attributes?: Record<string, string>
9
+ role?: string
10
+ eventHandlers?: string[]
11
+ }): ExtractedElement {
12
+ const propTrackerData: PropTrackerData = {
13
+ tag: input.tag,
14
+ role: input.role,
15
+ boundProps: [],
16
+ concatenatedAttrs: new Map(),
17
+ eventHandlers: input.eventHandlers ?? [],
18
+ }
19
+ return {
20
+ traceId: 'trace-0',
21
+ name: 'root',
22
+ tag: input.tag,
23
+ attributes: input.attributes ?? {},
24
+ extractorData: new Map([['prop-tracker', propTrackerData]]),
25
+ children: [],
26
+ }
27
+ }
28
+
29
+ describe('inferSupportedNativeStates', () => {
30
+ describe('hover + focus (interactive)', () => {
31
+ it('returns true for native form tags', () => {
32
+ for (const tag of ['button', 'a', 'input', 'select', 'textarea', 'summary']) {
33
+ const states = inferSupportedNativeStates(makeElement({ tag }))
34
+ expect(states.hover, `hover for <${tag}>`).toBe(true)
35
+ expect(states.focus, `focus for <${tag}>`).toBe(true)
36
+ }
37
+ })
38
+
39
+ it('returns true when role is interactive', () => {
40
+ const interactiveRoles = ['switch', 'checkbox', 'tab', 'menuitem', 'combobox', 'slider']
41
+ for (const role of interactiveRoles) {
42
+ const states = inferSupportedNativeStates(makeElement({ tag: 'div', role }))
43
+ expect(states.hover, `hover for role=${role}`).toBe(true)
44
+ expect(states.focus, `focus for role=${role}`).toBe(true)
45
+ }
46
+ })
47
+
48
+ it('returns true when any pointer/keyboard/form event handler is present', () => {
49
+ const handlers = ['onClick', 'onPointerDown', 'onFocus', 'onKeyDown', 'onChange']
50
+ for (const handler of handlers) {
51
+ const states = inferSupportedNativeStates(makeElement({ tag: 'div', eventHandlers: [handler] }))
52
+ expect(states.hover, `hover for ${handler}`).toBe(true)
53
+ expect(states.focus, `focus for ${handler}`).toBe(true)
54
+ }
55
+ })
56
+
57
+ it('returns false for a plain div with no signals', () => {
58
+ const states = inferSupportedNativeStates(makeElement({ tag: 'div' }))
59
+ expect(states.hover).toBe(false)
60
+ expect(states.focus).toBe(false)
61
+ })
62
+
63
+ it('reads role from the rendered attributes when prop-tracker data is absent', () => {
64
+ const element: ExtractedElement = {
65
+ traceId: 'trace-0',
66
+ name: 'root',
67
+ tag: 'div',
68
+ attributes: { role: 'button' },
69
+ extractorData: new Map(),
70
+ children: [],
71
+ }
72
+ const states = inferSupportedNativeStates(element)
73
+ expect(states.hover).toBe(true)
74
+ expect(states.focus).toBe(true)
75
+ })
76
+ })
77
+
78
+ describe('disabled', () => {
79
+ it('returns true for form-disableable native tags', () => {
80
+ for (const tag of ['button', 'input', 'select', 'textarea', 'fieldset']) {
81
+ const states = inferSupportedNativeStates(makeElement({ tag }))
82
+ expect(states.disabled, `disabled for <${tag}>`).toBe(true)
83
+ }
84
+ })
85
+
86
+ it('returns true for form-control roles', () => {
87
+ for (const role of ['button', 'switch', 'checkbox', 'radio', 'menuitem', 'tab', 'option', 'link']) {
88
+ const states = inferSupportedNativeStates(makeElement({ tag: 'div', role }))
89
+ expect(states.disabled, `disabled for role=${role}`).toBe(true)
90
+ }
91
+ })
92
+
93
+ it('returns false for plain divs and non-form roles', () => {
94
+ expect(inferSupportedNativeStates(makeElement({ tag: 'div' })).disabled).toBe(false)
95
+ expect(inferSupportedNativeStates(makeElement({ tag: 'div', role: 'navigation' })).disabled).toBe(false)
96
+ })
97
+ })
98
+
99
+ describe('invalid', () => {
100
+ it('returns true for form-validatable native tags', () => {
101
+ for (const tag of ['input', 'select', 'textarea', 'form']) {
102
+ const states = inferSupportedNativeStates(makeElement({ tag }))
103
+ expect(states.invalid, `invalid for <${tag}>`).toBe(true)
104
+ }
105
+ })
106
+
107
+ it('returns false for a plain button (not form-validatable)', () => {
108
+ expect(inferSupportedNativeStates(makeElement({ tag: 'button' })).invalid).toBe(false)
109
+ })
110
+
111
+ it('ignores the aria-invalid attribute on non-form tags', () => {
112
+ const states = inferSupportedNativeStates(makeElement({ tag: 'div', attributes: { 'aria-invalid': 'true' } }))
113
+ expect(states.invalid).toBe(false)
114
+ })
115
+ })
116
+
117
+ describe('role="presentation" / "none" opt-out', () => {
118
+ it('suppresses every native state when role is "presentation"', () => {
119
+ const states = inferSupportedNativeStates(makeElement({ tag: 'button', role: 'presentation' }))
120
+ expect(states).toEqual({ hover: false, focus: false, disabled: false, invalid: false })
121
+ })
122
+
123
+ it('suppresses every native state when role is "none"', () => {
124
+ const states = inferSupportedNativeStates(makeElement({ tag: 'input', role: 'none' }))
125
+ expect(states).toEqual({ hover: false, focus: false, disabled: false, invalid: false })
126
+ })
127
+ })
128
+
129
+ describe('tabindex="-1" suppresses focus', () => {
130
+ it('keeps hover and disabled but suppresses focus on an interactive tag with tabindex="-1"', () => {
131
+ const states = inferSupportedNativeStates(makeElement({ tag: 'button', attributes: { tabindex: '-1' } }))
132
+ expect(states.hover).toBe(true)
133
+ expect(states.focus).toBe(false)
134
+ expect(states.disabled).toBe(true)
135
+ })
136
+
137
+ it('does not affect non-interactive elements (no focus to suppress)', () => {
138
+ const states = inferSupportedNativeStates(makeElement({ tag: 'div', attributes: { tabindex: '-1' } }))
139
+ expect(states.focus).toBe(false)
140
+ expect(states.hover).toBe(false)
141
+ })
142
+ })
143
+ })
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Design-state inference from React-extractor signals.
3
+ *
4
+ * The new design (Notion: "New Design") supports only the four native states
5
+ * (hover/focus/disabled/invalid) and derives them purely from the React side:
6
+ * html tag, role attribute, and event handlers. The CSS extractor is not
7
+ * involved.
8
+ */
9
+
10
+ import type { ExtractedElement } from './core/tree-builder'
11
+ import type { PropTrackerData } from './prop-tracker'
12
+
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Types
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+
17
+ export interface SupportedNativeStates {
18
+ hover: boolean
19
+ focus: boolean
20
+ disabled: boolean
21
+ invalid: boolean
22
+ }
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Signal sets
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ const INTERACTIVE_TAGS = new Set(['button', 'a', 'input', 'select', 'textarea', 'summary'])
29
+
30
+ const INTERACTIVE_ROLES = new Set([
31
+ 'button',
32
+ 'link',
33
+ 'switch',
34
+ 'checkbox',
35
+ 'radio',
36
+ 'tab',
37
+ 'option',
38
+ 'menuitem',
39
+ 'menuitemcheckbox',
40
+ 'menuitemradio',
41
+ 'treeitem',
42
+ 'combobox',
43
+ 'slider',
44
+ 'spinbutton',
45
+ 'searchbox',
46
+ 'textbox',
47
+ 'listbox',
48
+ ])
49
+
50
+ const INTERACTIVE_EVENT_HANDLERS = new Set([
51
+ 'onClick',
52
+ 'onMouseDown',
53
+ 'onMouseUp',
54
+ 'onMouseEnter',
55
+ 'onMouseLeave',
56
+ 'onPointerDown',
57
+ 'onPointerUp',
58
+ 'onFocus',
59
+ 'onBlur',
60
+ 'onKeyDown',
61
+ 'onKeyUp',
62
+ 'onKeyPress',
63
+ 'onChange',
64
+ 'onInput',
65
+ 'onSubmit',
66
+ ])
67
+
68
+ const DISABLEABLE_TAGS = new Set(['button', 'input', 'select', 'textarea', 'fieldset'])
69
+
70
+ const DISABLEABLE_ROLES = new Set(['button', 'switch', 'checkbox', 'radio', 'menuitem', 'tab', 'option', 'link'])
71
+
72
+ const VALIDATABLE_TAGS = new Set(['input', 'select', 'textarea', 'form'])
73
+
74
+ const PRESENTATION_ROLES = new Set(['presentation', 'none'])
75
+
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+ // Inference
78
+ // ─────────────────────────────────────────────────────────────────────────────
79
+
80
+ function getRole(element: ExtractedElement): string | undefined {
81
+ const propTrackerData = element.extractorData.get('prop-tracker') as PropTrackerData | undefined
82
+ return propTrackerData?.role ?? element.attributes.role
83
+ }
84
+
85
+ function getEventHandlers(element: ExtractedElement): string[] {
86
+ const propTrackerData = element.extractorData.get('prop-tracker') as PropTrackerData | undefined
87
+ return propTrackerData?.eventHandlers ?? []
88
+ }
89
+
90
+ /**
91
+ * `role="presentation"` and `role="none"` strip the element's implicit ARIA
92
+ * semantics — a `<button role="presentation">` is no longer a button. Treat
93
+ * either as an explicit opt-out from every native state.
94
+ */
95
+ function hasPresentationRole(role: string | undefined): boolean {
96
+ return role !== undefined && PRESENTATION_ROLES.has(role)
97
+ }
98
+
99
+ function hasInteractiveSignal(tag: string, role: string | undefined, eventHandlers: string[]): boolean {
100
+ if (INTERACTIVE_TAGS.has(tag)) return true
101
+ if (role !== undefined && INTERACTIVE_ROLES.has(role)) return true
102
+ return eventHandlers.some((handler) => INTERACTIVE_EVENT_HANDLERS.has(handler))
103
+ }
104
+
105
+ function hasDisableableSignal(tag: string, role: string | undefined): boolean {
106
+ if (DISABLEABLE_TAGS.has(tag)) return true
107
+ if (role !== undefined && DISABLEABLE_ROLES.has(role)) return true
108
+ return false
109
+ }
110
+
111
+ function hasValidatableSignal(tag: string): boolean {
112
+ return VALIDATABLE_TAGS.has(tag)
113
+ }
114
+
115
+ /**
116
+ * Decides which of the four native states the given element supports, based on
117
+ * its tag, role, and event handlers.
118
+ *
119
+ * Two attribute-level overrides apply:
120
+ * - `role="presentation"` / `role="none"` opts the element out of every state.
121
+ * - `tabindex="-1"` suppresses `focus` only (the element is programmatically
122
+ * focusable but not via keyboard tab).
123
+ */
124
+ export function inferSupportedNativeStates(element: ExtractedElement): SupportedNativeStates {
125
+ const tag = element.tag.toLowerCase()
126
+ const role = getRole(element)
127
+
128
+ if (hasPresentationRole(role)) {
129
+ return { hover: false, focus: false, disabled: false, invalid: false }
130
+ }
131
+
132
+ const eventHandlers = getEventHandlers(element)
133
+ const interactive = hasInteractiveSignal(tag, role, eventHandlers)
134
+ const isUntabbable = element.attributes.tabindex === '-1'
135
+
136
+ return {
137
+ hover: interactive,
138
+ focus: interactive && !isUntabbable,
139
+ disabled: hasDisableableSignal(tag, role),
140
+ invalid: hasValidatableSignal(tag),
141
+ }
142
+ }
@@ -19,6 +19,7 @@ export {
19
19
  enrichGapProperties,
20
20
  resolveDisplayValue,
21
21
  getDefaultDisplayForTag,
22
+ inferSupportedNativeStates,
22
23
  } from './extractors'
23
24
 
24
25
  export type {
@@ -34,6 +35,7 @@ export type {
34
35
  PropTrackerData,
35
36
  PropTrackerExtractorState,
36
37
  CssPropertiesData,
38
+ SupportedNativeStates,
37
39
  } from './extractors'
38
40
 
39
41
  // ─────────────────────────────────────────────────────────────────────────────
@@ -46,3 +46,13 @@ export function findPreferredSemanticClass(classNames: string[]): string | undef
46
46
  const semanticClasses = classNames.filter(isGlobalSemanticClass)
47
47
  return semanticClasses.find((className) => !HAS_BEM_MODIFIER.test(className)) ?? semanticClasses[0]
48
48
  }
49
+
50
+ /**
51
+ * Derives the state name from a BEM modifier class: the segment after the last `--`
52
+ * (`custom-states--featured` → `featured`, `card__label--in-progress` → `in-progress`).
53
+ * Falls back to the whole modifier when no `--` is present.
54
+ */
55
+ export function stateNameFromModifier(modifier: string): string {
56
+ const separatorIndex = modifier.lastIndexOf('--')
57
+ return separatorIndex >= 0 ? modifier.slice(separatorIndex + 2) : modifier
58
+ }