@pyreon/elements 0.22.0 → 0.23.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/README.md +176 -147
- package/lib/index.js +2 -1
- package/package.json +11 -11
- package/src/__tests__/iterator-function-children.test.tsx +120 -0
- package/src/helpers/Iterator/component.tsx +14 -1
package/README.md
CHANGED
|
@@ -1,196 +1,225 @@
|
|
|
1
1
|
# @pyreon/elements
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Five foundational layout primitives — Element, Text, List, Overlay, Portal — plus an Iterator helper.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`@pyreon/elements` is the layer between `@pyreon/styler`/`@pyreon/unistyle` and the high-level UI components. Every layout prop is responsive (single value, mobile-first array, or breakpoint object). `Element` is a three-section flex container (`beforeContent` / `content` / `afterContent`) with an internal fast path that collapses one wrapper layer when only `content` is present — measured 31-45% faster across mount benchmarks. `Overlay` ships a full `useOverlay` hook handling open/close, viewport flipping, ESC, click-outside, scroll tracking, hover delay, and modal overflow-locking — no positioning logic to reinvent. `Iterator` and `List` cover data-driven children with positional metadata; `Portal` renders into an isolated wrapper inside a configurable DOM location.
|
|
6
6
|
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
- **Element** — three-section flex layout (beforeContent / content / afterContent)
|
|
10
|
-
- **Text** — semantic text rendering with auto paragraph wrapping
|
|
11
|
-
- **List** — data-driven rendering with positional metadata (first, last, odd, even)
|
|
12
|
-
- **Overlay** — headless trigger+content pattern
|
|
13
|
-
- **Portal** — stub (runtime-dom provides actual portal)
|
|
14
|
-
- **Responsive everything** — single value, array, or breakpoint object on every layout prop
|
|
15
|
-
- **Equal before/after** — `equalBeforeAfter` prop on Element to equalize slot dimensions
|
|
16
|
-
|
|
17
|
-
## Installation
|
|
7
|
+
## Install
|
|
18
8
|
|
|
19
9
|
```bash
|
|
20
|
-
bun add @pyreon/elements
|
|
10
|
+
bun add @pyreon/elements @pyreon/core @pyreon/reactivity @pyreon/ui-core @pyreon/unistyle
|
|
21
11
|
```
|
|
22
12
|
|
|
23
|
-
##
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
})
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { Element, Text, List, Overlay, Portal, Provider } from '@pyreon/elements'
|
|
17
|
+
|
|
18
|
+
<Provider>
|
|
19
|
+
<Element
|
|
20
|
+
tag="button"
|
|
21
|
+
direction="inline"
|
|
22
|
+
alignX="center"
|
|
23
|
+
alignY="center"
|
|
24
|
+
gap={8}
|
|
25
|
+
beforeContent={<Icon name="star" />}
|
|
26
|
+
afterContent={<Icon name="chevron-right" />}
|
|
27
|
+
>
|
|
28
|
+
Click me
|
|
29
|
+
</Element>
|
|
30
|
+
</Provider>
|
|
42
31
|
```
|
|
43
32
|
|
|
44
|
-
|
|
33
|
+
`Provider` is re-exported from `@pyreon/unistyle` — set it once near the app root to scope breakpoints, root-size, and theme defaults.
|
|
34
|
+
|
|
35
|
+
## `Element` — three-section flex layout
|
|
36
|
+
|
|
37
|
+
Most-used primitive. Renders an outer container with optional `beforeContent` / `afterContent` slots flanking the main `content` (children).
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
<Element
|
|
41
|
+
tag="button"
|
|
42
|
+
direction="inline" // 'inline' | 'rows' | 'reverseInline' | 'reverseRows'
|
|
43
|
+
alignX="center" // 'left' | 'center' | 'right' | 'spaceBetween' | ...
|
|
44
|
+
alignY="center" // 'top' | 'center' | 'bottom' | 'stretch' | ...
|
|
45
|
+
gap={8}
|
|
46
|
+
block // flex vs inline-flex
|
|
47
|
+
equalCols // equalize before/after widths via ResizeObserver
|
|
48
|
+
equalBeforeAfter
|
|
49
|
+
beforeContent={<Icon />}
|
|
50
|
+
afterContent={<Icon />}
|
|
51
|
+
>
|
|
52
|
+
Action
|
|
53
|
+
</Element>
|
|
54
|
+
```
|
|
45
55
|
|
|
46
|
-
**Content
|
|
56
|
+
**Content slots** (priority: `children` > `content` > `label`):
|
|
47
57
|
|
|
48
|
-
| Prop
|
|
49
|
-
|
|
50
|
-
| children
|
|
51
|
-
| content
|
|
52
|
-
| label
|
|
53
|
-
| beforeContent | `
|
|
54
|
-
| afterContent
|
|
58
|
+
| Prop | Type | Notes |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `children` | `VNodeChild` | Standard JSX children |
|
|
61
|
+
| `content` | `VNodeChild` | Alternative slot when `children` is awkward |
|
|
62
|
+
| `label` | `VNodeChild` | Third fallback; useful in data-driven `List` |
|
|
63
|
+
| `beforeContent` | `VNodeChild` | Rendered before the main slot |
|
|
64
|
+
| `afterContent` | `VNodeChild` | Rendered after the main slot |
|
|
55
65
|
|
|
56
66
|
**Layout props** (all responsive):
|
|
57
67
|
|
|
58
|
-
| Prop
|
|
59
|
-
|
|
60
|
-
| tag
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
| equalCols |
|
|
68
|
+
| Prop | Default | Description |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `tag` | `'div'` | Outer HTML tag |
|
|
71
|
+
| `direction` | `'inline'` | `'inline'` (row) / `'rows'` (column) / `'reverseInline'` / `'reverseRows'` |
|
|
72
|
+
| `alignX` | `'left'` | Horizontal alignment along the flex direction |
|
|
73
|
+
| `alignY` | `'center'` | Cross-axis alignment |
|
|
74
|
+
| `gap` | — | Gap between sections |
|
|
75
|
+
| `block` | — | `flex` vs `inline-flex` |
|
|
76
|
+
| `equalCols` | — | Equal width for before/after columns (snapshot at mount) |
|
|
77
|
+
| `equalBeforeAfter` | — | Equalize before/after via live `ResizeObserver` (resilient to async font/content changes) |
|
|
78
|
+
| `dangerouslySetInnerHTML` | — | Forwards to `runtime-dom` / `runtime-server` |
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
Per-section overrides: `contentDirection`, `contentAlignX`, `beforeContentAlignY`, `afterContentDirection`, etc. — every section accepts the same axis props prefixed with the section name.
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
Element({
|
|
72
|
-
contentDirection: 'rows',
|
|
73
|
-
contentAlignX: 'center',
|
|
74
|
-
beforeContentAlignY: 'top',
|
|
75
|
-
afterContentDirection: 'inline',
|
|
76
|
-
})
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
### Text
|
|
80
|
-
|
|
81
|
-
Semantic text component with optional paragraph auto-wrapping.
|
|
82
|
+
**Simple-path fast path**: when there's no `beforeContent` / `afterContent` and the tag doesn't need the button/fieldset/legend two-layer flex fix, Element inlines the wrapper helper into ONE styled invocation. Saves one component invocation + one `splitProps` + one `mountChild` per Element. Real-Chromium benchmark drops a 500-child mount from 2.9ms to 1.6ms (-45%).
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
import { Text } from '@pyreon/elements'
|
|
84
|
+
## `Text` — semantic typography
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
Text
|
|
88
|
-
Text
|
|
86
|
+
```tsx
|
|
87
|
+
<Text tag="h1">Heading</Text>
|
|
88
|
+
<Text paragraph>This renders as a <p>.</Text>
|
|
89
|
+
<Text tag="strong">Bold</Text>
|
|
89
90
|
```
|
|
90
91
|
|
|
91
|
-
| Prop
|
|
92
|
-
|
|
93
|
-
| tag
|
|
94
|
-
| paragraph
|
|
95
|
-
| children / label | `
|
|
96
|
-
| css
|
|
97
|
-
|
|
98
|
-
### List
|
|
92
|
+
| Prop | Type | Notes |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `tag` | `'h1'`-`'h6'` / `'p'` / `'span'` / `'strong'` / `'em'` / `'small'` / … | Inline-by-default |
|
|
95
|
+
| `paragraph` | `boolean` | Shorthand for `tag="p"` |
|
|
96
|
+
| `children` / `label` | `VNodeChild` | Text content |
|
|
97
|
+
| `css` | `ExtendCss` | Extend styling |
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
import { List, Element } from '@pyreon/elements'
|
|
99
|
+
## `List` — data-driven children with positional metadata
|
|
104
100
|
|
|
105
|
-
|
|
106
|
-
List
|
|
107
|
-
component
|
|
108
|
-
data
|
|
109
|
-
valueName: 'label',
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
// Object data with positional metadata
|
|
113
|
-
List({
|
|
114
|
-
component: ListItem,
|
|
115
|
-
data: [
|
|
101
|
+
```tsx
|
|
102
|
+
<List
|
|
103
|
+
component={ListItem}
|
|
104
|
+
data={[
|
|
116
105
|
{ id: 1, name: 'Alice' },
|
|
117
106
|
{ id: 2, name: 'Bob' },
|
|
118
|
-
]
|
|
119
|
-
itemKey
|
|
120
|
-
itemProps
|
|
107
|
+
]}
|
|
108
|
+
itemKey="id"
|
|
109
|
+
itemProps={(item, { first, last, odd, even, index }) => ({
|
|
121
110
|
highlighted: first,
|
|
122
111
|
separator: !last,
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// With root Element wrapper
|
|
127
|
-
List
|
|
128
|
-
rootElement
|
|
129
|
-
direction
|
|
130
|
-
gap
|
|
131
|
-
component
|
|
132
|
-
data
|
|
133
|
-
|
|
112
|
+
})}
|
|
113
|
+
/>
|
|
114
|
+
|
|
115
|
+
// With root Element wrapper — gap/direction/align take effect
|
|
116
|
+
<List
|
|
117
|
+
rootElement
|
|
118
|
+
direction="rows"
|
|
119
|
+
gap={8}
|
|
120
|
+
component={Card}
|
|
121
|
+
data={items}
|
|
122
|
+
/>
|
|
134
123
|
```
|
|
135
124
|
|
|
136
|
-
| Prop
|
|
137
|
-
|
|
138
|
-
| data
|
|
139
|
-
| component
|
|
140
|
-
| valueName
|
|
141
|
-
| itemKey
|
|
142
|
-
| itemProps
|
|
143
|
-
| wrapComponent | `ComponentFn`
|
|
144
|
-
| rootElement
|
|
125
|
+
| Prop | Type | Notes |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `data` | `Array<string \| number \| object>` | Source data |
|
|
128
|
+
| `component` | `ComponentFn` | Renders per item |
|
|
129
|
+
| `valueName` | `string` | Prop name for scalar values (default `'children'`) |
|
|
130
|
+
| `itemKey` | `string \| (item) => Key` | Key extractor |
|
|
131
|
+
| `itemProps` | `object \| (item, meta) => object` | Extra props injected per item |
|
|
132
|
+
| `wrapComponent` | `ComponentFn` | Wrapper around each item |
|
|
133
|
+
| `rootElement` | `boolean` | Wrap in an `Element` (enables `direction` / `gap` / `align`) |
|
|
134
|
+
|
|
135
|
+
Positional metadata (`{ index, first, last, odd, even, position }`) is passed to both `itemKey` and `itemProps` callbacks.
|
|
136
|
+
|
|
137
|
+
## `Iterator` — lower-level data iterator
|
|
138
|
+
|
|
139
|
+
Same data/component model as `List`, four typed overloads (simple values, object values, children-only, loose forwarding) so spread-pattern wrapping (`<Iterator {...wrapperProps} />`) typechecks. Use when you don't need List's auto-wrap layout — e.g. emitting a flat array of `<option>` elements inside a `<select>`.
|
|
140
|
+
|
|
141
|
+
## `Overlay` + `useOverlay` — dropdowns / tooltips / popovers / modals
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
<Overlay
|
|
145
|
+
openOn="click"
|
|
146
|
+
closeOn="clickOutsideContent"
|
|
147
|
+
type="dropdown"
|
|
148
|
+
align="bottom"
|
|
149
|
+
alignX="left"
|
|
150
|
+
offsetX={0}
|
|
151
|
+
offsetY={4}
|
|
152
|
+
closeOnEsc
|
|
153
|
+
hoverDelay={150}
|
|
154
|
+
trigger={<Button>Open menu</Button>}
|
|
155
|
+
>
|
|
156
|
+
<DropdownMenu />
|
|
157
|
+
</Overlay>
|
|
158
|
+
```
|
|
145
159
|
|
|
146
|
-
|
|
160
|
+
For headless control, use the hook directly:
|
|
147
161
|
|
|
148
|
-
|
|
162
|
+
```tsx
|
|
163
|
+
const overlay = useOverlay({
|
|
164
|
+
openOn: 'click',
|
|
165
|
+
closeOn: 'clickOnTrigger',
|
|
166
|
+
type: 'tooltip',
|
|
167
|
+
align: 'top',
|
|
168
|
+
onOpen: () => track('tooltip-open'),
|
|
169
|
+
})
|
|
170
|
+
// overlay.isOpen / overlay.toggle / overlay.open / overlay.close / overlay.position()
|
|
171
|
+
```
|
|
149
172
|
|
|
150
|
-
|
|
173
|
+
Built-in behaviour:
|
|
174
|
+
- **Viewport-edge flipping** — automatically flips align when the content would overflow.
|
|
175
|
+
- **Throttled positioning** — scroll + resize listeners throttled (default delay 60ms).
|
|
176
|
+
- **ESC + click-outside** — opt-in via `closeOnEsc` / `closeOn: 'clickOutsideContent'`.
|
|
177
|
+
- **Hover delay** — `hoverDelay` debounces both open and close for `openOn: 'hover'`.
|
|
178
|
+
- **Modal overflow lock** — `type: 'modal'` ref-counts `document.body` overflow so nested modals don't double-lock.
|
|
151
179
|
|
|
152
|
-
|
|
180
|
+
`OverlayProvider` + `useOverlayContext` coordinate nested overlays (a parent dropdown can block its children's click-outside).
|
|
153
181
|
|
|
154
|
-
|
|
155
|
-
import { Overlay } from '@pyreon/elements'
|
|
182
|
+
## `Portal` — render into a different DOM location
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
alignX: 'left',
|
|
162
|
-
trigger: Button({ label: 'Open menu' }),
|
|
163
|
-
children: DropdownMenu({}),
|
|
164
|
-
})
|
|
184
|
+
```tsx
|
|
185
|
+
<Portal target={document.body} tag="div" data-modal-id="settings">
|
|
186
|
+
<Modal />
|
|
187
|
+
</Portal>
|
|
165
188
|
```
|
|
166
189
|
|
|
167
|
-
|
|
190
|
+
Creates a per-instance wrapper element (default `<div>`, configurable via `tag`) INSIDE `target` (default `document.body`). Multiple portals share `target` without intermingling children — each gets its own wrapper.
|
|
168
191
|
|
|
169
|
-
|
|
192
|
+
| Prop | Default | Notes |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `target` | `document.body` | Destination element (`HTMLElement \| (() => HTMLElement) \| null`) |
|
|
195
|
+
| `tag` | `'div'` | Wrapper HTML tag |
|
|
196
|
+
| Any data-/aria- attrs | — | Forwarded to the wrapper |
|
|
170
197
|
|
|
171
|
-
##
|
|
198
|
+
## `Util` — utility wrapper for non-layout primitives
|
|
172
199
|
|
|
173
|
-
|
|
200
|
+
Reserved escape-hatch for components that need styler integration without Element's layout props (e.g. SVG roots). Same theme/style pipeline, no axis/gap props.
|
|
201
|
+
|
|
202
|
+
## Responsive values
|
|
174
203
|
|
|
175
204
|
```ts
|
|
176
|
-
//
|
|
177
|
-
|
|
205
|
+
direction="inline" // single value
|
|
206
|
+
direction={['rows', 'inline']} // mobile-first array
|
|
207
|
+
direction={{ xs: 'rows', md: 'inline', lg: 'inline' }} // breakpoint object
|
|
208
|
+
```
|
|
178
209
|
|
|
179
|
-
|
|
180
|
-
Element({ direction: ['rows', 'inline'] })
|
|
210
|
+
Applies to `tag`, `direction`, `alignX`, `alignY`, `gap`, `block`, `equalCols`, and every per-section variant.
|
|
181
211
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
212
|
+
## Gotchas
|
|
213
|
+
|
|
214
|
+
- **Wrapper drops the children slot for void tags** (`<hr>`, `<input>`, `<img>`, `<br>`, etc.) so `{undefined}` JSX slots don't trip runtime-dom's "void element cannot have children" warning. If you author a custom wrapper that forwards `children`, branch on `getShouldBeEmpty(tag)` first.
|
|
215
|
+
- **`<Portal>` creates a per-instance wrapper INSIDE `target`** — `document.body.firstChild` is not your modal; query via `document.body.querySelector('[data-modal-id]').parentElement`.
|
|
216
|
+
- **`equalBeforeAfter` uses `ResizeObserver`** and falls back to a one-shot measurement when the API is unavailable (SSR, older runtimes). For async content (font swaps, lazy images) you want `equalBeforeAfter`, not `equalCols`.
|
|
217
|
+
- **Element's `direction` accepts `'inline' | 'rows' | 'reverseInline' | 'reverseRows'`** — `'row'` is invalid (caught by TS).
|
|
218
|
+
- **`Iterator` ships 4 overloads with a `LooseProps` fallback** so `<Iterator {...wrapperProps} />` forwarding patterns typecheck. The trade-off: mixed-shape arrays (`[1, {id:1}, null]`) bind to the fallback rather than failing at the type level. Runtime still picks the right mode based on which props are populated.
|
|
185
219
|
|
|
186
|
-
##
|
|
220
|
+
## Documentation
|
|
187
221
|
|
|
188
|
-
|
|
189
|
-
| ------------------ | -------- |
|
|
190
|
-
| @pyreon/core | >= 0.0.1 |
|
|
191
|
-
| @pyreon/reactivity | >= 0.0.1 |
|
|
192
|
-
| @pyreon/ui-core | >= 0.0.1 |
|
|
193
|
-
| @pyreon/unistyle | >= 0.0.1 |
|
|
222
|
+
Full docs: [docs.pyreon.dev/docs/elements](https://docs.pyreon.dev/docs/elements) (or `docs/docs/elements.md` in this repo).
|
|
194
223
|
|
|
195
224
|
## License
|
|
196
225
|
|
package/lib/index.js
CHANGED
|
@@ -664,7 +664,8 @@ const attachItemProps = ({ i, length }) => {
|
|
|
664
664
|
};
|
|
665
665
|
};
|
|
666
666
|
const Component$7 = (props) => {
|
|
667
|
-
const { itemKey, valueName, children, component, data, wrapComponent: Wrapper, wrapProps, itemProps } = props;
|
|
667
|
+
const { itemKey, valueName, children: rawChildren, component, data, wrapComponent: Wrapper, wrapProps, itemProps } = props;
|
|
668
|
+
const children = typeof rawChildren === "function" ? rawChildren() : rawChildren;
|
|
668
669
|
const injectItemProps = typeof itemProps === "function" ? itemProps : () => itemProps;
|
|
669
670
|
const injectWrapItemProps = typeof wrapProps === "function" ? wrapProps : () => wrapProps;
|
|
670
671
|
const getKey = (item, index) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/elements",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Foundational UI components for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,22 +42,22 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@pyreon/core": "^0.
|
|
45
|
+
"@pyreon/core": "^0.23.0",
|
|
46
46
|
"@pyreon/manifest": "0.13.1",
|
|
47
|
-
"@pyreon/reactivity": "^0.
|
|
48
|
-
"@pyreon/runtime-dom": "^0.
|
|
49
|
-
"@pyreon/test-utils": "^0.13.
|
|
50
|
-
"@pyreon/typescript": "^0.
|
|
47
|
+
"@pyreon/reactivity": "^0.23.0",
|
|
48
|
+
"@pyreon/runtime-dom": "^0.23.0",
|
|
49
|
+
"@pyreon/test-utils": "^0.13.10",
|
|
50
|
+
"@pyreon/typescript": "^0.23.0",
|
|
51
51
|
"@vitest/browser-playwright": "^4.1.4",
|
|
52
|
-
"@vitus-labs/tools-rolldown": "^2.
|
|
52
|
+
"@vitus-labs/tools-rolldown": "^2.4.0"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">= 22"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@pyreon/core": "^0.
|
|
59
|
-
"@pyreon/reactivity": "^0.
|
|
60
|
-
"@pyreon/ui-core": "^0.
|
|
61
|
-
"@pyreon/unistyle": "^0.
|
|
58
|
+
"@pyreon/core": "^0.23.0",
|
|
59
|
+
"@pyreon/reactivity": "^0.23.0",
|
|
60
|
+
"@pyreon/ui-core": "^0.23.0",
|
|
61
|
+
"@pyreon/unistyle": "^0.23.0"
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** @jsxImportSource @pyreon/core */
|
|
2
|
+
/**
|
|
3
|
+
* Regression: `<Iterator>{items}</Iterator>` where the Pyreon compiler
|
|
4
|
+
* wrapped `items` in `() => items` (the prop-inlining pass) used to
|
|
5
|
+
* silently misrender — the `Array.isArray(children)` and Fragment-type
|
|
6
|
+
* checks both fell through (a function is neither), and the fallthrough
|
|
7
|
+
* `renderChild(function)` called `render(function, props)` which
|
|
8
|
+
* interpreted the function as a COMPONENT FUNCTION. That accidentally
|
|
9
|
+
* worked at the DOM level (the wrapped function's call returned the array
|
|
10
|
+
* and mountChild rendered it), but the per-item metadata (`first`/`last`/
|
|
11
|
+
* `position`/`index`/`odd`/`even`) was LOST because the iteration loop
|
|
12
|
+
* was never reached.
|
|
13
|
+
*
|
|
14
|
+
* Fix: unwrap eagerly at component-body entry — `typeof children ===
|
|
15
|
+
* 'function' ? children() : children`. Mirrors kinetic's `resolveChildren`
|
|
16
|
+
* pattern (PR #731 + top-level Transition/Stagger parallel fix).
|
|
17
|
+
*
|
|
18
|
+
* Bisect-verified: reverting the `typeof rawChildren === 'function'`
|
|
19
|
+
* unwrap fails this spec — per-item `first`/`last` props arrive as
|
|
20
|
+
* `undefined` because the iteration loop was skipped.
|
|
21
|
+
*/
|
|
22
|
+
import type { VNode, VNodeChild } from '@pyreon/core'
|
|
23
|
+
import { h } from '@pyreon/core'
|
|
24
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
25
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
26
|
+
import Iterator from '../helpers/Iterator/component'
|
|
27
|
+
|
|
28
|
+
let containers: HTMLElement[] = []
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
for (const c of containers) c.remove()
|
|
31
|
+
containers = []
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('<Iterator> — function-wrapped children', () => {
|
|
35
|
+
it('iterates function-wrapped children with per-item metadata via wrapProps', () => {
|
|
36
|
+
let firstFlagSet = false
|
|
37
|
+
let lastFlagSet = false
|
|
38
|
+
const positions: number[] = []
|
|
39
|
+
|
|
40
|
+
// ItemWrapper just renders <li>. The per-item metadata is captured
|
|
41
|
+
// via the `wrapProps` injector — Iterator calls it with the
|
|
42
|
+
// attached metadata `{first, last, position, index, odd, even}`.
|
|
43
|
+
// Counting positions/flags proves the iteration loop fired.
|
|
44
|
+
const ItemWrapper = (props: {
|
|
45
|
+
children?: VNodeChild
|
|
46
|
+
'data-first'?: string
|
|
47
|
+
'data-last'?: string
|
|
48
|
+
'data-position'?: number
|
|
49
|
+
}) => h('li', props as never, props.children as never)
|
|
50
|
+
|
|
51
|
+
const items: VNode[] = [
|
|
52
|
+
h('span', { 'data-id': 'item-a' }, 'A'),
|
|
53
|
+
h('span', { 'data-id': 'item-b' }, 'B'),
|
|
54
|
+
h('span', { 'data-id': 'item-c' }, 'C'),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
// Compiler-emitted shape: children is `() => items` (the prop-inlining
|
|
58
|
+
// wrap for stable references). Pre-fix: the function was treated as a
|
|
59
|
+
// component → iteration loop skipped → per-item metadata not attached.
|
|
60
|
+
const tree = h(Iterator, {
|
|
61
|
+
wrapComponent: ItemWrapper,
|
|
62
|
+
// wrapProps receives `(itemProps, extendedProps)` where
|
|
63
|
+
// extendedProps carries the per-item metadata. Captured here to
|
|
64
|
+
// prove the iteration loop fired (which the fallthrough wouldn't).
|
|
65
|
+
wrapProps: (_: object, ext: { first: boolean; last: boolean; position: number }) => {
|
|
66
|
+
positions.push(ext.position)
|
|
67
|
+
if (ext.first) firstFlagSet = true
|
|
68
|
+
if (ext.last) lastFlagSet = true
|
|
69
|
+
return {}
|
|
70
|
+
},
|
|
71
|
+
children: (() => items) as unknown as VNodeChild,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const container = document.createElement('div')
|
|
75
|
+
document.body.appendChild(container)
|
|
76
|
+
containers.push(container)
|
|
77
|
+
|
|
78
|
+
const dispose = mount(tree as VNode, container)
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
positions,
|
|
82
|
+
`wrapProps was called for positions=${JSON.stringify(positions)}; ` +
|
|
83
|
+
`expected [1,2,3] (per-item iteration loop must fire). ` +
|
|
84
|
+
`html=${container.innerHTML.slice(0, 400)}`,
|
|
85
|
+
).toEqual([1, 2, 3])
|
|
86
|
+
expect(firstFlagSet, 'first=true metadata must reach wrapProps').toBe(true)
|
|
87
|
+
expect(lastFlagSet, 'last=true metadata must reach wrapProps').toBe(true)
|
|
88
|
+
|
|
89
|
+
expect(container.querySelector('[data-id="item-a"]')?.tagName).toBe('SPAN')
|
|
90
|
+
expect(container.querySelector('[data-id="item-b"]')?.tagName).toBe('SPAN')
|
|
91
|
+
expect(container.querySelector('[data-id="item-c"]')?.tagName).toBe('SPAN')
|
|
92
|
+
|
|
93
|
+
dispose()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('static-array children control — was always working', () => {
|
|
97
|
+
let renderedItems = 0
|
|
98
|
+
const ItemWrapper = (props: { children?: VNodeChild; position?: number }) => {
|
|
99
|
+
renderedItems++
|
|
100
|
+
return h('li', { 'data-position': props.position }, props.children as never)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tree = h(
|
|
104
|
+
Iterator,
|
|
105
|
+
{ wrapComponent: ItemWrapper },
|
|
106
|
+
h('span', { 'data-id': 'static-a' }, 'A'),
|
|
107
|
+
h('span', { 'data-id': 'static-b' }, 'B'),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const container = document.createElement('div')
|
|
111
|
+
document.body.appendChild(container)
|
|
112
|
+
containers.push(container)
|
|
113
|
+
const dispose = mount(tree as VNode, container)
|
|
114
|
+
|
|
115
|
+
expect(renderedItems).toBe(2)
|
|
116
|
+
expect(container.querySelector('[data-id="static-a"]')?.tagName).toBe('SPAN')
|
|
117
|
+
|
|
118
|
+
dispose()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -82,7 +82,7 @@ const Component = (props: LooseProps) => {
|
|
|
82
82
|
const {
|
|
83
83
|
itemKey,
|
|
84
84
|
valueName,
|
|
85
|
-
children,
|
|
85
|
+
children: rawChildren,
|
|
86
86
|
component,
|
|
87
87
|
data,
|
|
88
88
|
wrapComponent: Wrapper,
|
|
@@ -90,6 +90,19 @@ const Component = (props: LooseProps) => {
|
|
|
90
90
|
itemProps,
|
|
91
91
|
} = props
|
|
92
92
|
|
|
93
|
+
// Unwrap the Pyreon compiler's `() => x` accessor wrap. When the parent
|
|
94
|
+
// emits `<Iterator>{items}</Iterator>` and the compiler-emitted form is
|
|
95
|
+
// `Iterator({ children: () => items })`, the downstream `Array.isArray`
|
|
96
|
+
// check returns false, the Fragment check returns false (function is not
|
|
97
|
+
// an object), and the fallthrough `renderChild(function)` calls
|
|
98
|
+
// `render(function, props)` which interprets the function as a component
|
|
99
|
+
// function — wrong shape, lost per-item metadata. Resolving eagerly here
|
|
100
|
+
// keeps every downstream branch correct. Mirrors the kinetic Stagger /
|
|
101
|
+
// TransitionItem fix (PR #731 + parallel top-level fixes).
|
|
102
|
+
const children = typeof rawChildren === 'function'
|
|
103
|
+
? (rawChildren as () => VNodeChild)()
|
|
104
|
+
: rawChildren
|
|
105
|
+
|
|
93
106
|
const injectItemProps = typeof itemProps === 'function' ? itemProps : () => itemProps
|
|
94
107
|
|
|
95
108
|
const injectWrapItemProps = typeof wrapProps === 'function' ? wrapProps : () => wrapProps
|