@pyreon/elements 0.15.0 → 0.16.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 +105 -48
- package/lib/index.js +51 -32
- package/package.json +12 -12
- package/src/Element/component.tsx +13 -2
- package/src/List/component.tsx +65 -15
- package/src/Portal/component.tsx +23 -10
- package/src/__tests__/Element.test.ts +157 -0
- package/src/__tests__/Iterator.test.ts +12 -3
- package/src/__tests__/Iterator.types.test.ts +237 -0
- package/src/__tests__/Portal.test.ts +122 -48
- package/src/__tests__/Wrapper-innerhtml.test.tsx +178 -0
- package/src/__tests__/elements.browser.test.tsx +47 -0
- package/src/__tests__/wrapper-block-cascade.test.ts +121 -0
- package/src/helpers/Iterator/component.tsx +55 -4
- package/src/helpers/Iterator/index.ts +17 -1
- package/src/helpers/Iterator/types.ts +97 -38
- package/src/helpers/Wrapper/component.tsx +32 -0
- package/src/helpers/Wrapper/styled.ts +12 -18
- package/src/index.ts +4 -0
- package/src/types.ts +33 -2
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { VNode } from '@pyreon/core'
|
|
2
2
|
import { h } from '@pyreon/core'
|
|
3
|
+
import * as runtimeDom from '@pyreon/runtime-dom'
|
|
3
4
|
import { describe, expect, it } from 'vitest'
|
|
4
5
|
import { Element } from '../Element'
|
|
5
6
|
import Content from '../helpers/Content/component'
|
|
6
7
|
import Wrapper from '../helpers/Wrapper/component'
|
|
7
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
|
+
|
|
8
16
|
const asVNode = (v: unknown) => v as VNode
|
|
9
17
|
|
|
10
18
|
/**
|
|
@@ -635,6 +643,155 @@ describe('Element', () => {
|
|
|
635
643
|
})
|
|
636
644
|
})
|
|
637
645
|
|
|
646
|
+
describe('equalBeforeAfter ResizeObserver', () => {
|
|
647
|
+
// Captures the live ResizeObserver constructor — we install a stub on
|
|
648
|
+
// globalThis for the duration of the test, mount + unmount via the real
|
|
649
|
+
// runtime-dom pipeline, and assert the observer was set up + cleaned up.
|
|
650
|
+
// Mirrors vitus-labs's useLayoutEffect + ResizeObserver setup so async
|
|
651
|
+
// slot resizes (font swaps, lazy text, viewport changes) keep the
|
|
652
|
+
// before/after slots equalized — not just the one-shot mount measurement.
|
|
653
|
+
type ROStub = {
|
|
654
|
+
observed: HTMLElement[]
|
|
655
|
+
disconnects: number
|
|
656
|
+
callbacks: Array<() => void>
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function installResizeObserverStub(): ROStub {
|
|
660
|
+
const stub: ROStub = { observed: [], disconnects: 0, callbacks: [] }
|
|
661
|
+
class StubResizeObserver {
|
|
662
|
+
callback: () => void
|
|
663
|
+
constructor(callback: () => void) {
|
|
664
|
+
this.callback = callback
|
|
665
|
+
stub.callbacks.push(callback)
|
|
666
|
+
}
|
|
667
|
+
observe(node: HTMLElement) {
|
|
668
|
+
stub.observed.push(node)
|
|
669
|
+
}
|
|
670
|
+
disconnect() {
|
|
671
|
+
stub.disconnects++
|
|
672
|
+
}
|
|
673
|
+
unobserve() {
|
|
674
|
+
/* no-op */
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = StubResizeObserver
|
|
678
|
+
return stub
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function uninstallResizeObserverStub(prev: unknown) {
|
|
682
|
+
if (prev === undefined)
|
|
683
|
+
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
|
|
684
|
+
else (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = prev
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
it('observes the equalize ref on mount when equalBeforeAfter+before+after are set', async () => {
|
|
688
|
+
const { mount } = await import('@pyreon/runtime-dom')
|
|
689
|
+
const prev = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
|
|
690
|
+
const stub = installResizeObserverStub()
|
|
691
|
+
try {
|
|
692
|
+
const root = document.createElement('div')
|
|
693
|
+
document.body.appendChild(root)
|
|
694
|
+
|
|
695
|
+
const unmount = mount(
|
|
696
|
+
h(Element, {
|
|
697
|
+
equalBeforeAfter: true,
|
|
698
|
+
beforeContent: h('span', null, 'B'),
|
|
699
|
+
children: 'main',
|
|
700
|
+
afterContent: h('span', null, 'A'),
|
|
701
|
+
}),
|
|
702
|
+
root,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
expect(stub.observed.length).toBe(1)
|
|
706
|
+
expect(stub.disconnects).toBe(0)
|
|
707
|
+
|
|
708
|
+
unmount()
|
|
709
|
+
expect(stub.disconnects).toBe(1)
|
|
710
|
+
|
|
711
|
+
root.remove()
|
|
712
|
+
} finally {
|
|
713
|
+
uninstallResizeObserverStub(prev)
|
|
714
|
+
}
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it('does not register an observer when equalBeforeAfter is false', async () => {
|
|
718
|
+
const { mount } = await import('@pyreon/runtime-dom')
|
|
719
|
+
const prev = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
|
|
720
|
+
const stub = installResizeObserverStub()
|
|
721
|
+
try {
|
|
722
|
+
const root = document.createElement('div')
|
|
723
|
+
document.body.appendChild(root)
|
|
724
|
+
|
|
725
|
+
const unmount = mount(
|
|
726
|
+
h(Element, {
|
|
727
|
+
beforeContent: h('span', null, 'B'),
|
|
728
|
+
children: 'main',
|
|
729
|
+
afterContent: h('span', null, 'A'),
|
|
730
|
+
}),
|
|
731
|
+
root,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
expect(stub.observed.length).toBe(0)
|
|
735
|
+
|
|
736
|
+
unmount()
|
|
737
|
+
root.remove()
|
|
738
|
+
} finally {
|
|
739
|
+
uninstallResizeObserverStub(prev)
|
|
740
|
+
}
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it('does not register an observer when only one of before/after is set', async () => {
|
|
744
|
+
const { mount } = await import('@pyreon/runtime-dom')
|
|
745
|
+
const prev = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
|
|
746
|
+
const stub = installResizeObserverStub()
|
|
747
|
+
try {
|
|
748
|
+
const root = document.createElement('div')
|
|
749
|
+
document.body.appendChild(root)
|
|
750
|
+
|
|
751
|
+
const unmount = mount(
|
|
752
|
+
h(Element, {
|
|
753
|
+
equalBeforeAfter: true,
|
|
754
|
+
beforeContent: h('span', null, 'B'),
|
|
755
|
+
children: 'main',
|
|
756
|
+
}),
|
|
757
|
+
root,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
expect(stub.observed.length).toBe(0)
|
|
761
|
+
|
|
762
|
+
unmount()
|
|
763
|
+
root.remove()
|
|
764
|
+
} finally {
|
|
765
|
+
uninstallResizeObserverStub(prev)
|
|
766
|
+
}
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('survives missing ResizeObserver global (SSR / older runtimes)', async () => {
|
|
770
|
+
const { mount } = await import('@pyreon/runtime-dom')
|
|
771
|
+
const prev = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
|
|
772
|
+
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
|
|
773
|
+
try {
|
|
774
|
+
const root = document.createElement('div')
|
|
775
|
+
document.body.appendChild(root)
|
|
776
|
+
|
|
777
|
+
// Should not throw even though ResizeObserver is undefined.
|
|
778
|
+
const unmount = mount(
|
|
779
|
+
h(Element, {
|
|
780
|
+
equalBeforeAfter: true,
|
|
781
|
+
beforeContent: h('span', null, 'B'),
|
|
782
|
+
children: 'main',
|
|
783
|
+
afterContent: h('span', null, 'A'),
|
|
784
|
+
}),
|
|
785
|
+
root,
|
|
786
|
+
)
|
|
787
|
+
unmount()
|
|
788
|
+
root.remove()
|
|
789
|
+
} finally {
|
|
790
|
+
uninstallResizeObserverStub(prev)
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
})
|
|
794
|
+
|
|
638
795
|
describe('component metadata', () => {
|
|
639
796
|
it('has displayName set', () => {
|
|
640
797
|
expect(Element.displayName).toBeDefined()
|
|
@@ -2,6 +2,15 @@ import type { ComponentFn, VNode, VNodeChild } from '@pyreon/core'
|
|
|
2
2
|
import { Fragment, h } from '@pyreon/core'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import Iterator from '../helpers/Iterator/component'
|
|
5
|
+
import type { LooseProps as IteratorLooseProps } from '../helpers/Iterator/types'
|
|
6
|
+
|
|
7
|
+
// The strict overloads on Iterator's public surface reject edge-case shapes
|
|
8
|
+
// like `{}` (no data, no children) or `{ children, data }` (conflicting
|
|
9
|
+
// modes). The runtime tolerates them deliberately — we test those tolerated
|
|
10
|
+
// edge cases here, so we cast to the loose internal prop type the
|
|
11
|
+
// implementation accepts. End users hit the strict overloads; these tests
|
|
12
|
+
// exercise the runtime fallbacks the overloads structurally forbid.
|
|
13
|
+
const Loose = Iterator as unknown as (props: IteratorLooseProps) => VNodeChild
|
|
5
14
|
|
|
6
15
|
const asVNode = (v: unknown) => v as VNode
|
|
7
16
|
|
|
@@ -43,7 +52,7 @@ describe('Iterator', () => {
|
|
|
43
52
|
})
|
|
44
53
|
|
|
45
54
|
it('returns null when children is null/undefined', () => {
|
|
46
|
-
const result =
|
|
55
|
+
const result = Loose({})
|
|
47
56
|
expect(result).toBeNull()
|
|
48
57
|
})
|
|
49
58
|
|
|
@@ -87,7 +96,7 @@ describe('Iterator', () => {
|
|
|
87
96
|
|
|
88
97
|
it('children take priority over data', () => {
|
|
89
98
|
const child = h('span', { 'data-testid': 'child' }, 'Child wins')
|
|
90
|
-
const result =
|
|
99
|
+
const result = Loose({
|
|
91
100
|
children: child,
|
|
92
101
|
component: TextItem,
|
|
93
102
|
data: ['x', 'y'],
|
|
@@ -442,7 +451,7 @@ describe('Iterator', () => {
|
|
|
442
451
|
|
|
443
452
|
describe('edge cases', () => {
|
|
444
453
|
it('returns null when component is missing but data exists', () => {
|
|
445
|
-
const result =
|
|
454
|
+
const result = Loose({ data: ['a', 'b'] })
|
|
446
455
|
expect(result).toBeNull()
|
|
447
456
|
})
|
|
448
457
|
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time type tests for Iterator + List overloads.
|
|
3
|
+
*
|
|
4
|
+
* The public callable interface ships FOUR overloads in priority order:
|
|
5
|
+
*
|
|
6
|
+
* 1. SimpleProps<T extends SimpleValue> — `valueName` allowed, no `children`
|
|
7
|
+
* 2. ObjectProps<T extends ObjectValue> — `valueName` FORBIDDEN, no `children`
|
|
8
|
+
* 3. ChildrenProps — `children` required, no `data`/`component`
|
|
9
|
+
* 4. LooseProps — fallback for forwarding patterns
|
|
10
|
+
*
|
|
11
|
+
* The first three drive per-mode T inference and stricter constraints for
|
|
12
|
+
* direct callers. The 4th (LooseProps — added in PR #229's mirror) exists
|
|
13
|
+
* so that wide-union props produced by `@pyreon/rocketstyle`'s 4-overload-
|
|
14
|
+
* aware `ExtractProps` (PR #222 mirror) have a binding home. Pre-fallback
|
|
15
|
+
* the wide union failed to bind to any narrow overload and TS reported
|
|
16
|
+
* "no overload matches this call" at every forwarding site.
|
|
17
|
+
*
|
|
18
|
+
* Trade-off: adding the loose fallback intentionally weakens the strict
|
|
19
|
+
* per-mode constraints for DIRECT callers too — a call that doesn't match
|
|
20
|
+
* Simple / Object / Children will now match LooseProps and compile. This
|
|
21
|
+
* matches vitus-labs's design choice (PR #229): forwarding-pattern support
|
|
22
|
+
* is more valuable than mixed-shape rejection at the type level. Runtime
|
|
23
|
+
* still picks the right mode based on the data shape.
|
|
24
|
+
*
|
|
25
|
+
* Regression: pre-PR-5 (4-overload `ExtractProps`), Pyreon's Iterator type
|
|
26
|
+
* collapsed all three narrow modes when wrapped through `rocketstyle()` /
|
|
27
|
+
* `attrs()` — only the LAST overload's props survived. PR #5 + PR #7
|
|
28
|
+
* together restore the full union AND give it a binding home.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, expectTypeOf, it } from 'vitest'
|
|
32
|
+
import { h } from '@pyreon/core'
|
|
33
|
+
import Iterator from '../helpers/Iterator/component'
|
|
34
|
+
import List from '../List/component'
|
|
35
|
+
import type {
|
|
36
|
+
ChildrenProps,
|
|
37
|
+
LooseProps,
|
|
38
|
+
ObjectProps,
|
|
39
|
+
Props,
|
|
40
|
+
SimpleProps,
|
|
41
|
+
} from '../helpers/Iterator/types'
|
|
42
|
+
|
|
43
|
+
describe('Iterator — Props<T> generic dispatch', () => {
|
|
44
|
+
it('Props<string> narrows to SimpleProps<string>', () => {
|
|
45
|
+
expectTypeOf<Props<string>>().toEqualTypeOf<SimpleProps<string>>()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('Props<{ id: number; name: string }> narrows to ObjectProps<...>', () => {
|
|
49
|
+
type User = { id: number; name: string }
|
|
50
|
+
expectTypeOf<Props<User>>().toEqualTypeOf<ObjectProps<User>>()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('Props<unknown> (default) falls back to loose props', () => {
|
|
54
|
+
// The default Props (no T) MUST accept the legacy untyped call surface.
|
|
55
|
+
// Smoke check: `data: any[]` shape continues to typecheck without
|
|
56
|
+
// narrowing.
|
|
57
|
+
type Default = Props
|
|
58
|
+
const _ok: Default = { data: ['a', 1, null], component: 'div' }
|
|
59
|
+
void _ok
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('Iterator — happy-path overload selection', () => {
|
|
64
|
+
const Item = (p: { children?: unknown }) => h('span', null, p.children as never)
|
|
65
|
+
|
|
66
|
+
it('SimpleProps mode: valueName allowed', () => {
|
|
67
|
+
// Direct call with primitive data + valueName → SimpleProps overload
|
|
68
|
+
Iterator({
|
|
69
|
+
data: ['a', 'b'] as string[],
|
|
70
|
+
component: Item,
|
|
71
|
+
valueName: 'text',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Mixed shape (children + data) now falls through to LooseProps — by
|
|
75
|
+
// design after PR #229's mirror. Strict per-mode rejection is no longer
|
|
76
|
+
// enforced at the type level; runtime picks the right path based on
|
|
77
|
+
// which props are present. This compiles and that's intentional.
|
|
78
|
+
Iterator({
|
|
79
|
+
data: ['a', 'b'] as string[],
|
|
80
|
+
component: Item,
|
|
81
|
+
children: h('span', null, 'leaked'),
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('ObjectProps mode: valueName FORBIDDEN by Object overload but accepted by Loose', () => {
|
|
86
|
+
type Row = { id: number; label: string }
|
|
87
|
+
Iterator({
|
|
88
|
+
data: [{ id: 1, label: 'a' }] as Row[],
|
|
89
|
+
component: Item,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// `valueName: 'row'` doesn't match ObjectProps' `valueName?: never`, but
|
|
93
|
+
// the LooseProps fallback accepts it. Pre-PR-7 this errored; now it's a
|
|
94
|
+
// legal forwarding shape.
|
|
95
|
+
Iterator({
|
|
96
|
+
data: [{ id: 1, label: 'a' }] as Row[],
|
|
97
|
+
component: Item,
|
|
98
|
+
valueName: 'row',
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('ChildrenProps mode: clean form picked when only children supplied', () => {
|
|
103
|
+
Iterator({ children: h('span', null, 'hi') })
|
|
104
|
+
|
|
105
|
+
// Mixing children with data / component used to be a hard error.
|
|
106
|
+
// Post-PR-7 the LooseProps fallback accepts these — runtime decides
|
|
107
|
+
// which mode fires based on which fields are populated.
|
|
108
|
+
Iterator({
|
|
109
|
+
children: h('span', null, 'hi'),
|
|
110
|
+
data: [1, 2, 3],
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
Iterator({
|
|
114
|
+
children: h('span', null, 'hi'),
|
|
115
|
+
component: Item,
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('Iterator + List — LooseProps fallback for forwarding patterns (PR #7)', () => {
|
|
121
|
+
const Item = (p: { children?: unknown }) => h('span', null, p.children as never)
|
|
122
|
+
|
|
123
|
+
it('LooseProps shape binds via the 4th overload (no overload-mismatch error)', () => {
|
|
124
|
+
// The motivating shape: a wide-union props object produced by
|
|
125
|
+
// `Partial<(typeof Wrapper)['$$types']>` (rocketstyle's $$types after
|
|
126
|
+
// PR #5's 4-overload ExtractProps distributes the union). Without the
|
|
127
|
+
// loose fallback overload, this fails at every forwarding call site
|
|
128
|
+
// with "no overload matches this call" — the wide union doesn't bind
|
|
129
|
+
// to SimpleProps<T> / ObjectProps<T> / ChildrenProps individually.
|
|
130
|
+
const looseForwarded: LooseProps = {
|
|
131
|
+
data: ['a', 'b'],
|
|
132
|
+
component: Item,
|
|
133
|
+
valueName: 'text',
|
|
134
|
+
}
|
|
135
|
+
Iterator(looseForwarded)
|
|
136
|
+
// Cast preserves the LooseProps binding (the conditional Props<T>
|
|
137
|
+
// exposes LooseProps when T defaults to unknown).
|
|
138
|
+
Iterator(looseForwarded as Props)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('partial / empty shapes are accepted via LooseProps', () => {
|
|
142
|
+
// Empty object — no narrow overload matches (data missing for Simple/
|
|
143
|
+
// Object, children missing for Children) but LooseProps' fields are
|
|
144
|
+
// all optional. This is the genuine forwarding shape from props spread.
|
|
145
|
+
const empty: LooseProps = {}
|
|
146
|
+
Iterator(empty)
|
|
147
|
+
|
|
148
|
+
// Object with just `data` (no component) — falls through to LooseProps.
|
|
149
|
+
Iterator({ data: ['a'] as string[] } as LooseProps)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('List inherits the LooseProps fallback overload', () => {
|
|
153
|
+
const looseForwarded: LooseProps = {
|
|
154
|
+
data: [{ id: 1, name: 'A' }],
|
|
155
|
+
component: Item,
|
|
156
|
+
}
|
|
157
|
+
// List's 4th overload mirrors Iterator's — wide unions bind here too.
|
|
158
|
+
List({ ...looseForwarded, rootElement: true })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('the loose fallback is the 4th overload (order matters for inference)', () => {
|
|
162
|
+
// Direct callers that DO match SimpleProps shape should still drive
|
|
163
|
+
// T inference from `data` — i.e. the overload picked at the call site
|
|
164
|
+
// is the FIRST one matching, not LooseProps. This is what preserves
|
|
165
|
+
// the strict per-mode constraints for the direct-caller happy path.
|
|
166
|
+
//
|
|
167
|
+
// We can't introspect "which overload TS picked" directly, but we CAN
|
|
168
|
+
// prove SimpleProps<T>'s `valueName?: string` survives — a LooseProps
|
|
169
|
+
// pick would lose the per-mode field constraints. The fact that the
|
|
170
|
+
// existing `expectTypeOf<Props<string>>().toEqualTypeOf<SimpleProps<string>>()`
|
|
171
|
+
// in the first describe block passes is the structural anchor.
|
|
172
|
+
type Props_string = Props<string>
|
|
173
|
+
expectTypeOf<Props_string>().toEqualTypeOf<SimpleProps<string>>()
|
|
174
|
+
// Negative: when T is unparameterized, Props falls back to LooseProps
|
|
175
|
+
// by design (the `unknown extends T` clause in Props<T>'s definition).
|
|
176
|
+
expectTypeOf<Props>().toEqualTypeOf<LooseProps>()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('List — generic flow + Element prop forwarding', () => {
|
|
181
|
+
const Card = (p: { children?: unknown }) => h('div', null, p.children as never)
|
|
182
|
+
|
|
183
|
+
it('inherits Iterator overload constraints', () => {
|
|
184
|
+
type User = { id: number; name: string }
|
|
185
|
+
List({
|
|
186
|
+
data: [{ id: 1, name: 'Alice' }] as User[],
|
|
187
|
+
component: Card,
|
|
188
|
+
rootElement: true,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ObjectProps mode forbids `valueName`, but the LooseProps fallback
|
|
192
|
+
// accepts it — same trade-off as Iterator (see top-of-file comment).
|
|
193
|
+
List({
|
|
194
|
+
data: [{ id: 1, name: 'Alice' }] as User[],
|
|
195
|
+
component: Card,
|
|
196
|
+
valueName: 'user',
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('forwards Element layout props (tag, direction, alignX, …)', () => {
|
|
201
|
+
List({
|
|
202
|
+
data: ['a', 'b'] as string[],
|
|
203
|
+
component: Card,
|
|
204
|
+
valueName: 'text',
|
|
205
|
+
rootElement: true,
|
|
206
|
+
tag: 'ul',
|
|
207
|
+
direction: 'rows',
|
|
208
|
+
alignX: 'center',
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('rejects Element label/content (List-specific blacklist)', () => {
|
|
213
|
+
List({
|
|
214
|
+
children: h('span', null, 'hi'),
|
|
215
|
+
// @ts-expect-error — List forbids `label` (ListOnly: label?: never)
|
|
216
|
+
label: 'oops',
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
List({
|
|
220
|
+
children: h('span', null, 'hi'),
|
|
221
|
+
// @ts-expect-error — List forbids `content` (ListOnly: content?: never)
|
|
222
|
+
content: 'oops',
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('Children-vs-data type discrimination', () => {
|
|
228
|
+
it('ChildrenProps and SimpleProps are mutually exclusive', () => {
|
|
229
|
+
// The strict overloads pick exactly one mode. ChildrenProps' `data: never`
|
|
230
|
+
// and SimpleProps' `children: never` ensure they can't be unified into
|
|
231
|
+
// one shape — verified at the type level here.
|
|
232
|
+
type C = ChildrenProps
|
|
233
|
+
type S = SimpleProps<string>
|
|
234
|
+
expectTypeOf<C['data']>().toEqualTypeOf<undefined>()
|
|
235
|
+
expectTypeOf<S['children']>().toEqualTypeOf<undefined>()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -1,68 +1,142 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
4
|
import { Portal } from '../Portal'
|
|
5
5
|
|
|
6
|
-
const asVNode = (v: unknown) => v as VNode
|
|
7
|
-
|
|
8
6
|
describe('Portal', () => {
|
|
9
|
-
describe('
|
|
10
|
-
it('
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
describe('wrapper element creation', () => {
|
|
8
|
+
it('creates a per-instance wrapper appended to document.body by default', () => {
|
|
9
|
+
const before = document.body.children.length
|
|
10
|
+
const root = document.createElement('div')
|
|
11
|
+
document.body.appendChild(root)
|
|
12
|
+
|
|
13
|
+
const unmount = mount(h(Portal, { children: h('span', { id: 'pchild' }, 'modal') }), root)
|
|
14
|
+
|
|
15
|
+
// Wrapper appended directly to document.body (not inside `root`).
|
|
16
|
+
expect(document.body.children.length).toBe(before + 2) // root + portal wrapper
|
|
17
|
+
const wrapper = document.body.querySelector('#pchild')!.parentElement!
|
|
18
|
+
expect(wrapper).not.toBe(document.body)
|
|
19
|
+
expect(wrapper.tagName).toBe('DIV')
|
|
20
|
+
expect(wrapper.parentElement).toBe(document.body)
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const result = asVNode(Portal({ children: child }))
|
|
19
|
-
const props = result.props as Record<string, unknown>
|
|
20
|
-
expect(props.target).toBe(document.body)
|
|
22
|
+
unmount()
|
|
23
|
+
root.remove()
|
|
21
24
|
})
|
|
22
25
|
|
|
23
|
-
it('uses
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
it('uses the supplied tag for the wrapper element', () => {
|
|
27
|
+
const root = document.createElement('div')
|
|
28
|
+
document.body.appendChild(root)
|
|
29
|
+
|
|
30
|
+
const unmount = mount(
|
|
31
|
+
h(Portal, { tag: 'section', children: h('span', { id: 'tagchild' }, 'x') }),
|
|
32
|
+
root,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const wrapper = document.body.querySelector('#tagchild')!.parentElement!
|
|
36
|
+
expect(wrapper.tagName).toBe('SECTION')
|
|
37
|
+
|
|
38
|
+
unmount()
|
|
39
|
+
root.remove()
|
|
29
40
|
})
|
|
30
41
|
|
|
31
|
-
it('
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
it('appends the wrapper to DOMLocation when provided', () => {
|
|
43
|
+
const root = document.createElement('div')
|
|
44
|
+
const customTarget = document.createElement('article')
|
|
45
|
+
customTarget.id = 'custom-target'
|
|
46
|
+
document.body.appendChild(root)
|
|
47
|
+
document.body.appendChild(customTarget)
|
|
48
|
+
|
|
49
|
+
const unmount = mount(
|
|
50
|
+
h(Portal, { DOMLocation: customTarget, children: h('span', { id: 'cchild' }, 'inside') }),
|
|
51
|
+
root,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const wrapper = customTarget.querySelector('#cchild')!.parentElement!
|
|
55
|
+
expect(wrapper.parentElement).toBe(customTarget)
|
|
56
|
+
|
|
57
|
+
unmount()
|
|
58
|
+
root.remove()
|
|
59
|
+
customTarget.remove()
|
|
36
60
|
})
|
|
37
61
|
|
|
38
|
-
it('
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
it('renders children inside the wrapper', () => {
|
|
63
|
+
const root = document.createElement('div')
|
|
64
|
+
document.body.appendChild(root)
|
|
65
|
+
|
|
66
|
+
const unmount = mount(
|
|
67
|
+
h(Portal, { children: h('span', { id: 'inside-wrapper', class: 'modal' }, 'Modal') }),
|
|
68
|
+
root,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const child = document.body.querySelector('#inside-wrapper')!
|
|
72
|
+
expect(child.textContent).toBe('Modal')
|
|
73
|
+
const wrapper = child.parentElement!
|
|
74
|
+
expect(wrapper.parentElement).toBe(document.body)
|
|
75
|
+
|
|
76
|
+
unmount()
|
|
77
|
+
root.remove()
|
|
42
78
|
})
|
|
43
79
|
|
|
44
|
-
it('
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
80
|
+
it('removes the wrapper from the DOM on unmount', () => {
|
|
81
|
+
const root = document.createElement('div')
|
|
82
|
+
document.body.appendChild(root)
|
|
83
|
+
|
|
84
|
+
const before = document.body.children.length
|
|
85
|
+
const unmount = mount(
|
|
86
|
+
h(Portal, { children: h('span', { id: 'cleanup-child' }, 'x') }),
|
|
87
|
+
root,
|
|
88
|
+
)
|
|
89
|
+
expect(document.body.children.length).toBe(before + 1) // wrapper added
|
|
90
|
+
const wrapper = document.body.querySelector('#cleanup-child')!.parentElement!
|
|
91
|
+
expect(wrapper.isConnected).toBe(true)
|
|
92
|
+
|
|
93
|
+
unmount()
|
|
94
|
+
expect(wrapper.isConnected).toBe(false)
|
|
95
|
+
expect(document.body.contains(wrapper)).toBe(false)
|
|
96
|
+
|
|
97
|
+
root.remove()
|
|
48
98
|
})
|
|
49
99
|
|
|
50
|
-
it('
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
100
|
+
it('removes the wrapper from a custom DOMLocation on unmount', () => {
|
|
101
|
+
const root = document.createElement('div')
|
|
102
|
+
const customTarget = document.createElement('div')
|
|
103
|
+
document.body.appendChild(root)
|
|
104
|
+
document.body.appendChild(customTarget)
|
|
105
|
+
|
|
106
|
+
const unmount = mount(
|
|
107
|
+
h(Portal, { DOMLocation: customTarget, children: h('span', { id: 'cu' }, 'x') }),
|
|
108
|
+
root,
|
|
109
|
+
)
|
|
110
|
+
expect(customTarget.children.length).toBe(1)
|
|
111
|
+
|
|
112
|
+
unmount()
|
|
113
|
+
expect(customTarget.children.length).toBe(0)
|
|
114
|
+
|
|
115
|
+
root.remove()
|
|
116
|
+
customTarget.remove()
|
|
57
117
|
})
|
|
58
|
-
})
|
|
59
118
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
119
|
+
it('isolates per-instance wrappers when multiple Portals share a DOMLocation', () => {
|
|
120
|
+
const root = document.createElement('div')
|
|
121
|
+
document.body.appendChild(root)
|
|
122
|
+
|
|
123
|
+
const u1 = mount(h(Portal, { children: h('span', { id: 'p1' }, 'A') }), root)
|
|
124
|
+
const u2 = mount(h(Portal, { children: h('span', { id: 'p2' }, 'B') }), root)
|
|
125
|
+
|
|
126
|
+
const w1 = document.body.querySelector('#p1')!.parentElement!
|
|
127
|
+
const w2 = document.body.querySelector('#p2')!.parentElement!
|
|
128
|
+
expect(w1).not.toBe(w2)
|
|
129
|
+
expect(w1.parentElement).toBe(document.body)
|
|
130
|
+
expect(w2.parentElement).toBe(document.body)
|
|
131
|
+
|
|
132
|
+
u1()
|
|
133
|
+
// unmounting one Portal removes only its wrapper, not the sibling's
|
|
134
|
+
expect(w1.isConnected).toBe(false)
|
|
135
|
+
expect(w2.isConnected).toBe(true)
|
|
136
|
+
|
|
137
|
+
u2()
|
|
138
|
+
expect(w2.isConnected).toBe(false)
|
|
139
|
+
root.remove()
|
|
66
140
|
})
|
|
67
141
|
})
|
|
68
142
|
|