@pyreon/elements 0.13.1 → 0.15.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/lib/index.d.ts +8 -8
- package/lib/index.js +172 -148
- package/package.json +12 -11
- package/src/Element/component.tsx +59 -16
- package/src/Overlay/component.tsx +6 -1
- package/src/Overlay/context.tsx +5 -1
- package/src/__tests__/Element.test.ts +95 -46
- package/src/__tests__/Overlay.test.ts +8 -1
- package/src/__tests__/Wrapper.test.tsx +44 -0
- package/src/__tests__/elements.browser.test.tsx +30 -4
- package/src/__tests__/equalBeforeAfter.test.ts +4 -4
- package/src/__tests__/integration.test.tsx +36 -5
- package/src/__tests__/internElementBundle.test.ts +102 -0
- package/src/__tests__/native-markers.test.ts +13 -0
- package/src/__tests__/perf-stress.browser.test.tsx +119 -0
- package/src/__tests__/responsiveProps.test.ts +59 -28
- package/src/__tests__/useOverlay.test.ts +7 -1
- package/src/env.d.ts +6 -0
- package/src/helpers/Wrapper/component.tsx +35 -30
- package/src/helpers/internElementBundle.ts +37 -0
- package/src/utils.ts +1 -4
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
|
@@ -20,59 +20,102 @@ const getContentSlots = (result: VNode): VNode[] => {
|
|
|
20
20
|
) as VNode[]
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Element's simple-Element fast path inlines the Wrapper helper directly into
|
|
25
|
+
* a single Styled invocation — saves a component hop, splitProps call, and
|
|
26
|
+
* mountChild per Element. After the inline, layout props live on
|
|
27
|
+
* `props.$element.{direction,alignX,…}` and the HTML tag moves from `tag` to
|
|
28
|
+
* `as`. Compound (with-beforeContent/afterContent) renders and the rare
|
|
29
|
+
* needsFix tags (button/fieldset/legend) still go through the original
|
|
30
|
+
* `Wrapper` component.
|
|
31
|
+
*
|
|
32
|
+
* `getLayoutProps()` reads from whichever shape the result happens to be in
|
|
33
|
+
* so the test assertions don't need to know which path Element took.
|
|
34
|
+
*/
|
|
35
|
+
const getLayoutProps = (result: VNode): Record<string, unknown> => {
|
|
36
|
+
const p = result.props as Record<string, unknown>
|
|
37
|
+
// Inlined path: layout lives in $element bag, tag in `as`
|
|
38
|
+
if (p.$element && typeof p.$element === 'object') {
|
|
39
|
+
const el = p.$element as Record<string, unknown>
|
|
40
|
+
return {
|
|
41
|
+
tag: p.as,
|
|
42
|
+
direction: el.direction,
|
|
43
|
+
alignX: el.alignX,
|
|
44
|
+
alignY: el.alignY,
|
|
45
|
+
block: el.block,
|
|
46
|
+
equalCols: el.equalCols,
|
|
47
|
+
extendCss: el.extraStyles,
|
|
48
|
+
// isInline is no longer a separate prop in the inlined path —
|
|
49
|
+
// SUB_TAG/childFix decisions happen at compose-time.
|
|
50
|
+
isInline: undefined,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Wrapper-helper path: flat props
|
|
54
|
+
return {
|
|
55
|
+
tag: p.tag,
|
|
56
|
+
direction: p.direction,
|
|
57
|
+
alignX: p.alignX,
|
|
58
|
+
alignY: p.alignY,
|
|
59
|
+
block: p.block,
|
|
60
|
+
equalCols: p.equalCols,
|
|
61
|
+
extendCss: p.extendCss,
|
|
62
|
+
isInline: p.isInline,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
describe('Element', () => {
|
|
24
67
|
describe('basic rendering', () => {
|
|
25
68
|
it('returns a VNode whose type is the Wrapper component (a function)', () => {
|
|
26
69
|
const result = asVNode(Element({ children: 'hello' }))
|
|
27
70
|
expect(typeof result.type).toBe('function')
|
|
28
|
-
expect(result.type).toBe(
|
|
71
|
+
expect(typeof result.type).toBe("function")
|
|
29
72
|
})
|
|
30
73
|
|
|
31
74
|
it('passes tag as the tag prop to Wrapper', () => {
|
|
32
75
|
const result = asVNode(Element({ tag: 'section', children: 'content' }))
|
|
33
|
-
expect(result.
|
|
76
|
+
expect(getLayoutProps(result).tag).toBe('section')
|
|
34
77
|
})
|
|
35
78
|
|
|
36
79
|
it('defaults tag to undefined when not specified', () => {
|
|
37
80
|
const result = asVNode(Element({ children: 'hello' }))
|
|
38
|
-
expect(result.
|
|
81
|
+
expect(getLayoutProps(result).tag).toBeUndefined()
|
|
39
82
|
})
|
|
40
83
|
|
|
41
84
|
it('renders with no children', () => {
|
|
42
85
|
const result = asVNode(Element({}))
|
|
43
|
-
expect(result.type).toBe(
|
|
86
|
+
expect(typeof result.type).toBe("function")
|
|
44
87
|
})
|
|
45
88
|
})
|
|
46
89
|
|
|
47
90
|
describe('simple element (no beforeContent/afterContent)', () => {
|
|
48
91
|
it('uses contentDirection as wrapper direction (defaults to rows)', () => {
|
|
49
92
|
const result = asVNode(Element({ children: 'test' }))
|
|
50
|
-
expect(result.
|
|
93
|
+
expect(getLayoutProps(result).direction).toBe('rows')
|
|
51
94
|
})
|
|
52
95
|
|
|
53
96
|
it('uses contentAlignX as wrapper alignX (defaults to left)', () => {
|
|
54
97
|
const result = asVNode(Element({ children: 'test' }))
|
|
55
|
-
expect(result.
|
|
98
|
+
expect(getLayoutProps(result).alignX).toBe('left')
|
|
56
99
|
})
|
|
57
100
|
|
|
58
101
|
it('uses contentAlignY as wrapper alignY (defaults to center)', () => {
|
|
59
102
|
const result = asVNode(Element({ children: 'test' }))
|
|
60
|
-
expect(result.
|
|
103
|
+
expect(getLayoutProps(result).alignY).toBe('center')
|
|
61
104
|
})
|
|
62
105
|
|
|
63
106
|
it('overrides direction with contentDirection when simple', () => {
|
|
64
107
|
const result = asVNode(Element({ contentDirection: 'inline', children: 'test' }))
|
|
65
|
-
expect(result.
|
|
108
|
+
expect(getLayoutProps(result).direction).toBe('inline')
|
|
66
109
|
})
|
|
67
110
|
|
|
68
111
|
it('overrides alignX with contentAlignX when simple', () => {
|
|
69
112
|
const result = asVNode(Element({ contentAlignX: 'center', children: 'test' }))
|
|
70
|
-
expect(result.
|
|
113
|
+
expect(getLayoutProps(result).alignX).toBe('center')
|
|
71
114
|
})
|
|
72
115
|
|
|
73
116
|
it('overrides alignY with contentAlignY when simple', () => {
|
|
74
117
|
const result = asVNode(Element({ contentAlignY: 'top', children: 'test' }))
|
|
75
|
-
expect(result.
|
|
118
|
+
expect(getLayoutProps(result).alignY).toBe('top')
|
|
76
119
|
})
|
|
77
120
|
|
|
78
121
|
it('renders children directly via render() without Content wrappers', () => {
|
|
@@ -81,17 +124,17 @@ describe('Element', () => {
|
|
|
81
124
|
expect(slots).toHaveLength(0)
|
|
82
125
|
})
|
|
83
126
|
|
|
84
|
-
it('renders string children
|
|
127
|
+
it('renders string children directly as props.children', () => {
|
|
85
128
|
const result = asVNode(Element({ children: 'hello' }))
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
expect(
|
|
129
|
+
// Simple element fast path — passes children as a single value, not a
|
|
130
|
+
// 3-slot array wrapping falsy beforeContent/afterContent. This avoids
|
|
131
|
+
// 2 extra mountChild calls per Element in the common case.
|
|
132
|
+
expect(result.props.children).toBe('hello')
|
|
90
133
|
})
|
|
91
134
|
|
|
92
135
|
it('passes block prop to Wrapper', () => {
|
|
93
136
|
const result = asVNode(Element({ block: true, children: 'test' }))
|
|
94
|
-
expect(result.
|
|
137
|
+
expect(getLayoutProps(result).block).toBe(true)
|
|
95
138
|
})
|
|
96
139
|
})
|
|
97
140
|
|
|
@@ -104,7 +147,7 @@ describe('Element', () => {
|
|
|
104
147
|
afterContent: h('span', null, 'A'),
|
|
105
148
|
}),
|
|
106
149
|
)
|
|
107
|
-
expect(result.
|
|
150
|
+
expect(getLayoutProps(result).direction).toBe('inline')
|
|
108
151
|
})
|
|
109
152
|
|
|
110
153
|
it('uses explicit direction when provided', () => {
|
|
@@ -116,7 +159,7 @@ describe('Element', () => {
|
|
|
116
159
|
afterContent: h('span', null, 'A'),
|
|
117
160
|
}),
|
|
118
161
|
)
|
|
119
|
-
expect(result.
|
|
162
|
+
expect(getLayoutProps(result).direction).toBe('rows')
|
|
120
163
|
})
|
|
121
164
|
|
|
122
165
|
it('uses default alignX (left) and alignY (center)', () => {
|
|
@@ -127,8 +170,8 @@ describe('Element', () => {
|
|
|
127
170
|
afterContent: 'A',
|
|
128
171
|
}),
|
|
129
172
|
)
|
|
130
|
-
expect(result.
|
|
131
|
-
expect(result.
|
|
173
|
+
expect(getLayoutProps(result).alignX).toBe('left')
|
|
174
|
+
expect(getLayoutProps(result).alignY).toBe('center')
|
|
132
175
|
})
|
|
133
176
|
|
|
134
177
|
it('uses explicit alignX and alignY', () => {
|
|
@@ -141,8 +184,8 @@ describe('Element', () => {
|
|
|
141
184
|
afterContent: 'A',
|
|
142
185
|
}),
|
|
143
186
|
)
|
|
144
|
-
expect(result.
|
|
145
|
-
expect(result.
|
|
187
|
+
expect(getLayoutProps(result).alignX).toBe('center')
|
|
188
|
+
expect(getLayoutProps(result).alignY).toBe('top')
|
|
146
189
|
})
|
|
147
190
|
|
|
148
191
|
it('renders three Content children when both before and after exist', () => {
|
|
@@ -474,8 +517,8 @@ describe('Element', () => {
|
|
|
474
517
|
it('renders img with no children', () => {
|
|
475
518
|
// @ts-expect-error — testing element-specific attr forwarding
|
|
476
519
|
const result = asVNode(Element({ tag: 'img', src: '/pic.png' }))
|
|
477
|
-
expect(result.type).toBe(
|
|
478
|
-
expect(result.
|
|
520
|
+
expect(typeof result.type).toBe("function")
|
|
521
|
+
expect(getLayoutProps(result).tag).toBe('img')
|
|
479
522
|
expect(result.props.src).toBe('/pic.png')
|
|
480
523
|
expect(result.props.children).toBeUndefined()
|
|
481
524
|
})
|
|
@@ -483,52 +526,58 @@ describe('Element', () => {
|
|
|
483
526
|
it('renders input with no children', () => {
|
|
484
527
|
// @ts-expect-error — testing element-specific attr forwarding
|
|
485
528
|
const result = asVNode(Element({ tag: 'input', type: 'text' }))
|
|
486
|
-
expect(result.type).toBe(
|
|
487
|
-
expect(result.
|
|
529
|
+
expect(typeof result.type).toBe("function")
|
|
530
|
+
expect(getLayoutProps(result).tag).toBe('input')
|
|
488
531
|
expect(result.props.type).toBe('text')
|
|
489
532
|
expect(result.props.children).toBeUndefined()
|
|
490
533
|
})
|
|
491
534
|
|
|
492
535
|
it('renders with dangerouslySetInnerHTML (treated as empty)', () => {
|
|
493
536
|
const result = asVNode(Element({ dangerouslySetInnerHTML: { __html: '<b>hi</b>' } }))
|
|
494
|
-
expect(result.type).toBe(
|
|
537
|
+
expect(typeof result.type).toBe("function")
|
|
495
538
|
expect(result.props.dangerouslySetInnerHTML).toEqual({ __html: '<b>hi</b>' })
|
|
496
539
|
expect(result.props.children).toBeUndefined()
|
|
497
540
|
})
|
|
498
541
|
|
|
499
542
|
it('renders br with no children', () => {
|
|
500
543
|
const result = asVNode(Element({ tag: 'br' }))
|
|
501
|
-
expect(result.type).toBe(
|
|
544
|
+
expect(typeof result.type).toBe("function")
|
|
502
545
|
expect(result.props.children).toBeUndefined()
|
|
503
546
|
})
|
|
504
547
|
|
|
505
548
|
it('renders hr with no children', () => {
|
|
506
549
|
const result = asVNode(Element({ tag: 'hr' }))
|
|
507
|
-
expect(result.type).toBe(
|
|
550
|
+
expect(typeof result.type).toBe("function")
|
|
508
551
|
expect(result.props.children).toBeUndefined()
|
|
509
552
|
})
|
|
510
553
|
})
|
|
511
554
|
|
|
512
|
-
describe('
|
|
513
|
-
|
|
555
|
+
describe('inline-vs-block tag rendering', () => {
|
|
556
|
+
// The simple-element fast path inlines Wrapper into Styled and forwards
|
|
557
|
+
// `tag` as the `as` prop. The pre-fast-path `isInline` flag was an
|
|
558
|
+
// internal Wrapper plumbing detail that determined the inner SUB_TAG
|
|
559
|
+
// for the rare needsFix (button/fieldset/legend) path — invisible on
|
|
560
|
+
// span/a/section, which never need that fix. After the fast path the
|
|
561
|
+
// flag is gone in the simple path; the rendered tag is the contract.
|
|
562
|
+
it('renders inline tags like span as <span>', () => {
|
|
514
563
|
const result = asVNode(Element({ tag: 'span', children: 'text' }))
|
|
515
|
-
expect(result.
|
|
564
|
+
expect(getLayoutProps(result).tag).toBe('span')
|
|
516
565
|
})
|
|
517
566
|
|
|
518
|
-
it('
|
|
567
|
+
it('renders anchor tag as <a>', () => {
|
|
519
568
|
// @ts-expect-error — testing element-specific attr forwarding
|
|
520
569
|
const result = asVNode(Element({ tag: 'a', href: '#', children: 'link' }))
|
|
521
|
-
expect(result.
|
|
570
|
+
expect(getLayoutProps(result).tag).toBe('a')
|
|
522
571
|
})
|
|
523
572
|
|
|
524
|
-
it('
|
|
573
|
+
it('renders block tags like section as <section>', () => {
|
|
525
574
|
const result = asVNode(Element({ tag: 'section', children: 'text' }))
|
|
526
|
-
expect(result.
|
|
575
|
+
expect(getLayoutProps(result).tag).toBe('section')
|
|
527
576
|
})
|
|
528
577
|
|
|
529
|
-
it('
|
|
578
|
+
it('leaves tag undefined when not specified (default div)', () => {
|
|
530
579
|
const result = asVNode(Element({ children: 'text' }))
|
|
531
|
-
expect(result.
|
|
580
|
+
expect(getLayoutProps(result).tag).toBeUndefined()
|
|
532
581
|
})
|
|
533
582
|
})
|
|
534
583
|
|
|
@@ -536,21 +585,21 @@ describe('Element', () => {
|
|
|
536
585
|
it('passes css prop as extendCss to Wrapper', () => {
|
|
537
586
|
const customCss = 'color: red;'
|
|
538
587
|
const result = asVNode(Element({ css: customCss, children: 'test' }))
|
|
539
|
-
expect(result.
|
|
588
|
+
expect(getLayoutProps(result).extendCss).toBe(customCss)
|
|
540
589
|
})
|
|
541
590
|
|
|
542
591
|
it('does not pass extendCss when css not provided', () => {
|
|
543
592
|
const result = asVNode(Element({ children: 'test' }))
|
|
544
|
-
expect(result.
|
|
593
|
+
expect(getLayoutProps(result).extendCss).toBeUndefined()
|
|
545
594
|
})
|
|
546
595
|
})
|
|
547
596
|
|
|
548
597
|
describe('content fallback chain', () => {
|
|
549
598
|
it('prefers children over content', () => {
|
|
550
599
|
const result = asVNode(Element({ children: 'child', content: 'alt' }))
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
expect(
|
|
600
|
+
// Simple-element fast path returns children directly. The fallback
|
|
601
|
+
// chain (children → content → label) is exercised inside getChildren().
|
|
602
|
+
expect(result.props.children).toBe('child')
|
|
554
603
|
})
|
|
555
604
|
|
|
556
605
|
it('falls back to content when no children', () => {
|
|
@@ -576,13 +625,13 @@ describe('Element', () => {
|
|
|
576
625
|
describe('button tag (flex fix needed)', () => {
|
|
577
626
|
it('passes tag as button to Wrapper', () => {
|
|
578
627
|
const result = asVNode(Element({ tag: 'button', children: 'click' }))
|
|
579
|
-
expect(result.type).toBe(
|
|
580
|
-
expect(result.
|
|
628
|
+
expect(typeof result.type).toBe("function")
|
|
629
|
+
expect(getLayoutProps(result).tag).toBe('button')
|
|
581
630
|
})
|
|
582
631
|
|
|
583
632
|
it('passes isInline=true for button (inline element)', () => {
|
|
584
633
|
const result = asVNode(Element({ tag: 'button', children: 'click' }))
|
|
585
|
-
expect(result.
|
|
634
|
+
expect(getLayoutProps(result).isInline).toBe(true)
|
|
586
635
|
})
|
|
587
636
|
})
|
|
588
637
|
|
|
@@ -37,7 +37,14 @@ vi.mock('@pyreon/reactivity', () => {
|
|
|
37
37
|
return s
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// No-op: `@pyreon/core/context.ts` calls `setSnapshotCapture(...)` at
|
|
41
|
+
// module load to install the reactive-effect context-snapshot DI hook.
|
|
42
|
+
// Mocked tests don't drive real reactive scoping, so accept the call
|
|
43
|
+
// and discard. Required since `@pyreon/core` imports `setSnapshotCapture`
|
|
44
|
+
// from `@pyreon/reactivity` — without this stub the mock factory throws
|
|
45
|
+
// "No 'setSnapshotCapture' export is defined on the '@pyreon/reactivity' mock."
|
|
46
|
+
const setSnapshotCapture = () => {}
|
|
47
|
+
return { signal, setSnapshotCapture }
|
|
41
48
|
})
|
|
42
49
|
|
|
43
50
|
// onMount / onUnmount are no-ops outside a renderer
|
|
@@ -149,4 +149,48 @@ describe('Wrapper component', () => {
|
|
|
149
149
|
expect((result.props.$element as any).parentFix).toBeUndefined()
|
|
150
150
|
})
|
|
151
151
|
})
|
|
152
|
+
|
|
153
|
+
// Void HTML elements (hr, input, img, br, …) cannot have children. Element
|
|
154
|
+
// already skips passing children to Wrapper for void tags, but the JSX
|
|
155
|
+
// `{own.children}` slot still serialized `undefined` into the vnode and
|
|
156
|
+
// tripped runtime-dom's void-element warning. Wrapper now drops the slot
|
|
157
|
+
// entirely for void tags.
|
|
158
|
+
describe('void HTML elements drop the children slot', () => {
|
|
159
|
+
const VOID_TAGS = ['hr', 'input', 'img', 'br', 'area', 'base', 'col', 'embed', 'link', 'source', 'track', 'wbr'] as const
|
|
160
|
+
|
|
161
|
+
for (const tag of VOID_TAGS) {
|
|
162
|
+
it(`omits children for <${tag}>`, () => {
|
|
163
|
+
// Cast to any — the Wrapper Props.tag type narrows out void
|
|
164
|
+
// elements, but the runtime guard still has to cover the case
|
|
165
|
+
// where a void tag reaches Wrapper (e.g. via rocketstyle attrs
|
|
166
|
+
// composing `tag: 'hr'` from a callback whose return type is wider).
|
|
167
|
+
const result = asVNode(Wrapper({ tag } as any))
|
|
168
|
+
expect(result.type).toBe(Styled)
|
|
169
|
+
expect(result.props.children).toBeUndefined()
|
|
170
|
+
expect(result.children).toEqual([])
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
it('still renders children for non-void tags', () => {
|
|
175
|
+
const result = asVNode(Wrapper({ tag: 'div', children: 'kept' }))
|
|
176
|
+
expect(result.props.children).toBe('kept')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('omits children even when caller accidentally passes them to a void tag', () => {
|
|
180
|
+
const result = asVNode(Wrapper({ tag: 'hr', children: 'should-not-leak' }))
|
|
181
|
+
expect(result.props.children).toBeUndefined()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('dangerouslySetInnerHTML on a normally-void tag bypasses the void path', () => {
|
|
185
|
+
const result = asVNode(
|
|
186
|
+
Wrapper({
|
|
187
|
+
tag: 'hr',
|
|
188
|
+
dangerouslySetInnerHTML: { __html: '<b>x</b>' },
|
|
189
|
+
} as any),
|
|
190
|
+
)
|
|
191
|
+
// dangerouslySetInnerHTML opts out of the void-element guard
|
|
192
|
+
// because the tag becomes a custom element / shadow host case.
|
|
193
|
+
expect(result.type).toBe(Styled)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
152
196
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @jsxImportSource @pyreon/core */
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import { signal } from '@pyreon/reactivity'
|
|
4
4
|
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
5
5
|
import { Element } from '../Element'
|
|
@@ -52,8 +52,34 @@ describe('@pyreon/elements browser smoke', () => {
|
|
|
52
52
|
expect(document.querySelector('[data-portal-id="p"]')).toBeNull()
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
it('runs in a real browser —
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
it('runs in a real browser — Vitest defines `process.env.NODE_ENV !== "production"`', () => {
|
|
56
|
+
// Sanity check the test env: dev gates use bundler-agnostic
|
|
57
|
+
// `process.env.NODE_ENV !== 'production'`. Every modern bundler
|
|
58
|
+
// (incl. Vitest's Vite) replaces this at build time. In a real-browser
|
|
59
|
+
// test run the literal lands as `"development" !== "production"` →
|
|
60
|
+
// `true`, so dev warnings fire as expected.
|
|
61
|
+
expect(process.env.NODE_ENV).not.toBe('production')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Void HTML elements via Element must not trip runtime-dom's
|
|
65
|
+
// "<X> is a void element and cannot have children" warning.
|
|
66
|
+
// Wrapper used to leak an `undefined` child into the vnode for void tags.
|
|
67
|
+
describe('void HTML element tags', () => {
|
|
68
|
+
const voidTags: Array<'hr' | 'br' | 'input' | 'img'> = ['hr', 'br', 'input', 'img']
|
|
69
|
+
|
|
70
|
+
for (const tag of voidTags) {
|
|
71
|
+
it(`<${tag}> mounts without "void element" console.warn`, () => {
|
|
72
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
73
|
+
const { container, unmount } = mountInBrowser(<Element tag={tag} data-id={tag} />)
|
|
74
|
+
const el = container.querySelector(`[data-id="${tag}"]`)
|
|
75
|
+
expect(el?.tagName.toLowerCase()).toBe(tag)
|
|
76
|
+
const voidWarnings = warnSpy.mock.calls.filter((args) =>
|
|
77
|
+
typeof args[0] === 'string' && args[0].includes('void element'),
|
|
78
|
+
)
|
|
79
|
+
expect(voidWarnings).toEqual([])
|
|
80
|
+
warnSpy.mockRestore()
|
|
81
|
+
unmount()
|
|
82
|
+
})
|
|
83
|
+
}
|
|
58
84
|
})
|
|
59
85
|
})
|
|
@@ -27,13 +27,13 @@ describe('Element equalBeforeAfter', () => {
|
|
|
27
27
|
children: 'Main',
|
|
28
28
|
}),
|
|
29
29
|
)
|
|
30
|
-
expect(result.type).toBe(
|
|
30
|
+
expect(typeof result.type).toBe("function")
|
|
31
31
|
expect(typeof result.props.ref).toBe('function')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
it('does not crash without before/after content', () => {
|
|
35
35
|
const result = asVNode(Element({ equalBeforeAfter: true, children: 'Main only' }))
|
|
36
|
-
expect(result.type).toBe(
|
|
36
|
+
expect(typeof result.type).toBe("function")
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
it('does not crash with only beforeContent', () => {
|
|
@@ -44,7 +44,7 @@ describe('Element equalBeforeAfter', () => {
|
|
|
44
44
|
children: 'Main',
|
|
45
45
|
}),
|
|
46
46
|
)
|
|
47
|
-
expect(result.type).toBe(
|
|
47
|
+
expect(typeof result.type).toBe("function")
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
it('does not crash with only afterContent', () => {
|
|
@@ -55,7 +55,7 @@ describe('Element equalBeforeAfter', () => {
|
|
|
55
55
|
children: 'Main',
|
|
56
56
|
}),
|
|
57
57
|
)
|
|
58
|
-
expect(result.type).toBe(
|
|
58
|
+
expect(typeof result.type).toBe("function")
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('renders three slot children when both before and after exist', () => {
|
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
import type { VNode } from '@pyreon/core'
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
3
|
|
|
4
|
+
// Element's simple-Element fast path inlines the Wrapper helper directly into
|
|
5
|
+
// a Styled invocation, so layout props move from `result.props.{tag, direction, …}`
|
|
6
|
+
// to `result.props.{as, $element.direction, …}`. This helper reads from
|
|
7
|
+
// whichever shape the result is in so assertions don't depend on which path.
|
|
8
|
+
const getLayoutProps = (result: VNode): Record<string, unknown> => {
|
|
9
|
+
const p = result.props as Record<string, unknown>
|
|
10
|
+
if (p.$element && typeof p.$element === 'object') {
|
|
11
|
+
const el = p.$element as Record<string, unknown>
|
|
12
|
+
return {
|
|
13
|
+
tag: p.as,
|
|
14
|
+
direction: el.direction,
|
|
15
|
+
alignX: el.alignX,
|
|
16
|
+
alignY: el.alignY,
|
|
17
|
+
block: el.block,
|
|
18
|
+
equalCols: el.equalCols,
|
|
19
|
+
extendCss: el.extraStyles,
|
|
20
|
+
isInline: undefined,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
tag: p.tag,
|
|
25
|
+
direction: p.direction,
|
|
26
|
+
alignX: p.alignX,
|
|
27
|
+
alignY: p.alignY,
|
|
28
|
+
block: p.block,
|
|
29
|
+
equalCols: p.equalCols,
|
|
30
|
+
extendCss: p.extendCss,
|
|
31
|
+
isInline: p.isInline,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
4
35
|
// ---------------------------------------------------------------------------
|
|
5
36
|
// Mocks — match patterns from existing element tests
|
|
6
37
|
// ---------------------------------------------------------------------------
|
|
@@ -36,7 +67,7 @@ const getContentSlots = (result: VNode): VNode[] => {
|
|
|
36
67
|
describe('Element integration', () => {
|
|
37
68
|
it('renders with content prop producing Wrapper VNode', () => {
|
|
38
69
|
const result = asVNode(Element({ content: 'hello world', children: undefined }))
|
|
39
|
-
expect(result.type).toBe(
|
|
70
|
+
expect(typeof result.type).toBe("function")
|
|
40
71
|
const children = result.props.children
|
|
41
72
|
expect(children).toBeDefined()
|
|
42
73
|
})
|
|
@@ -50,7 +81,7 @@ describe('Element integration', () => {
|
|
|
50
81
|
children: undefined,
|
|
51
82
|
}),
|
|
52
83
|
)
|
|
53
|
-
expect(result.type).toBe(
|
|
84
|
+
expect(typeof result.type).toBe("function")
|
|
54
85
|
const slots = getContentSlots(result)
|
|
55
86
|
// With before/after content, there should be Content wrapper VNodes
|
|
56
87
|
expect(slots.length).toBeGreaterThanOrEqual(2)
|
|
@@ -67,7 +98,7 @@ describe('Element integration', () => {
|
|
|
67
98
|
|
|
68
99
|
// When beforeContent/afterContent are absent, direction falls through
|
|
69
100
|
// to wrapper level and contentDirection/contentAlignX take effect
|
|
70
|
-
expect(result.type).toBe(
|
|
101
|
+
expect(typeof result.type).toBe("function")
|
|
71
102
|
// The wrapper receives alignment props
|
|
72
103
|
expect(result.props).toBeDefined()
|
|
73
104
|
})
|
|
@@ -81,7 +112,7 @@ describe('Element integration', () => {
|
|
|
81
112
|
}),
|
|
82
113
|
)
|
|
83
114
|
// Simple element (no before/after) uses contentDirection as wrapper direction
|
|
84
|
-
expect(result.
|
|
85
|
-
expect(result.
|
|
115
|
+
expect(getLayoutProps(result).direction).toBe('inline')
|
|
116
|
+
expect(getLayoutProps(result).alignX).toBe('center')
|
|
86
117
|
})
|
|
87
118
|
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for internElementBundle() — the module-scope LRU cache that
|
|
3
|
+
* gives same-shape Element layouts a stable object identity. Locks in the
|
|
4
|
+
* bail conditions (functions, non-string objects) and the LRU semantics
|
|
5
|
+
* so a future refactor can't silently break the styler classCache hit.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { internElementBundle } from '../helpers/internElementBundle'
|
|
9
|
+
|
|
10
|
+
describe('internElementBundle', () => {
|
|
11
|
+
it('returns the same identity for the same primitive prop tuple', () => {
|
|
12
|
+
const a = internElementBundle({
|
|
13
|
+
block: false,
|
|
14
|
+
direction: 'inline',
|
|
15
|
+
alignX: 'left',
|
|
16
|
+
alignY: 'center',
|
|
17
|
+
equalCols: false,
|
|
18
|
+
extraStyles: undefined,
|
|
19
|
+
})
|
|
20
|
+
const b = internElementBundle({
|
|
21
|
+
block: false,
|
|
22
|
+
direction: 'inline',
|
|
23
|
+
alignX: 'left',
|
|
24
|
+
alignY: 'center',
|
|
25
|
+
equalCols: false,
|
|
26
|
+
extraStyles: undefined,
|
|
27
|
+
})
|
|
28
|
+
expect(a).toBe(b)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns different identity when any primitive value differs', () => {
|
|
32
|
+
const a = internElementBundle({ block: false, direction: 'inline' })
|
|
33
|
+
const b = internElementBundle({ block: true, direction: 'inline' })
|
|
34
|
+
const c = internElementBundle({ block: false, direction: 'rows' })
|
|
35
|
+
expect(a).not.toBe(b)
|
|
36
|
+
expect(a).not.toBe(c)
|
|
37
|
+
expect(b).not.toBe(c)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('handles different bundle shapes independently (parentFix vs childFix)', () => {
|
|
41
|
+
const parent = internElementBundle({
|
|
42
|
+
parentFix: true,
|
|
43
|
+
block: false,
|
|
44
|
+
extraStyles: undefined,
|
|
45
|
+
})
|
|
46
|
+
const child = internElementBundle({
|
|
47
|
+
childFix: true,
|
|
48
|
+
direction: 'inline',
|
|
49
|
+
alignX: 'left',
|
|
50
|
+
alignY: 'center',
|
|
51
|
+
equalCols: false,
|
|
52
|
+
})
|
|
53
|
+
expect(parent).not.toBe(child)
|
|
54
|
+
// Same shape repeats are interned
|
|
55
|
+
const parent2 = internElementBundle({
|
|
56
|
+
parentFix: true,
|
|
57
|
+
block: false,
|
|
58
|
+
extraStyles: undefined,
|
|
59
|
+
})
|
|
60
|
+
expect(parent).toBe(parent2)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('bails (returns the input untouched) when a value is a function', () => {
|
|
64
|
+
const fn = () => 'red'
|
|
65
|
+
const a = internElementBundle({ block: false, extraStyles: fn })
|
|
66
|
+
const b = internElementBundle({ block: false, extraStyles: fn })
|
|
67
|
+
// Both calls bail — neither is cached, so identities differ.
|
|
68
|
+
expect(a).not.toBe(b)
|
|
69
|
+
// The returned object IS the input (untouched).
|
|
70
|
+
expect(a).toEqual({ block: false, extraStyles: fn })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('bails when a value is a non-null object (CSSResult / nested object)', () => {
|
|
74
|
+
const cssResult = { strings: ['color: red'], values: [] }
|
|
75
|
+
const a = internElementBundle({ block: false, extraStyles: cssResult })
|
|
76
|
+
const b = internElementBundle({ block: false, extraStyles: cssResult })
|
|
77
|
+
expect(a).not.toBe(b)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('treats string extraStyles as cacheable (the common pre-resolved CSS case)', () => {
|
|
81
|
+
const a = internElementBundle({ block: false, extraStyles: 'color: red' })
|
|
82
|
+
const b = internElementBundle({ block: false, extraStyles: 'color: red' })
|
|
83
|
+
expect(a).toBe(b)
|
|
84
|
+
const c = internElementBundle({ block: false, extraStyles: 'color: blue' })
|
|
85
|
+
expect(a).not.toBe(c)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('null and undefined are distinct cache keys (JSON serializes them differently)', () => {
|
|
89
|
+
const aNull = internElementBundle({ extraStyles: null })
|
|
90
|
+
const aUndef = internElementBundle({ extraStyles: undefined })
|
|
91
|
+
expect(aNull).not.toBe(aUndef)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('LRU touch on hit moves entry to most-recent position', () => {
|
|
95
|
+
// Hit the same bundle twice — second call should still return the same
|
|
96
|
+
// identity (LRU touch keeps it alive).
|
|
97
|
+
const key = `lru-touch-${Math.random()}`
|
|
98
|
+
const first = internElementBundle({ direction: key })
|
|
99
|
+
const second = internElementBundle({ direction: key })
|
|
100
|
+
expect(first).toBe(second)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { isNativeCompat } from '@pyreon/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import Overlay from '../Overlay/component'
|
|
4
|
+
import OverlayContextProvider from '../Overlay/context'
|
|
5
|
+
|
|
6
|
+
describe('native-compat markers — @pyreon/elements', () => {
|
|
7
|
+
it('Overlay is marked native', () => {
|
|
8
|
+
expect(isNativeCompat(Overlay)).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
it('Overlay context Provider is marked native', () => {
|
|
11
|
+
expect(isNativeCompat(OverlayContextProvider)).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
})
|