@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.
@@ -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
  }
@@ -0,0 +1,142 @@
1
+ import type { CssCustomPropertyItem, EditorElement, ElementItem } from '@wix/react-component-schema'
2
+ import { CSS_PROPERTIES, ELEMENTS } from '@wix/react-component-schema'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { ValidationError } from '../errors'
6
+ import { validateEditorElement } from './editor-element-validator'
7
+
8
+ function buildMinimalEditorElement(overrides: Partial<EditorElement> = {}): EditorElement {
9
+ return {
10
+ selector: '.root',
11
+ displayName: 'Test Component',
12
+ ...overrides,
13
+ }
14
+ }
15
+
16
+ function buildInlineElementItem(overrides: Partial<ElementItem> = {}): ElementItem {
17
+ return {
18
+ elementType: ELEMENTS.ELEMENT_TYPE.inlineElement,
19
+ inlineElement: {
20
+ selector: '.child',
21
+ displayName: 'Child',
22
+ },
23
+ ...overrides,
24
+ }
25
+ }
26
+
27
+ function buildCssCustomPropertyItem(cssPropertyType: CssCustomPropertyItem['cssPropertyType']): CssCustomPropertyItem {
28
+ return { cssPropertyType }
29
+ }
30
+
31
+ describe('validateEditorElement', () => {
32
+ it('does not throw for a valid minimal editorElement', () => {
33
+ const editorElement = buildMinimalEditorElement()
34
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).not.toThrow()
35
+ })
36
+
37
+ it('does not throw when cssCustomProperties have valid property types', () => {
38
+ const editorElement = buildMinimalEditorElement({
39
+ cssCustomProperties: {
40
+ color: buildCssCustomPropertyItem(CSS_PROPERTIES.CSS_PROPERTY_TYPE.color),
41
+ size: buildCssCustomPropertyItem(CSS_PROPERTIES.CSS_PROPERTY_TYPE.length),
42
+ },
43
+ })
44
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).not.toThrow()
45
+ })
46
+
47
+ it('throws ValidationError when a cssCustomProperty has UNKNOWN cssPropertyType', () => {
48
+ const editorElement = buildMinimalEditorElement({
49
+ cssCustomProperties: {
50
+ brokenProp: buildCssCustomPropertyItem(CSS_PROPERTIES.CSS_PROPERTY_TYPE.UNKNOWN_CssPropertyType),
51
+ },
52
+ })
53
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(ValidationError)
54
+ })
55
+
56
+ it('includes the field path in the error message for UNKNOWN cssPropertyType', () => {
57
+ const editorElement = buildMinimalEditorElement({
58
+ cssCustomProperties: {
59
+ brokenProp: buildCssCustomPropertyItem(CSS_PROPERTIES.CSS_PROPERTY_TYPE.UNKNOWN_CssPropertyType),
60
+ },
61
+ })
62
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(
63
+ 'editorElement.cssCustomProperties.brokenProp',
64
+ )
65
+ })
66
+
67
+ it('throws ValidationError when a cssCustomProperty is missing cssPropertyType', () => {
68
+ const editorElement = buildMinimalEditorElement({
69
+ cssCustomProperties: {
70
+ missingTypeProp: {} as CssCustomPropertyItem,
71
+ },
72
+ })
73
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(ValidationError)
74
+ })
75
+
76
+ it('throws ValidationError when an elements entry has UNKNOWN elementType', () => {
77
+ const editorElement = buildMinimalEditorElement({
78
+ elements: {
79
+ child: {
80
+ elementType: ELEMENTS.ELEMENT_TYPE.UNKNOWN_ElementType,
81
+ inlineElement: { selector: '.child', displayName: 'Child' },
82
+ },
83
+ },
84
+ })
85
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(ValidationError)
86
+ })
87
+
88
+ it('throws ValidationError when an elements entry is missing elementType', () => {
89
+ const editorElement = buildMinimalEditorElement({
90
+ elements: {
91
+ child: buildInlineElementItem({ elementType: undefined }),
92
+ },
93
+ })
94
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(ValidationError)
95
+ })
96
+
97
+ it('collects all violations and throws once with all of them listed', () => {
98
+ const editorElement = buildMinimalEditorElement({
99
+ cssCustomProperties: {
100
+ badProp: buildCssCustomPropertyItem(CSS_PROPERTIES.CSS_PROPERTY_TYPE.UNKNOWN_CssPropertyType),
101
+ },
102
+ elements: {
103
+ child: {
104
+ elementType: ELEMENTS.ELEMENT_TYPE.UNKNOWN_ElementType,
105
+ inlineElement: { selector: '.child', displayName: 'Child' },
106
+ },
107
+ },
108
+ })
109
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(
110
+ /editorElement\.cssCustomProperties\.badProp.*\n.*editorElement\.elements\.child/s,
111
+ )
112
+ })
113
+
114
+ it('validates nested inline element cssCustomProperties recursively', () => {
115
+ const editorElement = buildMinimalEditorElement({
116
+ elements: {
117
+ child: {
118
+ elementType: ELEMENTS.ELEMENT_TYPE.inlineElement,
119
+ inlineElement: {
120
+ selector: '.child',
121
+ displayName: 'Child',
122
+ cssCustomProperties: {
123
+ nestedBad: buildCssCustomPropertyItem(CSS_PROPERTIES.CSS_PROPERTY_TYPE.UNKNOWN_CssPropertyType),
124
+ },
125
+ },
126
+ },
127
+ },
128
+ })
129
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(
130
+ 'editorElement.elements.child.inlineElement.cssCustomProperties.nestedBad',
131
+ )
132
+ })
133
+
134
+ it('includes the component name in the error message', () => {
135
+ const editorElement = buildMinimalEditorElement({
136
+ cssCustomProperties: {
137
+ badProp: buildCssCustomPropertyItem(CSS_PROPERTIES.CSS_PROPERTY_TYPE.UNKNOWN_CssPropertyType),
138
+ },
139
+ })
140
+ expect(() => validateEditorElement(editorElement, 'MyButton')).toThrow('MyButton')
141
+ })
142
+ })
@@ -0,0 +1,64 @@
1
+ import type {
2
+ CssCustomPropertyItem,
3
+ EditorReactComponent,
4
+ ElementItem,
5
+ InlineElement,
6
+ } from '@wix/react-component-schema'
7
+ import { CSS_PROPERTIES, ELEMENTS } from '@wix/react-component-schema'
8
+
9
+ import { ValidationError } from '../errors'
10
+
11
+ type RawEditorElement = NonNullable<EditorReactComponent['editorElement']>
12
+ type AnyElement = RawEditorElement | InlineElement
13
+
14
+ export function validateEditorElement(editorElement: RawEditorElement, componentName: string): void {
15
+ const violations = collectElementViolations(editorElement, 'editorElement')
16
+ if (violations.length === 0) return
17
+
18
+ const violationList = violations.map((violation) => ` - ${violation}`).join('\n')
19
+ throw new ValidationError(`Component "${componentName}" produced an invalid editorElement:\n${violationList}`, {
20
+ props: { phase: 'conversion' },
21
+ })
22
+ }
23
+
24
+ function collectElementViolations(element: AnyElement, elementPath: string): string[] {
25
+ return [
26
+ ...validateCssCustomProperties(element.cssCustomProperties, elementPath),
27
+ ...validateElementsMap(element.elements, elementPath),
28
+ ]
29
+ }
30
+
31
+ function validateCssCustomProperties(
32
+ cssCustomProperties: Record<string, CssCustomPropertyItem> | undefined,
33
+ elementPath: string,
34
+ ): string[] {
35
+ if (!cssCustomProperties) return []
36
+
37
+ const violations: string[] = []
38
+ for (const [propKey, propValue] of Object.entries(cssCustomProperties)) {
39
+ const unknownType = CSS_PROPERTIES.CSS_PROPERTY_TYPE.UNKNOWN_CssPropertyType
40
+ if (!propValue.cssPropertyType || propValue.cssPropertyType === unknownType) {
41
+ violations.push(`${elementPath}.cssCustomProperties.${propKey}: cssPropertyType is not set or UNKNOWN`)
42
+ }
43
+ }
44
+ return violations
45
+ }
46
+
47
+ function validateElementsMap(elements: Record<string, ElementItem> | undefined, elementPath: string): string[] {
48
+ if (!elements) return []
49
+
50
+ const violations: string[] = []
51
+ for (const [elementKey, elementItem] of Object.entries(elements)) {
52
+ const itemPath = `${elementPath}.elements.${elementKey}`
53
+ const unknownType = ELEMENTS.ELEMENT_TYPE.UNKNOWN_ElementType
54
+
55
+ if (!elementItem.elementType || elementItem.elementType === unknownType) {
56
+ violations.push(`${itemPath}: elementType is not set or UNKNOWN`)
57
+ }
58
+
59
+ if (elementItem.inlineElement) {
60
+ violations.push(...collectElementViolations(elementItem.inlineElement, `${itemPath}.inlineElement`))
61
+ }
62
+ }
63
+ return violations
64
+ }