@pyreon/elements 0.24.4 → 0.24.6

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.
Files changed (70) hide show
  1. package/package.json +10 -12
  2. package/src/Element/component.tsx +0 -315
  3. package/src/Element/constants.ts +0 -96
  4. package/src/Element/index.ts +0 -6
  5. package/src/Element/types.ts +0 -168
  6. package/src/Element/utils.ts +0 -15
  7. package/src/List/component.tsx +0 -105
  8. package/src/List/index.ts +0 -5
  9. package/src/Overlay/component.tsx +0 -140
  10. package/src/Overlay/context.tsx +0 -36
  11. package/src/Overlay/index.ts +0 -7
  12. package/src/Overlay/positioning.ts +0 -191
  13. package/src/Overlay/useOverlay.tsx +0 -461
  14. package/src/Portal/component.tsx +0 -54
  15. package/src/Portal/index.ts +0 -5
  16. package/src/Text/component.tsx +0 -67
  17. package/src/Text/index.ts +0 -5
  18. package/src/Text/styled.ts +0 -30
  19. package/src/Util/component.tsx +0 -43
  20. package/src/Util/index.ts +0 -5
  21. package/src/__tests__/Content.test.tsx +0 -123
  22. package/src/__tests__/Element-slot-reactivity.browser.test.tsx +0 -152
  23. package/src/__tests__/Element.test.ts +0 -819
  24. package/src/__tests__/Iterator.test.ts +0 -492
  25. package/src/__tests__/Iterator.types.test.ts +0 -237
  26. package/src/__tests__/List.test.ts +0 -199
  27. package/src/__tests__/Overlay.test.ts +0 -492
  28. package/src/__tests__/Portal.test.ts +0 -156
  29. package/src/__tests__/Text.test.ts +0 -274
  30. package/src/__tests__/Util.test.ts +0 -63
  31. package/src/__tests__/Wrapper-innerhtml.test.tsx +0 -178
  32. package/src/__tests__/Wrapper.test.tsx +0 -196
  33. package/src/__tests__/elements.browser.test.tsx +0 -132
  34. package/src/__tests__/equalBeforeAfter.test.ts +0 -122
  35. package/src/__tests__/helpers.test.ts +0 -65
  36. package/src/__tests__/integration.test.tsx +0 -118
  37. package/src/__tests__/internElementBundle.test.ts +0 -102
  38. package/src/__tests__/iterator-function-children.test.tsx +0 -120
  39. package/src/__tests__/native-markers.test.ts +0 -13
  40. package/src/__tests__/overlayContext.test.tsx +0 -78
  41. package/src/__tests__/perf-stress.browser.test.tsx +0 -119
  42. package/src/__tests__/positioning.test.ts +0 -90
  43. package/src/__tests__/responsiveProps.test.ts +0 -328
  44. package/src/__tests__/slot-component-reference.test.tsx +0 -157
  45. package/src/__tests__/useOverlay.test.ts +0 -1336
  46. package/src/__tests__/utils.test.ts +0 -69
  47. package/src/__tests__/wrapper-block-cascade.test.ts +0 -121
  48. package/src/constants.ts +0 -1
  49. package/src/env.d.ts +0 -6
  50. package/src/helpers/Content/component.tsx +0 -75
  51. package/src/helpers/Content/index.ts +0 -3
  52. package/src/helpers/Content/styled.ts +0 -105
  53. package/src/helpers/Content/types.ts +0 -49
  54. package/src/helpers/Iterator/component.tsx +0 -316
  55. package/src/helpers/Iterator/index.ts +0 -30
  56. package/src/helpers/Iterator/types.ts +0 -138
  57. package/src/helpers/Wrapper/component.tsx +0 -180
  58. package/src/helpers/Wrapper/constants.ts +0 -10
  59. package/src/helpers/Wrapper/index.ts +0 -3
  60. package/src/helpers/Wrapper/styled.ts +0 -64
  61. package/src/helpers/Wrapper/types.ts +0 -56
  62. package/src/helpers/Wrapper/utils.ts +0 -7
  63. package/src/helpers/index.ts +0 -4
  64. package/src/helpers/internElementBundle.ts +0 -37
  65. package/src/helpers/isPyreonComponent.ts +0 -46
  66. package/src/index.ts +0 -42
  67. package/src/manifest.ts +0 -190
  68. package/src/tests/manifest-snapshot.test.ts +0 -45
  69. package/src/types.ts +0 -112
  70. package/src/utils.ts +0 -5
