@wix/zero-config-implementation 1.97.0 → 1.99.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.d.ts +4 -0
- package/dist/index.js +6417 -6403
- package/package.json +2 -2
- package/src/component-renderer.ts +22 -5
- package/src/converters/states-builder.test.ts +1 -0
- package/src/converters/to-editor-component.test.ts +41 -18
- package/src/converters/to-editor-component.ts +9 -4
- package/src/information-extractors/react/extractors/core/tree-builder.test.ts +186 -0
- package/src/information-extractors/react/extractors/core/tree-builder.ts +41 -5
- package/src/information-extractors/react/extractors/core/types.ts +2 -0
- package/src/information-extractors/react/extractors/css-properties.test.ts +12 -2
- package/src/information-extractors/react/extractors/css-properties.ts +17 -2
- package/src/information-extractors/react/extractors/prop-tracker.test.ts +19 -1
- package/src/information-extractors/react/extractors/prop-tracker.ts +3 -0
- package/src/information-extractors/react/extractors/state-markers.test.ts +1 -0
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.99.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": "905a961b05476216ee10b85e56b0d50818e5247abe594c0917619c0a"
|
|
110
110
|
}
|
|
@@ -94,6 +94,16 @@ const isDOMTag = (type: unknown): type is string => typeof type === 'string' &&
|
|
|
94
94
|
|
|
95
95
|
type ElementCreator = (type: unknown, props: unknown, ...rest: unknown[]) => ReactElement
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether a child slot rendered nothing - what a node prop mocked to null leaves
|
|
99
|
+
* behind. `false` is excluded: it is what an unrendered conditional leaves, and a
|
|
100
|
+
* conditional on a mocked boolean would make the slot depend on the faker sequence.
|
|
101
|
+
*/
|
|
102
|
+
function isEmptySlot(child: unknown): boolean {
|
|
103
|
+
if (Array.isArray(child)) return child.some(isEmptySlot)
|
|
104
|
+
return child === null || child === undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
/**
|
|
98
108
|
* Creates an intercepted version of an element creation function.
|
|
99
109
|
* Works with both createElement (children as rest params) and jsx/jsxs (children in props).
|
|
@@ -103,14 +113,19 @@ function createInterceptor(
|
|
|
103
113
|
listeners: CreateElementListener[],
|
|
104
114
|
getNextId: () => string,
|
|
105
115
|
store: ExtractorStore,
|
|
116
|
+
/** jsx/jsxs/jsxDEV take children in props and a key as the third argument. */
|
|
117
|
+
childrenInProps: boolean,
|
|
106
118
|
): ElementCreator {
|
|
107
119
|
return (type, elementProps, ...rest) => {
|
|
108
120
|
const props = (elementProps ?? {}) as Record<string, unknown>
|
|
109
121
|
const isDomElement = isDOMTag(type)
|
|
110
122
|
const traceId = isDomElement ? getNextId() : undefined
|
|
111
123
|
|
|
112
|
-
// Children location differs: createElement
|
|
113
|
-
|
|
124
|
+
// Children location differs: createElement takes rest params but also accepts them
|
|
125
|
+
// in props, jsx/jsxs only in props. Keyed on presence, not truthiness, so a slot
|
|
126
|
+
// that rendered nothing is still visible.
|
|
127
|
+
const children = childrenInProps || rest.length === 0 ? ('children' in props ? [props.children] : []) : rest
|
|
128
|
+
const hasEmptySlot = children.some(isEmptySlot)
|
|
114
129
|
|
|
115
130
|
const event: CreateElementEvent = {
|
|
116
131
|
type,
|
|
@@ -119,6 +134,7 @@ function createInterceptor(
|
|
|
119
134
|
props: { ...props, children },
|
|
120
135
|
traceId,
|
|
121
136
|
children: children as unknown[],
|
|
137
|
+
hasEmptySlot,
|
|
122
138
|
store,
|
|
123
139
|
}
|
|
124
140
|
|
|
@@ -322,10 +338,11 @@ export function renderWithExtractors(
|
|
|
322
338
|
listeners,
|
|
323
339
|
getNextId,
|
|
324
340
|
store,
|
|
341
|
+
false,
|
|
325
342
|
)
|
|
326
|
-
const interceptedJsx = createInterceptor(originalJsx as ElementCreator, listeners, getNextId, store)
|
|
327
|
-
const interceptedJsxs = createInterceptor(originalJsxs as ElementCreator, listeners, getNextId, store)
|
|
328
|
-
const interceptedJsxDEV = createInterceptor(originalJsxDEV as ElementCreator, listeners, getNextId, store)
|
|
343
|
+
const interceptedJsx = createInterceptor(originalJsx as ElementCreator, listeners, getNextId, store, true)
|
|
344
|
+
const interceptedJsxs = createInterceptor(originalJsxs as ElementCreator, listeners, getNextId, store, true)
|
|
345
|
+
const interceptedJsxDEV = createInterceptor(originalJsxDEV as ElementCreator, listeners, getNextId, store, true)
|
|
329
346
|
|
|
330
347
|
// The shim reads globalThis[REACT_KEY] on every hook call, so pointing it at
|
|
331
348
|
// userReact ensures ESM hooks use the same dispatcher that SSR drives.
|
|
@@ -43,11 +43,22 @@ function createElementWithDisplayMatcher(tag: string, matcherData: MatchedCssDat
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function createComponent(rootElement: ExtractedElement): ComponentInfoWithCss {
|
|
46
|
+
function createComponent(rootElement: ExtractedElement, renderDisplayElementAsChild = false): ComponentInfoWithCss {
|
|
47
|
+
const componentRoot = renderDisplayElementAsChild
|
|
48
|
+
? {
|
|
49
|
+
traceId: 'trace-container',
|
|
50
|
+
name: 'container',
|
|
51
|
+
tag: 'div',
|
|
52
|
+
attributes: {},
|
|
53
|
+
extractorData: new Map<string, unknown>(),
|
|
54
|
+
children: [{ ...rootElement, traceId: 'trace-display', name: 'display' }],
|
|
55
|
+
}
|
|
56
|
+
: rootElement
|
|
57
|
+
|
|
47
58
|
return {
|
|
48
59
|
componentName: 'DisplayComponent',
|
|
49
60
|
props: {},
|
|
50
|
-
elements: [
|
|
61
|
+
elements: [componentRoot],
|
|
51
62
|
propUsages: new Map(),
|
|
52
63
|
css: [],
|
|
53
64
|
varUsedByTraceId: new Map(),
|
|
@@ -71,6 +82,10 @@ function createComponentWithA11yUsages(propPaths: string[]): ComponentInfoWithCs
|
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
|
|
85
|
+
function getDisplayProperty(result: ReturnType<typeof toEditorReactComponent>) {
|
|
86
|
+
return result.editorElement?.elements?.display?.inlineElement?.cssProperties?.display
|
|
87
|
+
}
|
|
88
|
+
|
|
74
89
|
describe('toEditorReactComponent accessibility conversion', () => {
|
|
75
90
|
it('omits the a11y item when the component reads only component-owned fields', () => {
|
|
76
91
|
const component = createComponentWithA11yUsages(['props.a11y.role', 'props.a11y.tabIndex'])
|
|
@@ -115,6 +130,14 @@ function createSemanticElement({
|
|
|
115
130
|
}
|
|
116
131
|
|
|
117
132
|
describe('toEditorReactComponent display conversion', () => {
|
|
133
|
+
it('omits display from the root editor element', () => {
|
|
134
|
+
const rootElement = createElementWithDisplayMatcher('div', matcherDataFromCss('display: flex'))
|
|
135
|
+
|
|
136
|
+
const result = toEditorReactComponent(createComponent(rootElement))
|
|
137
|
+
|
|
138
|
+
expect(result.editorElement?.cssProperties?.display).toBeUndefined()
|
|
139
|
+
})
|
|
140
|
+
|
|
118
141
|
it('resolves local display fallback chains before emitting the manifest display value', () => {
|
|
119
142
|
const rootElement = createElementWithDisplayMatcher(
|
|
120
143
|
'span',
|
|
@@ -123,10 +146,10 @@ describe('toEditorReactComponent display conversion', () => {
|
|
|
123
146
|
),
|
|
124
147
|
)
|
|
125
148
|
|
|
126
|
-
const result = toEditorReactComponent(createComponent(rootElement))
|
|
149
|
+
const result = toEditorReactComponent(createComponent(rootElement, true))
|
|
127
150
|
expect(result.editorElement).toBeDefined()
|
|
128
151
|
|
|
129
|
-
expect(result
|
|
152
|
+
expect(getDisplayProperty(result)).toEqual({
|
|
130
153
|
display: {
|
|
131
154
|
displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, 'inlineFlex'],
|
|
132
155
|
},
|
|
@@ -136,10 +159,10 @@ describe('toEditorReactComponent display conversion', () => {
|
|
|
136
159
|
it('keeps using the tag default when no display declaration exists', () => {
|
|
137
160
|
const rootElement = createElementWithDisplayMatcher('span', matcherDataFromCss(''))
|
|
138
161
|
|
|
139
|
-
const result = toEditorReactComponent(createComponent(rootElement))
|
|
162
|
+
const result = toEditorReactComponent(createComponent(rootElement, true))
|
|
140
163
|
expect(result.editorElement).toBeDefined()
|
|
141
164
|
|
|
142
|
-
expect(result
|
|
165
|
+
expect(getDisplayProperty(result)).toEqual({
|
|
143
166
|
display: {
|
|
144
167
|
displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, 'inline'],
|
|
145
168
|
},
|
|
@@ -166,9 +189,9 @@ describe('toEditorReactComponent display conversion', () => {
|
|
|
166
189
|
it.each(tagDefaults)('maps a <%s> tag default (%s) onto its enum name', (tag, _cssDisplay, expectedEnumValue) => {
|
|
167
190
|
const rootElement = createElementWithDisplayMatcher(tag, matcherDataFromCss(''))
|
|
168
191
|
|
|
169
|
-
const result = toEditorReactComponent(createComponent(rootElement))
|
|
192
|
+
const result = toEditorReactComponent(createComponent(rootElement, true))
|
|
170
193
|
|
|
171
|
-
expect(result
|
|
194
|
+
expect(getDisplayProperty(result)).toEqual({
|
|
172
195
|
display: {
|
|
173
196
|
displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, expectedEnumValue],
|
|
174
197
|
},
|
|
@@ -177,14 +200,14 @@ describe('toEditorReactComponent display conversion', () => {
|
|
|
177
200
|
|
|
178
201
|
it('treats an explicit `display: table-cell` declaration the same as the tag default', () => {
|
|
179
202
|
const fromTag = toEditorReactComponent(
|
|
180
|
-
createComponent(createElementWithDisplayMatcher('td', matcherDataFromCss(''))),
|
|
203
|
+
createComponent(createElementWithDisplayMatcher('td', matcherDataFromCss('')), true),
|
|
181
204
|
)
|
|
182
205
|
const fromCss = toEditorReactComponent(
|
|
183
|
-
createComponent(createElementWithDisplayMatcher('div', matcherDataFromCss('display: table-cell'))),
|
|
206
|
+
createComponent(createElementWithDisplayMatcher('div', matcherDataFromCss('display: table-cell')), true),
|
|
184
207
|
)
|
|
185
208
|
|
|
186
|
-
expect(fromCss
|
|
187
|
-
expect(fromCss
|
|
209
|
+
expect(getDisplayProperty(fromCss)).toEqual(getDisplayProperty(fromTag))
|
|
210
|
+
expect(getDisplayProperty(fromCss)).toEqual({
|
|
188
211
|
display: {
|
|
189
212
|
displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, CSS_PROPERTIES.DISPLAY_VALUE.tableCell],
|
|
190
213
|
},
|
|
@@ -194,9 +217,9 @@ describe('toEditorReactComponent display conversion', () => {
|
|
|
194
217
|
it('still maps kebab-case values that do have an enum representation', () => {
|
|
195
218
|
const rootElement = createElementWithDisplayMatcher('div', matcherDataFromCss('display: inline-block'))
|
|
196
219
|
|
|
197
|
-
const result = toEditorReactComponent(createComponent(rootElement))
|
|
220
|
+
const result = toEditorReactComponent(createComponent(rootElement, true))
|
|
198
221
|
|
|
199
|
-
expect(result
|
|
222
|
+
expect(getDisplayProperty(result)).toEqual({
|
|
200
223
|
display: {
|
|
201
224
|
displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, CSS_PROPERTIES.DISPLAY_VALUE.inlineBlock],
|
|
202
225
|
},
|
|
@@ -208,8 +231,8 @@ describe('toEditorReactComponent display conversion', () => {
|
|
|
208
231
|
|
|
209
232
|
for (const [tag] of tagDefaults) {
|
|
210
233
|
const rootElement = createElementWithDisplayMatcher(tag, matcherDataFromCss(''))
|
|
211
|
-
const result = toEditorReactComponent(createComponent(rootElement))
|
|
212
|
-
const emittedValues = result
|
|
234
|
+
const result = toEditorReactComponent(createComponent(rootElement, true))
|
|
235
|
+
const emittedValues = getDisplayProperty(result)?.display?.displayValues ?? []
|
|
213
236
|
|
|
214
237
|
expect(emittedValues.filter((displayValue) => !allowedValues.has(displayValue))).toEqual([])
|
|
215
238
|
}
|
|
@@ -234,9 +257,9 @@ describe('toEditorReactComponent display conversion', () => {
|
|
|
234
257
|
'div',
|
|
235
258
|
matcherDataFromCss(`display: ${toCssSpelling(enumValue)}`),
|
|
236
259
|
)
|
|
237
|
-
const result = toEditorReactComponent(createComponent(rootElement))
|
|
260
|
+
const result = toEditorReactComponent(createComponent(rootElement, true))
|
|
238
261
|
|
|
239
|
-
return result
|
|
262
|
+
return getDisplayProperty(result)?.display?.displayValues?.[1] !== enumValue
|
|
240
263
|
})
|
|
241
264
|
|
|
242
265
|
expect(unreachable).toEqual([])
|
|
@@ -127,7 +127,7 @@ function buildEditorElement(
|
|
|
127
127
|
component.innerElementProps,
|
|
128
128
|
component.propUsages,
|
|
129
129
|
),
|
|
130
|
-
cssProperties: buildCssProperties(rootElement),
|
|
130
|
+
cssProperties: buildCssProperties(rootElement, true),
|
|
131
131
|
cssCustomProperties: rootCustomProps,
|
|
132
132
|
...(rootStates && { states: rootStates }),
|
|
133
133
|
...(displayGroups && { displayGroups }),
|
|
@@ -224,7 +224,7 @@ function buildElements(
|
|
|
224
224
|
const semanticClass = getSemanticBlockName(element)
|
|
225
225
|
|
|
226
226
|
const data = innerEntry && propUsages ? buildData(innerEntry.props, propUsages) : undefined
|
|
227
|
-
const cssProps = buildCssProperties(element)
|
|
227
|
+
const cssProps = buildCssProperties(element, false)
|
|
228
228
|
const cssCustomProps = nearestCommonAncestorCustomProps.get(element.traceId) ?? {}
|
|
229
229
|
|
|
230
230
|
// Inner-element custom states come from custom-class triggers only; prop
|
|
@@ -563,7 +563,10 @@ function toDisplayEnumValue(cssValue: string): DisplayEnumValue {
|
|
|
563
563
|
return (CSS_DISPLAY_TO_ENUM[cssValue] ?? cssValue) as DisplayEnumValue
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
-
function buildCssProperties(
|
|
566
|
+
function buildCssProperties(
|
|
567
|
+
element: ExtractedElement | undefined,
|
|
568
|
+
isRootElement: boolean,
|
|
569
|
+
): Record<string, CssPropertyItem> {
|
|
567
570
|
const result: Record<string, CssPropertyItem> = {}
|
|
568
571
|
|
|
569
572
|
const cssData = element?.extractorData.get('css-properties') as CssPropertiesData | undefined
|
|
@@ -574,7 +577,9 @@ function buildCssProperties(element: ExtractedElement | undefined): Record<strin
|
|
|
574
577
|
const cssPropertyValues = element ? getMatchedPropertyValues(element) : new Map<string, string>()
|
|
575
578
|
for (const propName of decidedProperties) {
|
|
576
579
|
if (propName === CSS_PROPERTY_TYPE.display && element) {
|
|
577
|
-
|
|
580
|
+
if (!isRootElement) {
|
|
581
|
+
result[propName] = buildDisplayProperty(element)
|
|
582
|
+
}
|
|
578
583
|
continue
|
|
579
584
|
}
|
|
580
585
|
const resolvedValue = resolveLonghand(propName, cssPropertyValues)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { CSS_PROPERTIES } from '@wix/react-component-schema'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import type { CssPropertiesData } from '../css-properties'
|
|
4
|
+
import { getCssPropertiesForTag } from '../css-properties'
|
|
5
|
+
import { ExtractorStore } from './store'
|
|
6
|
+
import { type ExtractedElement, buildElementTree } from './tree-builder'
|
|
7
|
+
|
|
8
|
+
const { CSS_PROPERTY_TYPE } = CSS_PROPERTIES
|
|
9
|
+
|
|
10
|
+
function createStoreForTags(tagsByTraceId: Record<string, string>, emptySlotTraceIds: string[] = []): ExtractorStore {
|
|
11
|
+
const store = new ExtractorStore()
|
|
12
|
+
for (const [traceId, tag] of Object.entries(tagsByTraceId)) {
|
|
13
|
+
store.set(traceId, 'css-properties', { relevant: getCssPropertiesForTag(tag) })
|
|
14
|
+
store.set(traceId, 'prop-tracker', {
|
|
15
|
+
tag,
|
|
16
|
+
boundProps: [],
|
|
17
|
+
concatenatedAttrs: new Map<string, string>(),
|
|
18
|
+
eventHandlers: [],
|
|
19
|
+
hasEmptySlot: emptySlotTraceIds.includes(traceId),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
return store
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function findElementByName(elements: ExtractedElement[], name: string): ExtractedElement | undefined {
|
|
26
|
+
for (const element of elements) {
|
|
27
|
+
if (element.name === name) return element
|
|
28
|
+
const match = findElementByName(element.children, name)
|
|
29
|
+
if (match) return match
|
|
30
|
+
}
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function relevantPropertiesOf(element: ExtractedElement): string[] {
|
|
35
|
+
const cssData = element.extractorData.get('css-properties') as CssPropertiesData | undefined
|
|
36
|
+
return cssData?.relevant ?? []
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('buildElementTree - button typography', () => {
|
|
40
|
+
const TEXT_PROPERTIES = [
|
|
41
|
+
CSS_PROPERTY_TYPE.font,
|
|
42
|
+
CSS_PROPERTY_TYPE.lineHeight,
|
|
43
|
+
CSS_PROPERTY_TYPE.letterSpacing,
|
|
44
|
+
CSS_PROPERTY_TYPE.textDecorationLine,
|
|
45
|
+
CSS_PROPERTY_TYPE.textTransform,
|
|
46
|
+
CSS_PROPERTY_TYPE.textAlign,
|
|
47
|
+
CSS_PROPERTY_TYPE.textShadow,
|
|
48
|
+
CSS_PROPERTY_TYPE.color,
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
it('gives a button the full text set when its label sits in a dropped wrapper', () => {
|
|
52
|
+
const html = `
|
|
53
|
+
<div data-trace-id="t1" class="label-button">
|
|
54
|
+
<button data-trace-id="t2" class="label-button__trigger">
|
|
55
|
+
<span data-trace-id="t3" class="_label_1x9fa">Play</span>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
`
|
|
59
|
+
const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span' })
|
|
60
|
+
|
|
61
|
+
const tree = buildElementTree(html, store)
|
|
62
|
+
const trigger = findElementByName(tree, 'labelButtonTrigger')
|
|
63
|
+
|
|
64
|
+
expect(trigger).toBeDefined()
|
|
65
|
+
expect(relevantPropertiesOf(trigger!)).toEqual(expect.arrayContaining(TEXT_PROPERTIES))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('keeps a text-free button to color alone, with no typography', () => {
|
|
69
|
+
const html = `
|
|
70
|
+
<div data-trace-id="t1" class="icon-button">
|
|
71
|
+
<button data-trace-id="t2" class="icon-button__trigger" aria-label="Play">
|
|
72
|
+
<svg data-trace-id="t3" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
`
|
|
76
|
+
const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' })
|
|
77
|
+
|
|
78
|
+
const tree = buildElementTree(html, store)
|
|
79
|
+
const trigger = findElementByName(tree, 'iconButtonTrigger')
|
|
80
|
+
|
|
81
|
+
expect(trigger).toBeDefined()
|
|
82
|
+
expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.paddingTop)
|
|
83
|
+
expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.color)
|
|
84
|
+
for (const textProperty of TEXT_PROPERTIES.filter((property) => property !== CSS_PROPERTY_TYPE.color)) {
|
|
85
|
+
expect(relevantPropertiesOf(trigger!)).not.toContain(textProperty)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('keeps typography on a button whose content prop rendered nothing', () => {
|
|
90
|
+
const html = `
|
|
91
|
+
<div data-trace-id="t1" class="cta-button">
|
|
92
|
+
<button data-trace-id="t2" class="cta-button__trigger"></button>
|
|
93
|
+
</div>
|
|
94
|
+
`
|
|
95
|
+
const store = createStoreForTags({ t1: 'div', t2: 'button' }, ['t2'])
|
|
96
|
+
|
|
97
|
+
const tree = buildElementTree(html, store)
|
|
98
|
+
const trigger = findElementByName(tree, 'ctaButtonTrigger')
|
|
99
|
+
|
|
100
|
+
// Node props mock to null, so an empty button may still render text at runtime
|
|
101
|
+
expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('keeps typography on a button whose slotted label sits in a wrapper', () => {
|
|
105
|
+
const html = `
|
|
106
|
+
<div data-trace-id="t1" class="cta-button">
|
|
107
|
+
<button data-trace-id="t2" class="cta-button__trigger">
|
|
108
|
+
<span data-trace-id="t3" class="_label_1x9fa"></span>
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
`
|
|
112
|
+
const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span' }, ['t3'])
|
|
113
|
+
|
|
114
|
+
const tree = buildElementTree(html, store)
|
|
115
|
+
const trigger = findElementByName(tree, 'ctaButtonTrigger')
|
|
116
|
+
|
|
117
|
+
// The wrapper is dropped and its slotted label mocked to null, so the button
|
|
118
|
+
// is the only element left that can carry the typography
|
|
119
|
+
expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('keeps typography on a button holding an icon beside a slotted label', () => {
|
|
123
|
+
const html = `
|
|
124
|
+
<div data-trace-id="t1" class="cta-button">
|
|
125
|
+
<button data-trace-id="t2" class="cta-button__trigger">
|
|
126
|
+
<svg data-trace-id="t3" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
`
|
|
130
|
+
const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' }, ['t2'])
|
|
131
|
+
|
|
132
|
+
const tree = buildElementTree(html, store)
|
|
133
|
+
const trigger = findElementByName(tree, 'ctaButtonTrigger')
|
|
134
|
+
|
|
135
|
+
// The icon is the only child left once the slotted label mocks to null
|
|
136
|
+
expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('keeps typography off a button that rendered nothing at all', () => {
|
|
140
|
+
const html = `
|
|
141
|
+
<div data-trace-id="t1" class="spacer-button">
|
|
142
|
+
<button data-trace-id="t2" class="spacer-button__trigger"></button>
|
|
143
|
+
</div>
|
|
144
|
+
`
|
|
145
|
+
const store = createStoreForTags({ t1: 'div', t2: 'button' })
|
|
146
|
+
|
|
147
|
+
const tree = buildElementTree(html, store)
|
|
148
|
+
const trigger = findElementByName(tree, 'spacerButtonTrigger')
|
|
149
|
+
|
|
150
|
+
expect(relevantPropertiesOf(trigger!)).not.toContain(CSS_PROPERTY_TYPE.font)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('finds a slot in a wrapper that also rendered an icon', () => {
|
|
154
|
+
const html = `
|
|
155
|
+
<div data-trace-id="t1" class="cta-button">
|
|
156
|
+
<button data-trace-id="t2" class="cta-button__trigger">
|
|
157
|
+
<span data-trace-id="t3" class="_content_1x9fa">
|
|
158
|
+
<svg data-trace-id="t4" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
|
|
159
|
+
</span>
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
`
|
|
163
|
+
const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span', t4: 'svg' }, ['t3'])
|
|
164
|
+
|
|
165
|
+
const tree = buildElementTree(html, store)
|
|
166
|
+
const trigger = findElementByName(tree, 'ctaButtonTrigger')
|
|
167
|
+
|
|
168
|
+
expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('ignores text that only SVG metadata renders', () => {
|
|
172
|
+
const html = `
|
|
173
|
+
<div data-trace-id="t1" class="icon-button">
|
|
174
|
+
<button data-trace-id="t2" class="icon-button__trigger">
|
|
175
|
+
<svg data-trace-id="t3" viewBox="0 0 24 24"><title>Play</title><path d="M7 4v16l12-8z"></path></svg>
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
`
|
|
179
|
+
const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' })
|
|
180
|
+
|
|
181
|
+
const tree = buildElementTree(html, store)
|
|
182
|
+
const trigger = findElementByName(tree, 'iconButtonTrigger')
|
|
183
|
+
|
|
184
|
+
expect(relevantPropertiesOf(trigger!)).not.toContain(CSS_PROPERTY_TYPE.font)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -11,7 +11,7 @@ import { TRACE_ATTR } from '../../../../component-renderer'
|
|
|
11
11
|
import { findPreferredSemanticClass, normalizeClassNames } from '../../../../utils/css-class'
|
|
12
12
|
import { PRESETS_WRAPPER_CLASS_NAME } from '../../utils/mock-generator'
|
|
13
13
|
import type { CssPropertiesData } from '../css-properties'
|
|
14
|
-
import { addTextProperties } from '../css-properties'
|
|
14
|
+
import { addTextProperties, rendersTextThroughContent } from '../css-properties'
|
|
15
15
|
import type { PropTrackerData } from '../prop-tracker'
|
|
16
16
|
import type { ExtractorStore } from './store'
|
|
17
17
|
|
|
@@ -111,11 +111,43 @@ const getAttributes = (element: Element): Record<string, string> => {
|
|
|
111
111
|
return attrs
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
const hasVisibleText = (node: Node): boolean => isTextNode(node) && node.value.trim().length > 0
|
|
115
|
+
|
|
114
116
|
/**
|
|
115
117
|
* Checks if an element has direct text content (non-whitespace text nodes as children)
|
|
116
118
|
*/
|
|
117
|
-
const hasDirectTextContent = (element: Element): boolean =>
|
|
118
|
-
|
|
119
|
+
const hasDirectTextContent = (element: Element): boolean => element.childNodes.some(hasVisibleText)
|
|
120
|
+
|
|
121
|
+
/** Tags whose text is metadata or source, never styleable text in the layout. */
|
|
122
|
+
const NON_RENDERED_TEXT_TAGS = new Set(['title', 'desc', 'metadata', 'script', 'style', 'template'])
|
|
123
|
+
|
|
124
|
+
/** An element whose own content can reach the page. */
|
|
125
|
+
const isRenderedElement = (node: Node): node is Element =>
|
|
126
|
+
isElement(node) && !NON_RENDERED_TEXT_TAGS.has(node.tagName.toLowerCase())
|
|
127
|
+
|
|
128
|
+
/** Checks if an element renders any text anywhere beneath it. */
|
|
129
|
+
const hasDescendantTextContent = (element: Element): boolean =>
|
|
130
|
+
element.childNodes.some(
|
|
131
|
+
(child) => hasVisibleText(child) || (isRenderedElement(child) && hasDescendantTextContent(child)),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/** Whether this element's own child slot rendered nothing. */
|
|
135
|
+
const hasEmptySlot = (element: Element, store: ExtractorStore): boolean => {
|
|
136
|
+
const traceId = getAttribute(element, TRACE_ATTR)
|
|
137
|
+
return Boolean(traceId && store.get<PropTrackerData>(traceId, 'prop-tracker')?.hasEmptySlot)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether content is missing from this element's own slot, or from one below it. */
|
|
141
|
+
const hasMissingContent = (element: Element, store: ExtractorStore): boolean =>
|
|
142
|
+
hasEmptySlot(element, store) ||
|
|
143
|
+
element.childNodes.some((child) => isElement(child) && hasMissingContent(child, store))
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Returns true if an element renders no text. Content the probe could not produce
|
|
147
|
+
* leaves this unknown, so an element missing content returns false.
|
|
148
|
+
*/
|
|
149
|
+
const rendersNoText = (element: Element, store: ExtractorStore): boolean =>
|
|
150
|
+
!hasDescendantTextContent(element) && !hasMissingContent(element, store)
|
|
119
151
|
|
|
120
152
|
/**
|
|
121
153
|
* Normalizes a tag name to its semantic camelCase form
|
|
@@ -275,8 +307,12 @@ export function buildElementTree(html: string, store: ExtractorStore): Extracted
|
|
|
275
307
|
// Check for text content
|
|
276
308
|
const hasText = hasDirectTextContent(node)
|
|
277
309
|
|
|
278
|
-
//
|
|
279
|
-
|
|
310
|
+
// Wrappers without a semantic class never reach the tree, so an element whose
|
|
311
|
+
// text comes from its content must own the typography itself.
|
|
312
|
+
const rendersText = hasText || (rendersTextThroughContent(node.tagName) && !rendersNoText(node, store))
|
|
313
|
+
|
|
314
|
+
// If element renders text and has css-properties data, add text properties
|
|
315
|
+
if (rendersText) {
|
|
280
316
|
const cssData = extractorData.get('css-properties') as CssPropertiesData | undefined
|
|
281
317
|
if (cssData) {
|
|
282
318
|
const enhanced: CssPropertiesData = {
|
|
@@ -24,6 +24,8 @@ export interface CreateElementEvent {
|
|
|
24
24
|
props: Record<string, unknown>
|
|
25
25
|
traceId?: string
|
|
26
26
|
children: unknown[]
|
|
27
|
+
/** True when a child slot rendered nothing, e.g. a `children` prop mocked to null. */
|
|
28
|
+
hasEmptySlot?: boolean
|
|
27
29
|
store: ExtractorStore
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -173,8 +173,8 @@ describe('getCssPropertiesForTag', () => {
|
|
|
173
173
|
expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.font)
|
|
174
174
|
})
|
|
175
175
|
|
|
176
|
-
it('gives
|
|
177
|
-
for (const tag of ['
|
|
176
|
+
it('gives controls that render their own text both box and text properties', () => {
|
|
177
|
+
for (const tag of ['select', 'textarea']) {
|
|
178
178
|
const relevantProperties = getCssPropertiesForTag(tag)
|
|
179
179
|
|
|
180
180
|
expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.paddingTop)
|
|
@@ -183,6 +183,16 @@ describe('getCssPropertiesForTag', () => {
|
|
|
183
183
|
}
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
+
it('treats a button as box-generating but not text-bearing, except for color', () => {
|
|
187
|
+
const relevantProperties = getCssPropertiesForTag('button')
|
|
188
|
+
|
|
189
|
+
expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.paddingTop)
|
|
190
|
+
// `color` resolves `currentColor` in borders, outlines and fills, text or not
|
|
191
|
+
expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.color)
|
|
192
|
+
expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.font)
|
|
193
|
+
expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.textAlign)
|
|
194
|
+
})
|
|
195
|
+
|
|
186
196
|
it('treats an input with no type as text-bearing', () => {
|
|
187
197
|
const relevantProperties = getCssPropertiesForTag('input')
|
|
188
198
|
|
|
@@ -30,7 +30,8 @@ export interface SemanticAttributes {
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Text & Typography - elements where readability and hierarchy are primary.
|
|
33
|
-
*
|
|
33
|
+
* `select`, `textarea`, and `input` render their own text; `input` varies by type
|
|
34
|
+
* (see below). `button` is absent on purpose - see CONTENT_DEPENDENT_TEXT_TAGS.
|
|
34
35
|
*/
|
|
35
36
|
const TEXT_TAGS = new Set([
|
|
36
37
|
'h1',
|
|
@@ -48,11 +49,21 @@ const TEXT_TAGS = new Set([
|
|
|
48
49
|
'em',
|
|
49
50
|
'b',
|
|
50
51
|
'i',
|
|
51
|
-
'button',
|
|
52
52
|
'select',
|
|
53
53
|
'textarea',
|
|
54
54
|
])
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Tags whose text comes from their content rather than their own rendering, so
|
|
58
|
+
* tree building decides once the rendered subtree is known.
|
|
59
|
+
*/
|
|
60
|
+
const CONTENT_DEPENDENT_TEXT_TAGS = new Set(['button'])
|
|
61
|
+
|
|
62
|
+
/** Whether the tag's content, rather than the tag itself, decides typography. */
|
|
63
|
+
export function rendersTextThroughContent(tag: string): boolean {
|
|
64
|
+
return CONTENT_DEPENDENT_TEXT_TAGS.has(tag.toLowerCase())
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
/** Anything else - including a missing or invalid `type` - defaults to text. */
|
|
57
68
|
const NON_TEXT_INPUT_TYPES = new Set(['checkbox', 'radio', 'range', 'color', 'file', 'image', 'hidden'])
|
|
58
69
|
|
|
@@ -157,6 +168,10 @@ export function getCssPropertiesForTag(tag: string, attributes: SemanticAttribut
|
|
|
157
168
|
|
|
158
169
|
if (hasTextProperties) {
|
|
159
170
|
properties.push(...TEXT_CSS_PROPERTIES)
|
|
171
|
+
} else if (rendersTextThroughContent(tag)) {
|
|
172
|
+
// `color` resolves `currentColor` in borders, outlines and fills even with no
|
|
173
|
+
// text; the rest of the typography waits until tree building sees the content.
|
|
174
|
+
properties.push(CSS_PROPERTY_TYPE.color)
|
|
160
175
|
}
|
|
161
176
|
if (hasMediaProperties) {
|
|
162
177
|
properties.push(...MEDIA_CSS_PROPERTIES)
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { createRefElementMarker } from '../../../extensions/ref-elements/path-utils'
|
|
3
3
|
import type { RefElementMatch } from '../../../extensions/ref-elements/types'
|
|
4
4
|
import { ExtractorStore } from './core/store'
|
|
5
|
-
import { createPropTrackerExtractor } from './prop-tracker'
|
|
5
|
+
import { type PropTrackerData, createPropTrackerExtractor } from './prop-tracker'
|
|
6
6
|
|
|
7
7
|
function markRefElementComponent(runtimeComponent: (...args: unknown[]) => unknown, refComponentType: string): void {
|
|
8
8
|
Object.defineProperty(
|
|
@@ -248,4 +248,22 @@ describe('createPropTrackerExtractor', () => {
|
|
|
248
248
|
selector: '.card-cta',
|
|
249
249
|
})
|
|
250
250
|
})
|
|
251
|
+
|
|
252
|
+
it('persists the empty-slot signal onto the element data', () => {
|
|
253
|
+
const store = new ExtractorStore()
|
|
254
|
+
const { extractor } = createPropTrackerExtractor()
|
|
255
|
+
|
|
256
|
+
extractor.onCreateElement?.({
|
|
257
|
+
type: 'button',
|
|
258
|
+
isDomElement: true,
|
|
259
|
+
tag: 'button',
|
|
260
|
+
traceId: 'trace-1',
|
|
261
|
+
props: { className: 'trigger' },
|
|
262
|
+
children: [],
|
|
263
|
+
hasEmptySlot: true,
|
|
264
|
+
store,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
expect(store.get<PropTrackerData>('trace-1', 'prop-tracker')?.hasEmptySlot).toBe(true)
|
|
268
|
+
})
|
|
251
269
|
})
|
|
@@ -26,6 +26,8 @@ export interface PropTrackerData {
|
|
|
26
26
|
concatenatedAttrs: Map<string, string> // attrName → propName (for concatenated values)
|
|
27
27
|
/** on*-handler prop names whose value is a function on this element (e.g. ['onClick', 'onChange']). */
|
|
28
28
|
eventHandlers: string[]
|
|
29
|
+
/** True when a child slot rendered nothing, so the content is unknown. */
|
|
30
|
+
hasEmptySlot: boolean
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface PropTrackerExtractorState {
|
|
@@ -170,6 +172,7 @@ export function createPropTrackerExtractor(options?: CreatePropTrackerExtractorO
|
|
|
170
172
|
boundProps: [...boundProps],
|
|
171
173
|
concatenatedAttrs,
|
|
172
174
|
eventHandlers,
|
|
175
|
+
hasEmptySlot: event.hasEmptySlot ?? false,
|
|
173
176
|
} satisfies PropTrackerData)
|
|
174
177
|
|
|
175
178
|
attachMatchedRefElement(traceId, props, store)
|