@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/dist/index.js +5165 -5126
- package/package.json +2 -2
- package/src/converters/custom-states-builder.test.ts +3 -2
- package/src/converters/custom-states-builder.ts +6 -39
- package/src/converters/state-class-trigger.ts +41 -0
- package/src/converters/states-builder.test.ts +96 -0
- package/src/converters/states-builder.ts +13 -5
- package/src/converters/to-editor-component.ts +29 -16
- package/src/information-extractors/react/extractors/state-markers.test.ts +75 -7
- package/src/information-extractors/react/extractors/state-markers.ts +38 -6
- package/src/information-extractors/ts/__snapshots__/components.test.ts.snap +601 -0
- package/src/information-extractors/ts/components.test.ts +31 -0
- package/src/information-extractors/ts/components.ts +13 -0
- package/src/information-extractors/ts/utils/semantic-type-resolver.ts +40 -11
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"registry": "https://registry.npmjs.org/",
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "1.
|
|
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": "
|
|
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 {
|
|
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):
|
|
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 {
|
|
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:
|
|
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
|
|
43
|
+
const classList = elementStateClassList(element)
|
|
44
|
+
const elementClassTriggers = new Map<string, StateClassTrigger>()
|
|
47
45
|
for (const rule of classTriggers) {
|
|
48
|
-
if (!
|
|
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).
|
|
5
|
-
* React extractor (tag, role, event handlers)
|
|
6
|
-
*
|
|
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(
|
|
44
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
31
|
-
it('returns true for native
|
|
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
|
|
131
|
-
const states = inferSupportedNativeStates(makeElement({ tag: '
|
|
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)
|
|
6
|
-
*
|
|
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
|
|
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(
|
|
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
|
}
|