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