@wix/zero-config-implementation 1.88.0 → 1.90.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.88.0",
7
+ "version": "1.90.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": "82052597b3060c3ed871541af999cd1080a045710c37fd30f1fad233"
109
+ "falconPackageHash": "3d380033b2cb76ee95040a71fda50b5bbee8d8cb70460bfb8c8a5313"
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,
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { Result, ResultAsync } from 'neverthrow'
3
3
  import React, { type ComponentType } from 'react'
4
4
 
5
5
  import { toEditorReactComponent } from './converters'
6
- import { BaseError, IoError, type NotFoundError, ParseError } from './errors'
6
+ import { BaseError, IoError, type NotFoundError, ParseError, type ValidationError } from './errors'
7
7
  import { buildContextProviderModules } from './extensions/context-providers/context'
8
8
  import { buildContextAwareWrapper, loadMockProviders } from './extensions/context-providers/mock-provider'
9
9
  import { buildRefElementContext } from './extensions/ref-elements/context'
@@ -16,6 +16,7 @@ import type { ComponentInfo } from './information-extractors/ts/types'
16
16
  import { processComponent } from './manifest-pipeline'
17
17
  import { findComponent, findDefaultComponent, loadModuleForExtraction } from './module-loader'
18
18
  import { compileTsFile } from './ts-compiler'
19
+ import { validateEditorElement } from './validators/editor-element-validator'
19
20
 
20
21
  const defaultWrapper = wrapWithWixServices
21
22
 
@@ -52,7 +53,10 @@ export function extractComponentManifestResult(
52
53
  options?: ExtractComponentManifestOptions,
53
54
  ): ResultAsync<
54
55
  ManifestResult,
55
- InstanceType<typeof NotFoundError> | InstanceType<typeof ParseError> | InstanceType<typeof IoError>
56
+ | InstanceType<typeof NotFoundError>
57
+ | InstanceType<typeof ParseError>
58
+ | InstanceType<typeof IoError>
59
+ | InstanceType<typeof ValidationError>
56
60
  > {
57
61
  // Step 1: Compile TypeScript (fatal)
58
62
  return compileTsFile(componentPath)
@@ -206,7 +210,11 @@ async function processComponentWithCleanup(
206
210
  throw processResult.error
207
211
  }
208
212
 
209
- return { component: toEditorReactComponent(processResult.component), errors: [] }
213
+ const editorReactComponent = toEditorReactComponent(processResult.component)
214
+ if (editorReactComponent.editorElement) {
215
+ validateEditorElement(editorReactComponent.editorElement, processResult.component.componentName)
216
+ }
217
+ return { component: editorReactComponent, errors: [] }
210
218
  } finally {
211
219
  await cleanup()
212
220
  }
@@ -84,14 +84,14 @@ export function parseCss(cssString: string): CSSParserAPI {
84
84
  return getSelectorSpecificity(parsed)
85
85
  },
86
86
 
87
- getVarPropertyType(varName: string, defaultValue: string): string | undefined {
87
+ getVarPropertyType(varName: string, defaultValue: string): string {
88
88
  // A `@property` registration is the declared type and wins over usage inference.
89
89
  const normalizedVarName = varName.startsWith('--') ? varName : `--${varName}`
90
90
  const registered = registeredProperties.get(normalizedVarName)
91
91
  if (registered) return registered.cssPropertyType
92
92
 
93
93
  const usages = this.getVarUsages(varName)
94
- if (usages.length === 0) return undefined
94
+ if (usages.length === 0) return inferCssDataType(defaultValue)
95
95
  const uniqueProperties = [...new Set(usages)]
96
96
  if (uniqueProperties.length === 1) {
97
97
  const camelCased = camelCase(uniqueProperties[0])
@@ -120,13 +120,13 @@ export interface CSSParserAPI {
120
120
  * Determines the CSS property type for a custom property.
121
121
  * A `@property` registration is the top-priority source (declared type). Otherwise
122
122
  * falls back to usage: if all usages of varName are within the same CSS property,
123
- * returns that property name; if usages differ, returns the CSS data type inferred
124
- * from the initial value ('color', 'length', 'number', or 'string').
125
- * Returns undefined if the variable is neither registered nor used via var().
123
+ * returns that property name; if usages differ, or if the variable is not used via
124
+ * var() at all, returns the CSS data type inferred from the initial value
125
+ * ('color', 'length', 'number', or 'string').
126
126
  * @param varName - The CSS variable name (with or without --)
127
127
  * @param defaultValue - The initial value string of the custom property
128
128
  */
129
- getVarPropertyType: (varName: string, defaultValue: string) => string | undefined
129
+ getVarPropertyType: (varName: string, defaultValue: string) => string
130
130
 
131
131
  /**
132
132
  * Returns every custom property registered via a `@property` at-rule, keyed by the
@@ -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)', () => {