@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 CHANGED
@@ -1,196 +1,225 @@
1
1
  # @pyreon/elements
2
2
 
3
- Foundational UI components for Pyreon with responsive props.
3
+ Five foundational layout primitives Element, Text, List, Overlay, Portal — plus an Iterator helper.
4
4
 
5
- Five composable components for building buttons, cards, lists, dropdowns, tooltips, and modals. Every layout prop is responsive — pass a single value, a mobile-first array, or a breakpoint object.
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
- ## Features
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
- ## Components
24
-
25
- ### Element
26
-
27
- The core layout primitive. Renders a three-section flex container with optional beforeContent and afterContent slots around the main content.
28
-
29
- ```ts
30
- import { Element } from '@pyreon/elements'
31
-
32
- Element({
33
- tag: 'button',
34
- beforeContent: Icon({ name: 'star' }),
35
- afterContent: Icon({ name: 'chevron-right' }),
36
- direction: 'inline',
37
- alignX: 'center',
38
- alignY: 'center',
39
- gap: 8,
40
- children: 'Click me',
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
- When only content is present (no beforeContent/afterContent), Element optimizes by skipping the inner wrapper layer.
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 props** (rendered in priority order: children > content > label):
56
+ **Content slots** (priority: `children` > `content` > `label`):
47
57
 
48
- | Prop | Type | Description |
49
- | ------------- | ------- | ------------------------------------- |
50
- | children | `VNode` | Standard children |
51
- | content | `VNode` | Alternative to children |
52
- | label | `VNode` | Alternative to children/content |
53
- | beforeContent | `VNode` | Content rendered before the main slot |
54
- | afterContent | `VNode` | Content rendered after the main slot |
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 | Type | Default | Description |
59
- | --------- | ----------- | ---------- | -------------------------------------------------------------- |
60
- | tag | `HTMLTags` | `'div'` | HTML element tag |
61
- | block | `boolean` | | `flex` vs `inline-flex` |
62
- | direction | `Direction` | `'inline'` | `'inline'` \| `'rows'` \| `'reverseInline'` \| `'reverseRows'` |
63
- | alignX | `AlignX` | `'left'` | Horizontal alignment |
64
- | alignY | `AlignY` | `'center'` | Vertical alignment |
65
- | gap | `number` | — | Gap between content sections |
66
- | equalCols | `boolean` | | Equal width/height for before/after |
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
- Each section (content, beforeContent, afterContent) has its own direction, alignX, and alignY props prefixed with the section name:
80
+ Per-section overrides: `contentDirection`, `contentAlignX`, `beforeContentAlignY`, `afterContentDirection`, etc. every section accepts the same axis props prefixed with the section name.
69
81
 
70
- ```ts
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
- ```ts
84
- import { Text } from '@pyreon/elements'
84
+ ## `Text` — semantic typography
85
85
 
86
- Text({ tag: 'h1', children: 'Heading' })
87
- Text({ paragraph: true, children: 'This renders as a p tag.' })
88
- Text({ tag: 'strong', label: 'Bold 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 | Type | Description |
92
- | ---------------- | -------------- | -------------------------------------------------------- |
93
- | tag | `HTMLTextTags` | `'h1'`–`'h6'`, `'p'`, `'span'`, `'strong'`, `'em'`, etc. |
94
- | paragraph | `boolean` | Shorthand for `tag="p"` |
95
- | children / label | `VNode` | Text content |
96
- | css | `ExtendCss` | Extend styling |
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
- Data-driven list renderer with positional metadata.
101
-
102
- ```ts
103
- import { List, Element } from '@pyreon/elements'
99
+ ## `List` — data-driven children with positional metadata
104
100
 
105
- // Simple string data
106
- List({
107
- component: Element,
108
- data: ['Apple', 'Banana', 'Cherry'],
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: 'id',
120
- itemProps: (item, { first, last, odd, even, index }) => ({
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: true,
129
- direction: 'rows',
130
- gap: 8,
131
- component: Card,
132
- data: items,
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 | Type | Description |
137
- | ------------- | -------------------- | ------------------------------------------------------ |
138
- | data | `Array` | Array of strings, numbers, or objects |
139
- | component | `ComponentFn` | Component to render for each item |
140
- | valueName | `string` | Prop name for scalar values (default: `'children'`) |
141
- | itemKey | `string \| function` | Key extraction for list items |
142
- | itemProps | `object \| function` | Extra props injected into each item |
143
- | wrapComponent | `ComponentFn` | Wrapper around each item |
144
- | rootElement | `boolean` | Wrap list in an Element (enables direction, gap, etc.) |
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
- **Positional metadata** passed to `itemProps` callback:
160
+ For headless control, use the hook directly:
147
161
 
148
- `index`, `first`, `last`, `odd`, `even`, `position` (1-based)
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
- ### Overlay
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
- Headless trigger+content pattern for dropdowns, tooltips, and modals.
180
+ `OverlayProvider` + `useOverlayContext` coordinate nested overlays (a parent dropdown can block its children's click-outside).
153
181
 
154
- ```ts
155
- import { Overlay } from '@pyreon/elements'
182
+ ## `Portal` — render into a different DOM location
156
183
 
157
- Overlay({
158
- openOn: 'click',
159
- closeOn: 'clickOutsideContent',
160
- align: 'bottom',
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
- ### Portal
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
- Stub component the actual portal implementation is provided by `@pyreon/core`'s runtime-dom.
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
- ## Responsive Values
198
+ ## `Util` — utility wrapper for non-layout primitives
172
199
 
173
- Every layout prop (direction, alignX, alignY, gap, block, equalCols) supports three formats:
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
- // Single value — all breakpoints
177
- Element({ direction: 'inline' })
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
- // Array mobile-first, maps to breakpoints by position
180
- Element({ direction: ['rows', 'inline'] })
210
+ Applies to `tag`, `direction`, `alignX`, `alignY`, `gap`, `block`, `equalCols`, and every per-section variant.
181
211
 
182
- // Object — explicit breakpoints
183
- Element({ direction: { xs: 'rows', md: 'inline', lg: 'inline' } })
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
- ## Peer Dependencies
220
+ ## Documentation
187
221
 
188
- | Package | Version |
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.22.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.22.0",
45
+ "@pyreon/core": "^0.23.0",
46
46
  "@pyreon/manifest": "0.13.1",
47
- "@pyreon/reactivity": "^0.22.0",
48
- "@pyreon/runtime-dom": "^0.22.0",
49
- "@pyreon/test-utils": "^0.13.9",
50
- "@pyreon/typescript": "^0.22.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.3.0"
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.22.0",
59
- "@pyreon/reactivity": "^0.22.0",
60
- "@pyreon/ui-core": "^0.22.0",
61
- "@pyreon/unistyle": "^0.22.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