@wix/zero-config-implementation 1.87.0 → 1.89.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.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.87.0",
7
+ "version": "1.89.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",
@@ -106,5 +106,5 @@
106
106
  ]
107
107
  }
108
108
  },
109
- "falconPackageHash": "1db79cb96c2d3ad1aa00f8c608385635757b7899b0f11f7a74f228e4"
109
+ "falconPackageHash": "c9dcb49c34e33e60436cbc45d7c84a7433975392a119386339f5495f"
110
110
  }
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { ExtractedElement } from '../information-extractors/react'
3
3
  import type { CoupledProp } from '../information-extractors/react/types'
4
- import { type CustomClassTrigger, buildCustomStatesBlock } from './custom-states-builder'
4
+ import { buildCustomStatesBlock } from './custom-states-builder'
5
+ import type { StateClassTrigger } from './state-class-trigger'
5
6
 
6
7
  function elementWithClass(classAttribute: string): ExtractedElement {
7
8
  return { attributes: { class: classAttribute } } as unknown as ExtractedElement
@@ -11,7 +12,7 @@ function booleanStateProp(name: string): CoupledProp {
11
12
  return { name, type: 'boolean', isStateTrigger: true } as unknown as CoupledProp
12
13
  }
13
14
 
14
- function classTrigger(baseSelector: string, modifier: string, isCssModule = false): CustomClassTrigger {
15
+ function classTrigger(baseSelector: string, modifier: string, isCssModule = false): StateClassTrigger {
15
16
  return { baseSelector, modifier, isCssModule }
16
17
  }
17
18
 
@@ -18,20 +18,17 @@
18
18
 
19
19
  import type { States } from '@wix/react-component-schema'
20
20
  import { kebabCase } from 'case-anything'
21
- import type { StateClassRule } from '../information-extractors/css/types'
22
21
  import type { CoupledProp, ExtractedElement } from '../information-extractors/react'
23
- import { isGlobalSemanticClass, stateNameFromModifier } from '../utils/css-class'
22
+ import { stateNameFromModifier } from '../utils/css-class'
23
+ import { type StateClassTrigger, elementStateClassList, matchesStateBase } from './state-class-trigger'
24
24
  import { formatDisplayName } from './utils'
25
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
26
  interface BuildCustomStatesParams {
30
27
  element: ExtractedElement
31
28
  /** Prop triggers in scope for this element (root-level `ElementState<>` props; `[]` for inner elements). */
32
29
  propTriggers: CoupledProp[]
33
30
  /** Custom-class triggers (modifiers with no paired native pseudo) across all CSS files. */
34
- classTriggers: CustomClassTrigger[]
31
+ classTriggers: StateClassTrigger[]
35
32
  }
36
33
 
37
34
  export function buildCustomStatesBlock({
@@ -43,9 +40,10 @@ export function buildCustomStatesBlock({
43
40
 
44
41
  // Class triggers that target this element, keyed by their derived state name,
45
42
  // so prop triggers can look up their matching class.
46
- const elementClassTriggers = new Map<string, CustomClassTrigger>()
43
+ const classList = elementStateClassList(element)
44
+ const elementClassTriggers = new Map<string, StateClassTrigger>()
47
45
  for (const rule of classTriggers) {
48
- if (!elementMatchesBase(element, rule.baseSelector, rule.isCssModule)) continue
46
+ if (!matchesStateBase(classList, rule)) continue
49
47
  elementClassTriggers.set(stateNameFromModifier(rule.modifier), rule)
50
48
  }
51
49
 
@@ -84,34 +82,3 @@ export function buildCustomStatesBlock({
84
82
  function stateNameFromProp(propName: string): string {
85
83
  return kebabCase(propName)
86
84
  }
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
- }
@@ -0,0 +1,41 @@
1
+ import type { StateClassRule } from '../information-extractors/css/types'
2
+ import type { ExtractedElement } from '../information-extractors/react'
3
+ import { isGlobalSemanticClass } from '../utils/css-class'
4
+
5
+ /** A CSS state-class rule tagged with whether its source file is a CSS module. */
6
+ export type StateClassTrigger = StateClassRule & { isCssModule: boolean }
7
+
8
+ /** The element's rendered class list, split once so many triggers can be matched against it. */
9
+ export function elementStateClassList(element: ExtractedElement): string[] {
10
+ return element.attributes.class?.split(/\s+/).filter(Boolean) ?? []
11
+ }
12
+
13
+ /**
14
+ * Checks whether a rendered class list matches a trigger's simple single-class
15
+ * base selector (e.g. `.root`, `.custom-states`).
16
+ *
17
+ * Exact class matches always count. For CSS-module files the Vite hash patterns also
18
+ * count: `localName_HASH`, `_localName_HASH`, and the double-underscore `localName__HASH`
19
+ * form. The catch is that `localName__HASH` is syntactically identical to a BEM child
20
+ * class (`localName__child`) — which denotes a *different* element and must NOT match the
21
+ * block's base selector. We disambiguate by shape: a real BEM child is an all-lowercase
22
+ * semantic name (`isGlobalSemanticClass`), whereas a module hash carries uppercase / other
23
+ * non-kebab characters. So only genuine BEM children are excluded; hashed base classes match.
24
+ */
25
+ export function matchesStateBase(classList: string[], { baseSelector, isCssModule }: StateClassTrigger): boolean {
26
+ const match = baseSelector.match(/^\.([\w-]+)$/)
27
+ if (!match) return false
28
+
29
+ const localName = match[1]
30
+ return classList.some((className) => {
31
+ if (className === localName) return true
32
+ if (!isCssModule) return false
33
+
34
+ const isBemChild =
35
+ (className.startsWith(`${localName}__`) || className.startsWith(`_${localName}__`)) &&
36
+ isGlobalSemanticClass(className.replace(/^_/, ''))
37
+ if (isBemChild) return false
38
+
39
+ return className.startsWith(`${localName}_`) || className.startsWith(`_${localName}_`)
40
+ })
41
+ }
@@ -0,0 +1,96 @@
1
+ import { NATIVE_STATE_TYPE } from '@wix/react-component-schema'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { ExtractedElement } from '../information-extractors/react'
4
+ import type { PropTrackerData } from '../information-extractors/react/extractors/prop-tracker'
5
+ import type { StateClassTrigger } from './state-class-trigger'
6
+ import { buildStatesBlock } from './states-builder'
7
+
8
+ function makeElement(input: { tag: string; className: string; attributes?: Record<string, string> }): ExtractedElement {
9
+ const propTrackerData: PropTrackerData = {
10
+ tag: input.tag,
11
+ boundProps: [],
12
+ concatenatedAttrs: new Map(),
13
+ eventHandlers: [],
14
+ }
15
+ return {
16
+ traceId: 'trace-0',
17
+ name: 'root',
18
+ tag: input.tag,
19
+ attributes: { ...input.attributes, class: input.className },
20
+ extractorData: new Map([['prop-tracker', propTrackerData]]),
21
+ children: [],
22
+ }
23
+ }
24
+
25
+ function focusTrigger(input: {
26
+ baseSelector: string
27
+ modifier: string
28
+ isCssModule?: boolean
29
+ }): StateClassTrigger {
30
+ return { ...input, isCssModule: input.isCssModule ?? false, pseudoClass: 'focus' }
31
+ }
32
+
33
+ describe('buildStatesBlock', () => {
34
+ it('does not emit focus for a button without an authored focus state', () => {
35
+ const states = buildStatesBlock(makeElement({ tag: 'button', className: 'card-button' }), 'card-button', [])
36
+
37
+ expect(states).toEqual({
38
+ hover: {
39
+ displayName: 'Hover',
40
+ className: 'card-button--hover',
41
+ pseudoClass: NATIVE_STATE_TYPE.hover,
42
+ },
43
+ disabled: {
44
+ displayName: 'Disabled',
45
+ className: 'card-button--disabled',
46
+ pseudoClass: NATIVE_STATE_TYPE.disabled,
47
+ },
48
+ })
49
+ })
50
+
51
+ it('emits default focus for an input field', () => {
52
+ const states = buildStatesBlock(makeElement({ tag: 'input', className: 'card-input' }), 'card-input', [])
53
+
54
+ expect(states?.focus).toEqual({
55
+ displayName: 'Focus',
56
+ className: 'card-input--focus',
57
+ pseudoClass: NATIVE_STATE_TYPE.focus,
58
+ })
59
+ })
60
+
61
+ it('uses a matching authored focus modifier to opt in a button', () => {
62
+ const states = buildStatesBlock(makeElement({ tag: 'button', className: 'button_HASH' }), 'card-button', [
63
+ focusTrigger({ baseSelector: '.button', modifier: 'card-button--focus', isCssModule: true }),
64
+ ])
65
+
66
+ expect(states?.focus).toEqual({
67
+ displayName: 'Focus',
68
+ className: 'card-button--focus',
69
+ pseudoClass: NATIVE_STATE_TYPE.focus,
70
+ })
71
+ })
72
+
73
+ it('synthesizes the state className even when the authored modifier differs', () => {
74
+ const states = buildStatesBlock(makeElement({ tag: 'button', className: 'card-button' }), 'card-button', [
75
+ focusTrigger({ baseSelector: '.card-button', modifier: 'legacy-focus' }),
76
+ ])
77
+
78
+ expect(states?.focus?.className).toBe('card-button--focus')
79
+ })
80
+
81
+ it('ignores an authored focus modifier on a non-interactive element', () => {
82
+ const states = buildStatesBlock(makeElement({ tag: 'div', className: 'card-badge' }), 'card-badge', [
83
+ focusTrigger({ baseSelector: '.card-badge', modifier: 'card-badge--focus' }),
84
+ ])
85
+
86
+ expect(states).toBeUndefined()
87
+ })
88
+
89
+ it('ignores an authored focus modifier targeting another element', () => {
90
+ const states = buildStatesBlock(makeElement({ tag: 'button', className: 'secondary-button' }), 'secondary-button', [
91
+ focusTrigger({ baseSelector: '.primary-button', modifier: 'primary-button--focus' }),
92
+ ])
93
+
94
+ expect(states?.focus).toBeUndefined()
95
+ })
96
+ })
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Builds the `states` block for the root EditorElement and inline elements.
3
3
  *
4
- * Native states only (hover/focus/disabled/invalid). Signals come from the
5
- * React extractor (tag, role, event handlers). The CSS extractor is not
6
- * consulted.
4
+ * Native states only (hover/focus/disabled/invalid). Default signals come from
5
+ * the React extractor (tag, role, event handlers); a paired CSS focus modifier
6
+ * opts an interactive element into the focus state. The CSS is a signal only —
7
+ * the emitted className is always synthesized, like every other native state.
7
8
  *
8
9
  * className follows BEM by appending `--<state>` to the element's own
9
10
  * semantic class. Component authors are expected to write inner elements
@@ -23,6 +24,7 @@ import {
23
24
  type SupportedNativeStates,
24
25
  inferSupportedNativeStates,
25
26
  } from '../information-extractors/react'
27
+ import { type StateClassTrigger, elementStateClassList, matchesStateBase } from './state-class-trigger'
26
28
 
27
29
  const STATE_DISPLAY_NAMES = {
28
30
  hover: 'Hover',
@@ -40,8 +42,14 @@ const PSEUDO_CLASS_BY_STATE: Record<NativeStateName, NATIVE_STATE_TYPE> = {
40
42
  invalid: NATIVE_STATE_TYPE.invalid,
41
43
  }
42
44
 
43
- export function buildStatesBlock(element: ExtractedElement, semanticClass: string): States | undefined {
44
- const supported = inferSupportedNativeStates(element)
45
+ export function buildStatesBlock(
46
+ element: ExtractedElement,
47
+ semanticClass: string,
48
+ focusTriggers: StateClassTrigger[],
49
+ ): States | undefined {
50
+ const classList = elementStateClassList(element)
51
+ const authoredFocus = focusTriggers.some((trigger) => matchesStateBase(classList, trigger))
52
+ const supported = inferSupportedNativeStates(element, { authoredFocus })
45
53
  const states: States = {}
46
54
 
47
55
  for (const stateName of Object.keys(supported) as NativeStateName[]) {
@@ -27,8 +27,9 @@ import { getDefaultDisplayForTag, resolveDisplayValue } from '../information-ext
27
27
  import { findPreferredSemanticClass, normalizeClassNames } from '../utils/css-class'
28
28
  import { buildActiveItemDisplayGroups, buildActiveItemIndexDisplayFilters } from './active-item-index-builder'
29
29
  import { resolveLonghand } from './css-longhand-resolver'
30
- import { type CustomClassTrigger, buildCustomStatesBlock } from './custom-states-builder'
30
+ import { buildCustomStatesBlock } from './custom-states-builder'
31
31
  import { buildDataItem } from './data-item-builder'
32
+ import type { StateClassTrigger } from './state-class-trigger'
32
33
  import { buildStatesBlock } from './states-builder'
33
34
  import { formatDisplayName } from './utils'
34
35
 
@@ -43,14 +44,26 @@ function mergeStates(native: States | undefined, custom: States | undefined): St
43
44
  return Object.keys(merged).length > 0 ? merged : undefined
44
45
  }
45
46
 
46
- /** Collects custom-class state triggers (modifiers with no paired native pseudo) across all CSS files. */
47
- function collectCustomClassTriggers(component: ComponentInfoWithCss): CustomClassTrigger[] {
48
- return component.css.flatMap((cssInfo) =>
49
- cssInfo.api
50
- .getStateClasses()
51
- .filter((rule) => rule.pseudoClass === undefined)
52
- .map((rule) => ({ ...rule, isCssModule: cssInfo.isCssModule })),
47
+ /** State-class triggers grouped by the builder that consumes them. */
48
+ interface StateClassTriggers {
49
+ /** Modifiers paired with `:focus` — they opt an element into the native focus state. */
50
+ focus: StateClassTrigger[]
51
+ /** Modifiers with no paired pseudo-class — they denote custom (non-native) states. */
52
+ custom: StateClassTrigger[]
53
+ }
54
+
55
+ /**
56
+ * Collects state-class triggers across all CSS files, grouped up front so each element
57
+ * matches against only the rules its builder can use.
58
+ */
59
+ function collectStateClassTriggers(component: ComponentInfoWithCss): StateClassTriggers {
60
+ const triggers = component.css.flatMap((cssInfo) =>
61
+ cssInfo.api.getStateClasses().map((rule) => ({ ...rule, isCssModule: cssInfo.isCssModule })),
53
62
  )
63
+ return {
64
+ focus: triggers.filter((rule) => rule.pseudoClass === 'focus'),
65
+ custom: triggers.filter((rule) => rule.pseudoClass === undefined),
66
+ }
54
67
  }
55
68
 
56
69
  // Props to exclude from data items (same as in element-data.ts)
@@ -77,7 +90,7 @@ function buildEditorElement(
77
90
  const childElements = rootElement.children
78
91
  const rootCustomProps = nearestCommonAncestorCustomProps.get(rootElement.traceId) ?? {}
79
92
 
80
- const customClassTriggers = collectCustomClassTriggers(component)
93
+ const stateClassTriggers = collectStateClassTriggers(component)
81
94
 
82
95
  // Native states need a semantic block name to synthesize `<block>--<state>`; custom
83
96
  // class triggers don't (they carry the className verbatim and match the element
@@ -86,11 +99,11 @@ function buildEditorElement(
86
99
  const rootSemanticClasses = rootSemanticClass ? [rootSemanticClass] : []
87
100
  const rootStates = rootElement
88
101
  ? mergeStates(
89
- rootSemanticClass ? buildStatesBlock(rootElement, rootSemanticClass) : undefined,
102
+ rootSemanticClass ? buildStatesBlock(rootElement, rootSemanticClass, stateClassTriggers.focus) : undefined,
90
103
  buildCustomStatesBlock({
91
104
  element: rootElement,
92
105
  propTriggers: Object.values(component.props).filter((prop) => prop.isStateTrigger),
93
- classTriggers: customClassTriggers,
106
+ classTriggers: stateClassTriggers.custom,
94
107
  }),
95
108
  )
96
109
  : undefined
@@ -105,7 +118,7 @@ function buildEditorElement(
105
118
  elements: buildElements(
106
119
  childElements,
107
120
  nearestCommonAncestorCustomProps,
108
- customClassTriggers,
121
+ stateClassTriggers,
109
122
  rootSemanticClasses,
110
123
  component.innerElementProps,
111
124
  component.propUsages,
@@ -176,7 +189,7 @@ function buildData(
176
189
  function buildElements(
177
190
  elements: ExtractedElement[],
178
191
  nearestCommonAncestorCustomProps: Map<string, Record<string, CssCustomPropertyItem>>,
179
- customClassTriggers: CustomClassTrigger[],
192
+ stateClassTriggers: StateClassTriggers,
180
193
  ancestorSemanticClasses: string[],
181
194
  innerElementProps?: CoupledComponentInfo['innerElementProps'],
182
195
  propUsages?: TrackingStores['propUsages'],
@@ -208,8 +221,8 @@ function buildElements(
208
221
  // Inner-element custom states come from custom-class triggers only; prop
209
222
  // triggers (`ElementState<>`) are component-level and attach to the root.
210
223
  const states = mergeStates(
211
- semanticClass ? buildStatesBlock(element, semanticClass) : undefined,
212
- buildCustomStatesBlock({ element, propTriggers: [], classTriggers: customClassTriggers }),
224
+ semanticClass ? buildStatesBlock(element, semanticClass, stateClassTriggers.focus) : undefined,
225
+ buildCustomStatesBlock({ element, propTriggers: [], classTriggers: stateClassTriggers.custom }),
213
226
  )
214
227
 
215
228
  result[element.name] = {
@@ -231,7 +244,7 @@ function buildElements(
231
244
  ? buildElements(
232
245
  element.children,
233
246
  nearestCommonAncestorCustomProps,
234
- customClassTriggers,
247
+ stateClassTriggers,
235
248
  semanticClass ? [...ancestorSemanticClasses, semanticClass] : ancestorSemanticClasses,
236
249
  innerElementProps,
237
250
  propUsages,
@@ -27,12 +27,11 @@ function makeElement(input: {
27
27
  }
28
28
 
29
29
  describe('inferSupportedNativeStates', () => {
30
- describe('hover + focus (interactive)', () => {
31
- it('returns true for native form tags', () => {
30
+ describe('hover (interactive)', () => {
31
+ it('returns true for native interactive tags', () => {
32
32
  for (const tag of ['button', 'a', 'input', 'select', 'textarea', 'summary']) {
33
33
  const states = inferSupportedNativeStates(makeElement({ tag }))
34
34
  expect(states.hover, `hover for <${tag}>`).toBe(true)
35
- expect(states.focus, `focus for <${tag}>`).toBe(true)
36
35
  }
37
36
  })
38
37
 
@@ -41,7 +40,6 @@ describe('inferSupportedNativeStates', () => {
41
40
  for (const role of interactiveRoles) {
42
41
  const states = inferSupportedNativeStates(makeElement({ tag: 'div', role }))
43
42
  expect(states.hover, `hover for role=${role}`).toBe(true)
44
- expect(states.focus, `focus for role=${role}`).toBe(true)
45
43
  }
46
44
  })
47
45
 
@@ -50,7 +48,6 @@ describe('inferSupportedNativeStates', () => {
50
48
  for (const handler of handlers) {
51
49
  const states = inferSupportedNativeStates(makeElement({ tag: 'div', eventHandlers: [handler] }))
52
50
  expect(states.hover, `hover for ${handler}`).toBe(true)
53
- expect(states.focus, `focus for ${handler}`).toBe(true)
54
51
  }
55
52
  })
56
53
 
@@ -71,8 +68,71 @@ describe('inferSupportedNativeStates', () => {
71
68
  }
72
69
  const states = inferSupportedNativeStates(element)
73
70
  expect(states.hover).toBe(true)
71
+ })
72
+ })
73
+
74
+ describe('focus', () => {
75
+ it('returns true for native input fields', () => {
76
+ for (const tag of ['input', 'select', 'textarea']) {
77
+ expect(inferSupportedNativeStates(makeElement({ tag })).focus, `focus for <${tag}>`).toBe(true)
78
+ }
79
+ })
80
+
81
+ it('returns true for input-widget roles', () => {
82
+ const inputWidgetRoles = [
83
+ 'checkbox',
84
+ 'radio',
85
+ 'switch',
86
+ 'slider',
87
+ 'spinbutton',
88
+ 'textbox',
89
+ 'searchbox',
90
+ 'combobox',
91
+ 'listbox',
92
+ ]
93
+ for (const role of inputWidgetRoles) {
94
+ expect(inferSupportedNativeStates(makeElement({ tag: 'div', role })).focus, `focus for role=${role}`).toBe(true)
95
+ }
96
+ })
97
+
98
+ it('returns false for non-input interactive tags, roles, and event handlers', () => {
99
+ for (const tag of ['button', 'a', 'summary']) {
100
+ expect(inferSupportedNativeStates(makeElement({ tag })).focus, `focus for <${tag}>`).toBe(false)
101
+ }
102
+ for (const role of ['button', 'link', 'tab', 'menuitem']) {
103
+ expect(inferSupportedNativeStates(makeElement({ tag: 'div', role })).focus, `focus for role=${role}`).toBe(
104
+ false,
105
+ )
106
+ }
107
+ for (const eventHandler of ['onClick', 'onFocus', 'onChange']) {
108
+ expect(
109
+ inferSupportedNativeStates(makeElement({ tag: 'div', eventHandlers: [eventHandler] })).focus,
110
+ `focus for ${eventHandler}`,
111
+ ).toBe(false)
112
+ }
113
+ })
114
+
115
+ it('returns false for hidden and button-like input types', () => {
116
+ for (const inputType of ['hidden', 'button', 'submit', 'reset', 'image']) {
117
+ const states = inferSupportedNativeStates(makeElement({ tag: 'input', attributes: { type: inputType } }))
118
+ expect(states.focus, `focus for input type=${inputType}`).toBe(false)
119
+ }
120
+ })
121
+
122
+ it('uses an explicit role instead of the tag implicit semantics', () => {
123
+ expect(inferSupportedNativeStates(makeElement({ tag: 'input', role: 'button' })).focus).toBe(false)
124
+ expect(inferSupportedNativeStates(makeElement({ tag: 'button', role: 'checkbox' })).focus).toBe(true)
125
+ })
126
+
127
+ it('allows an authored focus state to opt in a non-input interactive element', () => {
128
+ const states = inferSupportedNativeStates(makeElement({ tag: 'button' }), { authoredFocus: true })
74
129
  expect(states.focus).toBe(true)
75
130
  })
131
+
132
+ it('ignores an authored focus state on a non-interactive element', () => {
133
+ const states = inferSupportedNativeStates(makeElement({ tag: 'div' }), { authoredFocus: true })
134
+ expect(states.focus).toBe(false)
135
+ })
76
136
  })
77
137
 
78
138
  describe('disabled', () => {
@@ -127,11 +187,19 @@ describe('inferSupportedNativeStates', () => {
127
187
  })
128
188
 
129
189
  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' } }))
190
+ it('keeps hover and invalid but suppresses default focus on an input with tabindex="-1"', () => {
191
+ const states = inferSupportedNativeStates(makeElement({ tag: 'input', attributes: { tabindex: '-1' } }))
132
192
  expect(states.hover).toBe(true)
133
193
  expect(states.focus).toBe(false)
134
194
  expect(states.disabled).toBe(true)
195
+ expect(states.invalid).toBe(true)
196
+ })
197
+
198
+ it('suppresses an explicitly authored focus state on an untabbable element', () => {
199
+ const states = inferSupportedNativeStates(makeElement({ tag: 'button', attributes: { tabindex: '-1' } }), {
200
+ authoredFocus: true,
201
+ })
202
+ expect(states.focus).toBe(false)
135
203
  })
136
204
 
137
205
  it('does not affect non-interactive elements (no focus to suppress)', () => {
@@ -2,9 +2,8 @@
2
2
  * Design-state inference from React-extractor signals.
3
3
  *
4
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.
5
+ * (hover/focus/disabled/invalid). React signals provide the defaults; a caller
6
+ * may additionally report a focus state the component author wrote in CSS.
8
7
  */
9
8
 
10
9
  import type { ExtractedElement } from './core/tree-builder'
@@ -65,6 +64,25 @@ const INTERACTIVE_EVENT_HANDLERS = new Set([
65
64
  'onSubmit',
66
65
  ])
67
66
 
67
+ // Elements that get a focus design state without the author asking for one: the
68
+ // ones whose focused appearance is part of using them. Mirrored in the wix-app
69
+ // skill's `editor-react-component/DESIGN-STATES.md` eligibility table.
70
+ const DEFAULT_FOCUS_TAGS = new Set(['select', 'textarea'])
71
+
72
+ const DEFAULT_FOCUS_ROLES = new Set([
73
+ 'checkbox',
74
+ 'radio',
75
+ 'switch',
76
+ 'slider',
77
+ 'spinbutton',
78
+ 'textbox',
79
+ 'searchbox',
80
+ 'combobox',
81
+ 'listbox',
82
+ ])
83
+
84
+ const INPUT_TYPES_WITHOUT_FOCUS_STATE = new Set(['hidden', 'button', 'submit', 'reset', 'image'])
85
+
68
86
  const DISABLEABLE_TAGS = new Set(['button', 'input', 'select', 'textarea', 'fieldset'])
69
87
 
70
88
  const DISABLEABLE_ROLES = new Set(['button', 'switch', 'checkbox', 'radio', 'menuitem', 'tab', 'option', 'link'])
@@ -102,6 +120,15 @@ function hasInteractiveSignal(tag: string, role: string | undefined, eventHandle
102
120
  return eventHandlers.some((handler) => INTERACTIVE_EVENT_HANDLERS.has(handler))
103
121
  }
104
122
 
123
+ function hasDefaultFocusSignal(element: ExtractedElement, tag: string, role: string | undefined): boolean {
124
+ if (role !== undefined) return DEFAULT_FOCUS_ROLES.has(role)
125
+ if (DEFAULT_FOCUS_TAGS.has(tag)) return true
126
+ if (tag !== 'input') return false
127
+
128
+ const inputType = element.attributes.type?.toLowerCase()
129
+ return inputType === undefined || !INPUT_TYPES_WITHOUT_FOCUS_STATE.has(inputType)
130
+ }
131
+
105
132
  function hasDisableableSignal(tag: string, role: string | undefined): boolean {
106
133
  if (DISABLEABLE_TAGS.has(tag)) return true
107
134
  if (role !== undefined && DISABLEABLE_ROLES.has(role)) return true
@@ -114,14 +141,19 @@ function hasValidatableSignal(tag: string): boolean {
114
141
 
115
142
  /**
116
143
  * Decides which of the four native states the given element supports, based on
117
- * its tag, role, and event handlers.
144
+ * its tag, role, event handlers, and `authoredFocus` — a paired focus modifier
145
+ * the component author wrote in CSS, which opts an interactive element into the
146
+ * focus state it does not get by default.
118
147
  *
119
148
  * Two attribute-level overrides apply:
120
149
  * - `role="presentation"` / `role="none"` opts the element out of every state.
121
150
  * - `tabindex="-1"` suppresses `focus` only (the element is programmatically
122
151
  * focusable but not via keyboard tab).
123
152
  */
124
- export function inferSupportedNativeStates(element: ExtractedElement): SupportedNativeStates {
153
+ export function inferSupportedNativeStates(
154
+ element: ExtractedElement,
155
+ { authoredFocus = false }: { authoredFocus?: boolean } = {},
156
+ ): SupportedNativeStates {
125
157
  const tag = element.tag.toLowerCase()
126
158
  const role = getRole(element)
127
159
 
@@ -135,7 +167,7 @@ export function inferSupportedNativeStates(element: ExtractedElement): Supported
135
167
 
136
168
  return {
137
169
  hover: interactive,
138
- focus: interactive && !isUntabbable,
170
+ focus: interactive && !isUntabbable && (hasDefaultFocusSignal(element, tag, role) || authoredFocus),
139
171
  disabled: hasDisableableSignal(tag, role),
140
172
  invalid: hasValidatableSignal(tag),
141
173
  }