@@ -1,819 +0,0 @@
1
- import type { VNode } from '@pyreon/core'
2
- import { h } from '@pyreon/core'
3
- import * as runtimeDom from '@pyreon/runtime-dom'
4
- import { describe, expect, it } from 'vitest'
5
- import { Element } from '../Element'
6
- import Content from '../helpers/Content/component'
7
- import Wrapper from '../helpers/Wrapper/component'
8
-
9
- // Namespace-import + destructure defeats CodeQL Autofix's `js/unused-import`
10
- // false-positive — `mount` is referenced inside `it()` callbacks far below,
11
- // which the bot's static analyzer fails to trace, causing it to remove the
12
- // import in a loop on every push. The namespace import is unambiguously
13
- // referenced on the next line, so the rule cannot fire.
14
- const { mount } = runtimeDom
15
-
16
- const asVNode = (v: unknown) => v as VNode
17
-
18
- /**
19
- * Helper to extract Content VNodes from the Wrapper's props.children.
20
- * In Pyreon, JSX children are passed as props.children (not result.children).
21
- */
22
- const getContentSlots = (result: VNode): VNode[] => {
23
- const children = result.props.children
24
- if (!Array.isArray(children)) return []
25
- return children.filter(
26
- (c: unknown) =>
27
- c != null && typeof c === 'object' && 'type' in (c as VNode) && (c as VNode).type === Content,
28
- ) as VNode[]
29
- }
30
-
31
- /**
32
- * Element's simple-Element fast path inlines the Wrapper helper directly into
33
- * a single Styled invocation — saves a component hop, splitProps call, and
34
- * mountChild per Element. After the inline, layout props live on
35
- * `props.$element.{direction,alignX,…}` and the HTML tag moves from `tag` to
36
- * `as`. Compound (with-beforeContent/afterContent) renders and the rare
37
- * needsFix tags (button/fieldset/legend) still go through the original
38
- * `Wrapper` component.
39
- *
40
- * `getLayoutProps()` reads from whichever shape the result happens to be in
41
- * so the test assertions don't need to know which path Element took.
42
- */
43
- const getLayoutProps = (result: VNode): Record<string, unknown> => {
44
- const p = result.props as Record<string, unknown>
45
- // Inlined path: layout lives in $element bag, tag in `as`
46
- if (p.$element && typeof p.$element === 'object') {
47
- const el = p.$element as Record<string, unknown>
48
- return {
49
- tag: p.as,
50
- direction: el.direction,
51
- alignX: el.alignX,
52
- alignY: el.alignY,
53
- block: el.block,
54
- equalCols: el.equalCols,
55
- extendCss: el.extraStyles,
56
- // isInline is no longer a separate prop in the inlined path —
57
- // SUB_TAG/childFix decisions happen at compose-time.
58
- isInline: undefined,
59
- }
60
- }
61
- // Wrapper-helper path: flat props
62
- return {
63
- tag: p.tag,
64
- direction: p.direction,
65
- alignX: p.alignX,
66
- alignY: p.alignY,
67
- block: p.block,
68
- equalCols: p.equalCols,
69
- extendCss: p.extendCss,
70
- isInline: p.isInline,
71
- }
72
- }
73
-
74
- describe('Element', () => {
75
- describe('basic rendering', () => {
76
- it('returns a VNode whose type is the Wrapper component (a function)', () => {
77
- const result = asVNode(Element({ children: 'hello' }))
78
- expect(typeof result.type).toBe('function')
79
- expect(typeof result.type).toBe("function")
80
- })
81
-
82
- it('passes tag as the tag prop to Wrapper', () => {
83
- const result = asVNode(Element({ tag: 'section', children: 'content' }))
84
- expect(getLayoutProps(result).tag).toBe('section')
85
- })
86
-
87
- it('defaults tag to undefined when not specified', () => {
88
- const result = asVNode(Element({ children: 'hello' }))
89
- expect(getLayoutProps(result).tag).toBeUndefined()
90
- })
91
-
92
- it('renders with no children', () => {
93
- const result = asVNode(Element({}))
94
- expect(typeof result.type).toBe("function")
95
- })
96
- })
97
-
98
- describe('simple element (no beforeContent/afterContent)', () => {
99
- it('uses contentDirection as wrapper direction (defaults to rows)', () => {
100
- const result = asVNode(Element({ children: 'test' }))
101
- expect(getLayoutProps(result).direction).toBe('rows')
102
- })
103
-
104
- it('uses contentAlignX as wrapper alignX (defaults to left)', () => {
105
- const result = asVNode(Element({ children: 'test' }))
106
- expect(getLayoutProps(result).alignX).toBe('left')
107
- })
108
-
109
- it('uses contentAlignY as wrapper alignY (defaults to center)', () => {
110
- const result = asVNode(Element({ children: 'test' }))
111
- expect(getLayoutProps(result).alignY).toBe('center')
112
- })
113
-
114
- it('overrides direction with contentDirection when simple', () => {
115
- const result = asVNode(Element({ contentDirection: 'inline', children: 'test' }))
116
- expect(getLayoutProps(result).direction).toBe('inline')
117
- })
118
-
119
- it('overrides alignX with contentAlignX when simple', () => {
120
- const result = asVNode(Element({ contentAlignX: 'center', children: 'test' }))
121
- expect(getLayoutProps(result).alignX).toBe('center')
122
- })
123
-
124
- it('overrides alignY with contentAlignY when simple', () => {
125
- const result = asVNode(Element({ contentAlignY: 'top', children: 'test' }))
126
- expect(getLayoutProps(result).alignY).toBe('top')
127
- })
128
-
129
- it('renders children directly via render() without Content wrappers', () => {
130
- const result = asVNode(Element({ children: h('span', null, 'inner') }))
131
- const slots = getContentSlots(result)
132
- expect(slots).toHaveLength(0)
133
- })
134
-
135
- it('renders string children directly as props.children', () => {
136
- const result = asVNode(Element({ children: 'hello' }))
137
- // Simple element fast path — passes children as a single value, not a
138
- // 3-slot array wrapping falsy beforeContent/afterContent. This avoids
139
- // 2 extra mountChild calls per Element in the common case.
140
- //
141
- // Children are wrapped in a reactive accessor (`() => resolveSlot(...)`)
142
- // so function-valued slot props (e.g. `content={() => <X />}`) stay
143
- // reactive — see `Element-slot-reactivity.browser.test.tsx`. The
144
- // accessor's RESOLVED value is the string `'hello'`.
145
- expect(typeof result.props.children).toBe('function')
146
- expect((result.props.children as () => unknown)()).toBe('hello')
147
- })
148
-
149
- it('passes block prop to Wrapper', () => {
150
- const result = asVNode(Element({ block: true, children: 'test' }))
151
- expect(getLayoutProps(result).block).toBe(true)
152
- })
153
- })
154
-
155
- describe('three-section layout (with beforeContent/afterContent)', () => {
156
- it('defaults wrapper direction to inline', () => {
157
- const result = asVNode(
158
- Element({
159
- beforeContent: h('span', null, 'B'),
160
- children: 'test',
161
- afterContent: h('span', null, 'A'),
162
- }),
163
- )
164
- expect(getLayoutProps(result).direction).toBe('inline')
165
- })
166
-
167
- it('uses explicit direction when provided', () => {
168
- const result = asVNode(
169
- Element({
170
- direction: 'rows',
171
- beforeContent: h('span', null, 'B'),
172
- children: 'test',
173
- afterContent: h('span', null, 'A'),
174
- }),
175
- )
176
- expect(getLayoutProps(result).direction).toBe('rows')
177
- })
178
-
179
- it('uses default alignX (left) and alignY (center)', () => {
180
- const result = asVNode(
181
- Element({
182
- beforeContent: 'B',
183
- children: 'test',
184
- afterContent: 'A',
185
- }),
186
- )
187
- expect(getLayoutProps(result).alignX).toBe('left')
188
- expect(getLayoutProps(result).alignY).toBe('center')
189
- })
190
-
191
- it('uses explicit alignX and alignY', () => {
192
- const result = asVNode(
193
- Element({
194
- alignX: 'center',
195
- alignY: 'top',
196
- beforeContent: 'B',
197
- children: 'test',
198
- afterContent: 'A',
199
- }),
200
- )
201
- expect(getLayoutProps(result).alignX).toBe('center')
202
- expect(getLayoutProps(result).alignY).toBe('top')
203
- })
204
-
205
- it('renders three Content children when both before and after exist', () => {
206
- const before = h('span', null, 'Before')
207
- const after = h('span', null, 'After')
208
- const result = asVNode(
209
- Element({
210
- beforeContent: before,
211
- children: 'Main',
212
- afterContent: after,
213
- }),
214
- )
215
-
216
- const slots = getContentSlots(result)
217
- expect(slots).toHaveLength(3)
218
- })
219
-
220
- it('sets correct contentType on each Content slot', () => {
221
- const before = h('span', null, 'Before')
222
- const after = h('span', null, 'After')
223
- const result = asVNode(
224
- Element({
225
- beforeContent: before,
226
- children: 'Main',
227
- afterContent: after,
228
- }),
229
- )
230
-
231
- const slots = getContentSlots(result)
232
- const [slot0, slot1, slot2] = slots as [VNode, VNode, VNode]
233
- expect(slot0.props.contentType).toBe('before')
234
- expect(slot1.props.contentType).toBe('content')
235
- expect(slot2.props.contentType).toBe('after')
236
- })
237
-
238
- it('passes parentDirection to Content slots', () => {
239
- const result = asVNode(
240
- Element({
241
- direction: 'rows',
242
- beforeContent: 'B',
243
- children: 'M',
244
- afterContent: 'A',
245
- }),
246
- )
247
-
248
- const slots = getContentSlots(result)
249
- for (const slot of slots) {
250
- expect(slot.props.parentDirection).toBe('rows')
251
- }
252
- })
253
-
254
- it('renders before and content Content slots when no afterContent', () => {
255
- const before = h('span', null, 'Before')
256
- const result = asVNode(
257
- Element({
258
- beforeContent: before,
259
- children: 'Main',
260
- }),
261
- )
262
-
263
- const slots = getContentSlots(result)
264
- // beforeContent makes isSimpleElement false, so content also gets a Content wrapper
265
- expect(slots).toHaveLength(2)
266
- const [s0, s1] = slots as [VNode, VNode]
267
- expect(s0.props.contentType).toBe('before')
268
- expect(s1.props.contentType).toBe('content')
269
- })
270
-
271
- it('renders content and after Content slots when no beforeContent', () => {
272
- const after = h('span', null, 'After')
273
- const result = asVNode(
274
- Element({
275
- children: 'Main',
276
- afterContent: after,
277
- }),
278
- )
279
-
280
- const slots = getContentSlots(result)
281
- // content slot + after slot (both are Content wrappers since afterContent makes it non-simple)
282
- expect(slots).toHaveLength(2)
283
- const [c0, c1] = slots as [VNode, VNode]
284
- expect(c0.props.contentType).toBe('content')
285
- expect(c1.props.contentType).toBe('after')
286
- })
287
-
288
- it('uses span sub-tag for inline parent elements (like span)', () => {
289
- const result = asVNode(
290
- Element({
291
- tag: 'span',
292
- beforeContent: 'B',
293
- children: 'M',
294
- afterContent: 'A',
295
- }),
296
- )
297
-
298
- const slots = getContentSlots(result)
299
- for (const slot of slots) {
300
- expect(slot.props.tag).toBe('span')
301
- }
302
- })
303
-
304
- it('uses undefined sub-tag for block parent elements (like div)', () => {
305
- const result = asVNode(
306
- Element({
307
- tag: 'div',
308
- beforeContent: 'B',
309
- children: 'M',
310
- afterContent: 'A',
311
- }),
312
- )
313
-
314
- const slots = getContentSlots(result)
315
- for (const slot of slots) {
316
- expect(slot.props.tag).toBeUndefined()
317
- }
318
- })
319
-
320
- it('passes equalCols to Content slots', () => {
321
- const result = asVNode(
322
- Element({
323
- equalCols: true,
324
- beforeContent: 'B',
325
- children: 'M',
326
- afterContent: 'A',
327
- }),
328
- )
329
-
330
- const slots = getContentSlots(result)
331
- for (const slot of slots) {
332
- expect(slot.props.equalCols).toBe(true)
333
- }
334
- })
335
-
336
- it('passes gap to before and after Content slots but not content slot', () => {
337
- const result = asVNode(
338
- Element({
339
- gap: 16,
340
- beforeContent: 'B',
341
- children: 'M',
342
- afterContent: 'A',
343
- }),
344
- )
345
-
346
- const slots = getContentSlots(result)
347
- const beforeSlot = slots.find((v) => v.props.contentType === 'before')
348
- const contentSlot = slots.find((v) => v.props.contentType === 'content')
349
- const afterSlot = slots.find((v) => v.props.contentType === 'after')
350
-
351
- expect(beforeSlot?.props.gap).toBe(16)
352
- expect(contentSlot?.props.gap).toBeUndefined()
353
- expect(afterSlot?.props.gap).toBe(16)
354
- })
355
-
356
- it('passes content-level alignment to the content Content slot', () => {
357
- const result = asVNode(
358
- Element({
359
- contentDirection: 'inline',
360
- contentAlignX: 'center',
361
- contentAlignY: 'top',
362
- beforeContent: 'B',
363
- children: 'M',
364
- afterContent: 'A',
365
- }),
366
- )
367
-
368
- const slots = getContentSlots(result)
369
- const contentSlot = slots.find((v) => v.props.contentType === 'content')
370
- expect(contentSlot?.props.direction).toBe('inline')
371
- expect(contentSlot?.props.alignX).toBe('center')
372
- expect(contentSlot?.props.alignY).toBe('top')
373
- })
374
-
375
- it('passes before-level alignment to the before Content slot', () => {
376
- const result = asVNode(
377
- Element({
378
- beforeContentDirection: 'rows',
379
- beforeContentAlignX: 'right',
380
- beforeContentAlignY: 'bottom',
381
- beforeContent: 'B',
382
- children: 'M',
383
- afterContent: 'A',
384
- }),
385
- )
386
-
387
- const slots = getContentSlots(result)
388
- const beforeSlot = slots.find((v) => v.props.contentType === 'before')
389
- expect(beforeSlot?.props.direction).toBe('rows')
390
- expect(beforeSlot?.props.alignX).toBe('right')
391
- expect(beforeSlot?.props.alignY).toBe('bottom')
392
- })
393
-
394
- it('passes after-level alignment to the after Content slot', () => {
395
- const result = asVNode(
396
- Element({
397
- afterContentDirection: 'rows',
398
- afterContentAlignX: 'center',
399
- afterContentAlignY: 'top',
400
- beforeContent: 'B',
401
- children: 'M',
402
- afterContent: 'A',
403
- }),
404
- )
405
-
406
- const slots = getContentSlots(result)
407
- const afterSlot = slots.find((v) => v.props.contentType === 'after')
408
- expect(afterSlot?.props.direction).toBe('rows')
409
- expect(afterSlot?.props.alignX).toBe('center')
410
- expect(afterSlot?.props.alignY).toBe('top')
411
- })
412
- })
413
-
414
- describe('HTML attribute filtering', () => {
415
- it('passes through id', () => {
416
- const result = asVNode(Element({ id: 'my-el', children: 'test' }))
417
- expect(result.props.id).toBe('my-el')
418
- })
419
-
420
- it('passes through role', () => {
421
- const result = asVNode(Element({ role: 'button', children: 'test' }))
422
- expect(result.props.role).toBe('button')
423
- })
424
-
425
- it('passes through data- attributes', () => {
426
- const result = asVNode(Element({ 'data-testid': 'el', children: 'test' }))
427
- expect(result.props['data-testid']).toBe('el')
428
- })
429
-
430
- it('passes through aria- attributes', () => {
431
- const result = asVNode(Element({ 'aria-label': 'label', children: 'test' }))
432
- expect(result.props['aria-label']).toBe('label')
433
- })
434
-
435
- it('passes through on-prefixed event handlers', () => {
436
- const handler = () => {
437
- /* noop */
438
- }
439
- const result = asVNode(Element({ onClick: handler, children: 'test' }))
440
- expect(result.props.onClick).toBe(handler)
441
- })
442
-
443
- it('passes through tabindex', () => {
444
- // @ts-expect-error — testing element-specific attr forwarding
445
- const result = asVNode(Element({ tabindex: 0, children: 'test' }))
446
- expect(result.props.tabindex).toBe(0)
447
- })
448
-
449
- it('passes through title', () => {
450
- const result = asVNode(Element({ title: 'tooltip', children: 'test' }))
451
- expect(result.props.title).toBe('tooltip')
452
- })
453
-
454
- it('passes through href for anchor tag', () => {
455
- // @ts-expect-error — testing element-specific attr forwarding
456
- const result = asVNode(Element({ tag: 'a', href: '/link', children: 'test' }))
457
- expect(result.props.href).toBe('/link')
458
- })
459
-
460
- it('passes through disabled for button tag', () => {
461
- // @ts-expect-error — testing element-specific attr forwarding
462
- const result = asVNode(Element({ tag: 'button', disabled: true, children: 'test' }))
463
- expect(result.props.disabled).toBe(true)
464
- })
465
-
466
- it('passes through class', () => {
467
- const result = asVNode(Element({ class: 'my-class', children: 'test' }))
468
- expect(result.props.class).toBe('my-class')
469
- })
470
-
471
- it('does not set class when not provided', () => {
472
- const result = asVNode(Element({ children: 'test' }))
473
- expect(result.props.class).toBeUndefined()
474
- })
475
-
476
- it('filters out reserved props (gap, beforeContent, afterContent, css, etc.)', () => {
477
- const result = asVNode(
478
- Element({
479
- beforeContent: h('span', null, 'x'),
480
- afterContent: h('span', null, 'y'),
481
- children: 'test',
482
- direction: 'inline',
483
- alignX: 'center',
484
- alignY: 'center',
485
- gap: 8,
486
- block: true,
487
- equalCols: true,
488
- }),
489
- )
490
- // These reserved props are consumed by Element and should not leak to Wrapper
491
- expect(result.props.gap).toBeUndefined()
492
- expect(result.props.beforeContent).toBeUndefined()
493
- expect(result.props.afterContent).toBeUndefined()
494
- expect(result.props.contentDirection).toBeUndefined()
495
- expect(result.props.css).toBeUndefined()
496
- expect(result.props.contentCss).toBeUndefined()
497
- expect(result.props.beforeContentCss).toBeUndefined()
498
- expect(result.props.afterContentCss).toBeUndefined()
499
- })
500
- })
501
-
502
- describe('ref handling', () => {
503
- it('passes a merged ref function to Wrapper', () => {
504
- const result = asVNode(Element({ children: 'test' }))
505
- expect(typeof result.props.ref).toBe('function')
506
- })
507
-
508
- it('wraps function ref in mergedRef', () => {
509
- let captured: HTMLElement | null = null
510
- const ref = (node: HTMLElement | null) => {
511
- captured = node
512
- }
513
- const result = asVNode(Element({ ref, children: 'test' }))
514
- expect(typeof result.props.ref).toBe('function')
515
- const fakeNode = {} as HTMLElement
516
- ;(result.props.ref as (node: HTMLElement | null) => void)(fakeNode)
517
- expect(captured).toBe(fakeNode)
518
- })
519
-
520
- it('wraps object ref in mergedRef', () => {
521
- const ref = { current: null as HTMLElement | null }
522
- const result = asVNode(Element({ ref, children: 'test' }))
523
- expect(typeof result.props.ref).toBe('function')
524
- const fakeNode = {} as HTMLElement
525
- ;(result.props.ref as (node: HTMLElement | null) => void)(fakeNode)
526
- expect(ref.current).toBe(fakeNode)
527
- })
528
- })
529
-
530
- describe('void / empty elements', () => {
531
- it('renders img with no children', () => {
532
- // @ts-expect-error — testing element-specific attr forwarding
533
- const result = asVNode(Element({ tag: 'img', src: '/pic.png' }))
534
- expect(typeof result.type).toBe("function")
535
- expect(getLayoutProps(result).tag).toBe('img')
536
- expect(result.props.src).toBe('/pic.png')
537
- expect(result.props.children).toBeUndefined()
538
- })
539
-
540
- it('renders input with no children', () => {
541
- // @ts-expect-error — testing element-specific attr forwarding
542
- const result = asVNode(Element({ tag: 'input', type: 'text' }))
543
- expect(typeof result.type).toBe("function")
544
- expect(getLayoutProps(result).tag).toBe('input')
545
- expect(result.props.type).toBe('text')
546
- expect(result.props.children).toBeUndefined()
547
- })
548
-
549
- it('renders with dangerouslySetInnerHTML (treated as empty)', () => {
550
- const result = asVNode(Element({ dangerouslySetInnerHTML: { __html: '<b>hi</b>' } }))
551
- expect(typeof result.type).toBe("function")
552
- expect(result.props.dangerouslySetInnerHTML).toEqual({ __html: '<b>hi</b>' })
553
- expect(result.props.children).toBeUndefined()
554
- })
555
-
556
- it('renders br with no children', () => {
557
- const result = asVNode(Element({ tag: 'br' }))
558
- expect(typeof result.type).toBe("function")
559
- expect(result.props.children).toBeUndefined()
560
- })
561
-
562
- it('renders hr with no children', () => {
563
- const result = asVNode(Element({ tag: 'hr' }))
564
- expect(typeof result.type).toBe("function")
565
- expect(result.props.children).toBeUndefined()
566
- })
567
- })
568
-
569
- describe('inline-vs-block tag rendering', () => {
570
- // The simple-element fast path inlines Wrapper into Styled and forwards
571
- // `tag` as the `as` prop. The pre-fast-path `isInline` flag was an
572
- // internal Wrapper plumbing detail that determined the inner SUB_TAG
573
- // for the rare needsFix (button/fieldset/legend) path — invisible on
574
- // span/a/section, which never need that fix. After the fast path the
575
- // flag is gone in the simple path; the rendered tag is the contract.
576
- it('renders inline tags like span as <span>', () => {
577
- const result = asVNode(Element({ tag: 'span', children: 'text' }))
578
- expect(getLayoutProps(result).tag).toBe('span')
579
- })
580
-
581
- it('renders anchor tag as <a>', () => {
582
- // @ts-expect-error — testing element-specific attr forwarding
583
- const result = asVNode(Element({ tag: 'a', href: '#', children: 'link' }))
584
- expect(getLayoutProps(result).tag).toBe('a')
585
- })
586
-
587
- it('renders block tags like section as <section>', () => {
588
- const result = asVNode(Element({ tag: 'section', children: 'text' }))
589
- expect(getLayoutProps(result).tag).toBe('section')
590
- })
591
-
592
- it('leaves tag undefined when not specified (default div)', () => {
593
- const result = asVNode(Element({ children: 'text' }))
594
- expect(getLayoutProps(result).tag).toBeUndefined()
595
- })
596
- })
597
-
598
- describe('extendCss prop', () => {
599
- it('passes css prop as extendCss to Wrapper', () => {
600
- const customCss = 'color: red;'
601
- const result = asVNode(Element({ css: customCss, children: 'test' }))
602
- expect(getLayoutProps(result).extendCss).toBe(customCss)
603
- })
604
-
605
- it('does not pass extendCss when css not provided', () => {
606
- const result = asVNode(Element({ children: 'test' }))
607
- expect(getLayoutProps(result).extendCss).toBeUndefined()
608
- })
609
- })
610
-
611
- describe('content fallback chain', () => {
612
- it('prefers children over content', () => {
613
- const result = asVNode(Element({ children: 'child', content: 'alt' }))
614
- // Simple-element fast path returns children directly. The fallback
615
- // chain (children → content → label) is exercised inside getChildren(),
616
- // which runs INSIDE the reactive accessor wrap — so invoking the
617
- // accessor reveals the resolved value.
618
- expect(typeof result.props.children).toBe('function')
619
- expect((result.props.children as () => unknown)()).toBe('child')
620
- })
621
-
622
- it('falls back to content when no children', () => {
623
- const result = asVNode(Element({ content: 'alt content' }))
624
- const children = result.props.children as unknown[]
625
- expect(children).toBeDefined()
626
- })
627
-
628
- it('falls back to label when no children or content', () => {
629
- const result = asVNode(Element({ label: 'label text' }))
630
- const children = result.props.children as unknown[]
631
- expect(children).toBeDefined()
632
- })
633
- })
634
-
635
- describe('Wrapper as prop reset', () => {
636
- it('resets the as prop to undefined on Wrapper', () => {
637
- const result = asVNode(Element({ children: 'test' }))
638
- expect(result.props.as).toBeUndefined()
639
- })
640
- })
641
-
642
- describe('button tag (flex fix needed)', () => {
643
- it('passes tag as button to Wrapper', () => {
644
- const result = asVNode(Element({ tag: 'button', children: 'click' }))
645
- expect(typeof result.type).toBe("function")
646
- expect(getLayoutProps(result).tag).toBe('button')
647
- })
648
-
649
- it('passes isInline=true for button (inline element)', () => {
650
- const result = asVNode(Element({ tag: 'button', children: 'click' }))
651
- expect(getLayoutProps(result).isInline).toBe(true)
652
- })
653
- })
654
-
655
- describe('equalBeforeAfter ResizeObserver', () => {
656
- // Captures the live ResizeObserver constructor — we install a stub on
657
- // globalThis for the duration of the test, mount + unmount via the real
658
- // runtime-dom pipeline, and assert the observer was set up + cleaned up.
659
- // Mirrors vitus-labs's useLayoutEffect + ResizeObserver setup so async
660
- // slot resizes (font swaps, lazy text, viewport changes) keep the
661
- // before/after slots equalized — not just the one-shot mount measurement.
662
- type ROStub = {
663
- observed: HTMLElement[]
664
- disconnects: number
665
- callbacks: Array<() => void>
666
- }
667
-
668
- function installResizeObserverStub(): ROStub {
669
- const stub: ROStub = { observed: [], disconnects: 0, callbacks: [] }
670
- class StubResizeObserver {
671
- callback: () => void
672
- constructor(callback: () => void) {
673
- this.callback = callback
674
- stub.callbacks.push(callback)
675
- }
676
- observe(node: HTMLElement) {
677
- stub.observed.push(node)
678
- }
679
- disconnect() {
680
- stub.disconnects++
681
- }
682
- unobserve() {
683
- /* no-op */
684
- }
685
- }
686
- ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = StubResizeObserver
687
- return stub
688
- }
689
-
690
- function uninstallResizeObserverStub(prev: unknown) {
691
- if (prev === undefined)
692
- delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
693
- else (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = prev
694
- }
695
-
696
- it('observes the equalize ref on mount when equalBeforeAfter+before+after are set', async () => {
697
- const { mount } = await import('@pyreon/runtime-dom')
698
- const prev = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
699
- const stub = installResizeObserverStub()
700
- try {
701
- const root = document.createElement('div')
702
- document.body.appendChild(root)
703
-
704
- const unmount = mount(
705
- h(Element, {
706
- equalBeforeAfter: true,
707
- beforeContent: h('span', null, 'B'),
708
- children: 'main',
709
- afterContent: h('span', null, 'A'),
710
- }),
711
- root,
712
- )
713
-
714
- expect(stub.observed.length).toBe(1)
715
- expect(stub.disconnects).toBe(0)
716
-
717
- unmount()
718
- expect(stub.disconnects).toBe(1)
719
-
720
- root.remove()
721
- } finally {
722
- uninstallResizeObserverStub(prev)
723
- }
724
- })
725
-
726
- it('does not register an observer when equalBeforeAfter is false', async () => {
727
- const { mount } = await import('@pyreon/runtime-dom')
728
- const prev = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
729
- const stub = installResizeObserverStub()
730
- try {
731
- const root = document.createElement('div')
732
- document.body.appendChild(root)
733
-
734
- const unmount = mount(
735
- h(Element, {
736
- beforeContent: h('span', null, 'B'),
737
- children: 'main',
738
- afterContent: h('span', null, 'A'),
739
- }),
740
- root,
741
- )
742
-
743
- expect(stub.observed.length).toBe(0)
744
-
745
- unmount()
746
- root.remove()
747
- } finally {
748
- uninstallResizeObserverStub(prev)
749
- }
750
- })
751
-
752
- it('does not register an observer when only one of before/after is set', async () => {
753
- const { mount } = await import('@pyreon/runtime-dom')
754
- const prev = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
755
- const stub = installResizeObserverStub()
756
- try {
757
- const root = document.createElement('div')
758
- document.body.appendChild(root)
759
-
760
- const unmount = mount(
761
- h(Element, {
762
- equalBeforeAfter: true,
763
- beforeContent: h('span', null, 'B'),
764
- children: 'main',
765
- }),
766
- root,
767
- )
768
-
769
- expect(stub.observed.length).toBe(0)
770
-
771
- unmount()
772
- root.remove()
773
- } finally {
774
- uninstallResizeObserverStub(prev)
775
- }
776
- })
777
-
778
- it('survives missing ResizeObserver global (SSR / older runtimes)', async () => {
779
- const { mount } = await import('@pyreon/runtime-dom')
780
- const prev = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
781
- delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
782
- try {
783
- const root = document.createElement('div')
784
- document.body.appendChild(root)
785
-
786
- // Should not throw even though ResizeObserver is undefined.
787
- const unmount = mount(
788
- h(Element, {
789
- equalBeforeAfter: true,
790
- beforeContent: h('span', null, 'B'),
791
- children: 'main',
792
- afterContent: h('span', null, 'A'),
793
- }),
794
- root,
795
- )
796
- unmount()
797
- root.remove()
798
- } finally {
799
- uninstallResizeObserverStub(prev)
800
- }
801
- })
802
- })
803
-
804
- describe('component metadata', () => {
805
- it('has displayName set', () => {
806
- expect(Element.displayName).toBeDefined()
807
- expect(Element.displayName).toContain('Element')
808
- })
809
-
810
- it('has PYREON__COMPONENT set', () => {
811
- expect(Element.PYREON__COMPONENT).toBeDefined()
812
- expect(Element.PYREON__COMPONENT).toContain('Element')
813
- })
814
-
815
- it('has pkgName set', () => {
816
- expect(Element.pkgName).toBeDefined()
817
- })
818
- })
819
- })