@symbiote-native/components 1.0.0 → 2.0.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.
Files changed (83) hide show
  1. package/README.md +3 -4
  2. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  3. package/build/behaviors/activity-indicator/index.android.js +16 -0
  4. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  5. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  6. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  7. package/build/behaviors/activity-indicator/index.js +5 -0
  8. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  9. package/build/behaviors/activity-indicator/shared.js +149 -0
  10. package/build/behaviors/button.d.ts +2 -0
  11. package/build/behaviors/button.js +328 -0
  12. package/build/behaviors/image-background.d.ts +2 -0
  13. package/build/behaviors/image-background.js +139 -0
  14. package/build/behaviors/image.d.ts +1 -1
  15. package/build/behaviors/image.js +2 -2
  16. package/build/behaviors/input-accessory-view.d.ts +1 -1
  17. package/build/behaviors/input-accessory-view.js +2 -2
  18. package/build/behaviors/pressable.d.ts +59 -1
  19. package/build/behaviors/pressable.js +93 -18
  20. package/build/behaviors/refresh-control.d.ts +2 -0
  21. package/build/behaviors/refresh-control.js +83 -0
  22. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  23. package/build/behaviors/scroll-view/index.android.js +52 -0
  24. package/build/behaviors/scroll-view/index.d.ts +2 -0
  25. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  26. package/build/behaviors/scroll-view/index.ios.js +10 -0
  27. package/build/behaviors/scroll-view/index.js +5 -0
  28. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  29. package/build/behaviors/scroll-view/shared.js +291 -0
  30. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  31. package/build/behaviors/scroll-view/sticky.js +568 -0
  32. package/build/behaviors/switch.d.ts +1 -1
  33. package/build/behaviors/switch.js +9 -5
  34. package/build/behaviors/text-input.d.ts +2 -2
  35. package/build/behaviors/text-input.js +43 -15
  36. package/build/behaviors/touchable-highlight.d.ts +9 -0
  37. package/build/behaviors/touchable-highlight.js +192 -0
  38. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  39. package/build/behaviors/touchable-native-feedback.js +333 -0
  40. package/build/behaviors/touchable-opacity.d.ts +12 -0
  41. package/build/behaviors/touchable-opacity.js +227 -0
  42. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  43. package/build/behaviors/touchable-without-feedback.js +296 -0
  44. package/build/component-names/index.android.js +29 -19
  45. package/build/component-names/index.ios.js +31 -19
  46. package/build/component-names/shared.d.ts +2 -1
  47. package/build/component-names/shared.js +16 -6
  48. package/build/descriptor.js +4 -4
  49. package/build/fold-host-bag.js +2 -2
  50. package/build/index.d.ts +17 -11
  51. package/build/index.js +44 -8
  52. package/build/register.d.ts +1 -0
  53. package/build/register.js +55 -0
  54. package/build/scroll-view-commands.d.ts +4 -0
  55. package/build/scroll-view-commands.js +30 -31
  56. package/build/state/text-input.d.ts +4 -1
  57. package/build/view/render-button.d.ts +36 -1
  58. package/build/view/render-button.js +101 -12
  59. package/build/view/render-image/index.js +2 -2
  60. package/build/view/render-input-accessory-view.js +1 -1
  61. package/build/view/render-modal.js +3 -3
  62. package/build/view/render-pressable/index.d.ts +2 -0
  63. package/build/view/render-pressable/index.js +24 -0
  64. package/build/view/render-scroll-view.d.ts +1 -0
  65. package/build/view/render-scroll-view.js +18 -9
  66. package/build/view/render-switch.d.ts +4 -1
  67. package/build/view/render-switch.js +2 -2
  68. package/build/view/render-text-input.js +2 -2
  69. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  70. package/build/view/render-touchable-native-feedback.js +19 -0
  71. package/host-primitives.cjs +202 -150
  72. package/host-primitives.d.cts +0 -2
  73. package/package.json +8 -17
  74. package/build/state-style.d.ts +0 -15
  75. package/build/state-style.js +0 -47
  76. package/build/view/render-activity-indicator.d.ts +0 -25
  77. package/build/view/render-activity-indicator.js +0 -88
  78. package/build/view/render-image-background.d.ts +0 -9
  79. package/build/view/render-image-background.js +0 -48
  80. package/lowering-fixtures.cjs +0 -259
  81. package/lowering-fixtures.d.cts +0 -17
  82. package/specialize-state-style.cjs +0 -219
  83. package/specialize-state-style.d.cts +0 -15
@@ -0,0 +1,568 @@
1
+ // Sticky headers on the lowered path. BOTH forms live here — the CHILD form (`<sticky-header>`, the
2
+ // path our own lists use) and the INDEX form (`stickyHeaderIndices`, RN's public API) — plus the
3
+ // owner-side half that feeds them.
4
+ //
5
+ // WHY A CHILD AT ALL. `stickyHeaderIndices` is an index list because JSX has no way to MARK an
6
+ // element — RN walks its own children array and wraps the flagged ones. `<StickyHeader>` says the
7
+ // same thing in the one place a lowered element can read without an index: the tag of a node that
8
+ // is already in the tree.
9
+ //
10
+ // THE INDEX FORM IS BUILT (2026-09-07), and this header said it was impossible until then. Both
11
+ // halves of that claim were false, measured against Angular's projection controller, which already
12
+ // resolves the same indices on engine nodes:
13
+ //
14
+ // "no children array to walk" `afterCommit` sees `owner.childHost.children` complete, on
15
+ // every commit that made a native call — and a child add or
16
+ // remove always does. The index basis is recoverable the way
17
+ // `reconcileStickyRecords` recovers it: a paint index that skips
18
+ // anchors.
19
+ // "no render to wrap anything in" a behavior builds nodes with the ordinary mutation API.
20
+ // `wrapForIndex` creates a node, inserts it at the slot and
21
+ // `appendChild`s the child into it — the engine's appendChild
22
+ // detaches from the old parent, so no removal is needed.
23
+ //
24
+ // The wrap is what must NOT be skipped. Writing the pin straight onto the flagged child is the
25
+ // tempting shortcut and it destroys the child's own `transform`: `fabricProps.addStyle` hoists
26
+ // style keys into one payload and later entries WIN, so a pin composed over an app's
27
+ // `transform: [{scale}]` replaces it rather than composing. RN's two nested views compose.
28
+ //
29
+ // WHAT RUNS WHERE. The DECISIONS are `reduceSticky` (`../../state/sticky-header-reducer`), shared
30
+ // with every adapter's own sticky component and untouched here. This module is one more EFFECT
31
+ // RUNNER for it — the fourth, after React/Vue/Svelte's components and Angular's projection
32
+ // controller — and the only one that runs on an engine node with no framework above it. The
33
+ // runner's shape is Angular's (`adapters/angular/src/components/scroll-view/projection.ts`),
34
+ // because that one already drives engine nodes; what changes is where the cross-talk comes from.
35
+ //
36
+ // CROSS-TALK WITHOUT INDICES. Each header is fed the y of the NEXT sticky header, which is the
37
+ // collision point it gets pushed off at. Every existing runner reads that out of an index map
38
+ // (`nextStickyHeaderY(childIndex)`), because indices are what it has. Here the owner keeps its
39
+ // headers in DOCUMENT order and the next one is the next entry — no index, and nothing to renumber
40
+ // when a list windows.
41
+ //
42
+ // THE OWNER HALF, and it is why this file holds both. Three of the scroll view's own props are
43
+ // functions of "does this ScrollView have sticky headers", which only a registration can answer:
44
+ // `scrollEventThrottle` (RN raises it so the offset reaches the AnimatedValue at all), the scroll
45
+ // listener that drives that value, and — inverted only — the viewport height the pin math needs.
46
+ // A separate module would have to export a registry back and forth.
47
+ import { AnimatedProps, AnimatedValue, appendChild, appListenerFor, createElement, dlog, insertBefore, isAnchor, isNativeAnimatedAvailable, markPropsDirty, Platform, removeChild, requestCommitFor, setBehaviorListener, setProp, whenCommitted, } from '@symbiote-native/engine';
48
+ import { descriptorFor } from '../../component-names';
49
+ import { attachStickyScroll } from '../../scroll-view-commands.js';
50
+ import { createInitialStickyState, reduceSticky, } from '../../state/sticky-header-reducer.js';
51
+ import { readLayoutNumber, STICKY_HEADER_Z_INDEX, } from '../../view/render-scroll-sticky.js';
52
+ import { resolveScrollForwarding } from '../../view/render-scroll-view.js';
53
+ export const STICKY_HEADER_TAG = 'sticky-header';
54
+ // The scroll views that could own a header, so a header can find its own by walking up. The tag is
55
+ // not on the node (`createElement` looks the behavior up and stores nothing), and the parent chain
56
+ // is the only route — a header may sit any depth below the content view.
57
+ const scrollOwners = new WeakSet();
58
+ const stickyOwners = new WeakMap();
59
+ const headerRuntimes = new WeakMap();
60
+ // ---------------------------------------------------------------- the owner half
61
+ export function markScrollOwner(node) {
62
+ scrollOwners.add(node);
63
+ }
64
+ export function hasStickyHeaders(owner) {
65
+ const sticky = stickyOwners.get(owner);
66
+ return sticky !== undefined && sticky.members.size > 0;
67
+ }
68
+ // Only the INVERTED pin reads the viewport height (`computeStickyInterpolation` ignores it
69
+ // otherwise), which is exactly when RN wraps the scroll view's own onLayout — so the gate flag
70
+ // lands on the same ScrollViews the wrapper puts it on and on no others.
71
+ function needsViewportHeight(owner) {
72
+ return hasStickyHeaders(owner) && owner.props.invertStickyHeaders === true;
73
+ }
74
+ function ownerSticky(owner) {
75
+ const existing = stickyOwners.get(owner);
76
+ if (existing !== undefined)
77
+ return existing;
78
+ const created = {
79
+ scrollValue: new AnimatedValue(0),
80
+ members: new Set(),
81
+ ordered: undefined,
82
+ layoutYs: new Map(),
83
+ viewportHeight: undefined,
84
+ writtenThrottle: undefined,
85
+ detachNativeScroll: undefined,
86
+ };
87
+ stickyOwners.set(owner, created);
88
+ return created;
89
+ }
90
+ // Put the scroll offset on the UI THREAD, which is what every wrapper's ScrollView already does
91
+ // (`useNativeStickyScrollAttach` -> `attachStickyScroll`) and what this runner was missing.
92
+ //
93
+ // Without it the offset only ever reaches `scrollValue` from `handleOwnerScroll`, i.e. once per
94
+ // delivered JS scroll event — so the pin moves at whatever rate the JS thread can be interrupted
95
+ // at. Device-reported 2026-09-08: during a flick the header was not painted at all and snapped
96
+ // into place only once the scroll stopped, which is the JS thread catching up.
97
+ //
98
+ // The interpolation listeners survive this. A tick is not the MOVEMENT — the AnimatedProps leaf
99
+ // owns that — it is the settled value the reducer debounces into the committed transform, RN's
100
+ // `passthroughAnimatedPropExplicitValues`, and a native value still streams to JS while a listener
101
+ // is registered (`AnimatedValue.__makeNative`).
102
+ function syncNativeScroll(owner, sticky) {
103
+ const wanted = sticky.members.size > 0 && isNativeAnimatedAvailable();
104
+ if (wanted === (sticky.detachNativeScroll !== undefined))
105
+ return;
106
+ if (!wanted) {
107
+ sticky.detachNativeScroll?.();
108
+ sticky.detachNativeScroll = undefined;
109
+ return;
110
+ }
111
+ sticky.detachNativeScroll = attachStickyScroll(owner, sticky.scrollValue);
112
+ }
113
+ // Depth-first over the content subtree, which IS document order — the same order RN's children
114
+ // walk produces, arrived at from the tree instead of from an index array.
115
+ function collectHeaders(node, members, out) {
116
+ for (const child of node.children) {
117
+ if (members.has(child))
118
+ out.push(child);
119
+ collectHeaders(child, members, out);
120
+ }
121
+ }
122
+ function orderedHeaders(owner, sticky) {
123
+ if (sticky.ordered !== undefined)
124
+ return sticky.ordered;
125
+ const out = [];
126
+ collectHeaders(owner.childHost ?? owner, sticky.members, out);
127
+ sticky.ordered = out;
128
+ return out;
129
+ }
130
+ // RN raises the scroll event rate for sticky headers so the offset actually reaches the
131
+ // AnimatedValue (`ScrollView.js:1798`). An app value always wins, which is why this reads
132
+ // `resolveScrollForwarding` rather than the constant — the 1/16 split lives there.
133
+ //
134
+ // This used to force `nativeStickyAvailable: false`, on the reasoning that a scroll value made
135
+ // native up front would cut the child listener cascade before a single tick arrived. It was wrong
136
+ // on both halves and it cost a visibly broken pin: no adapter forces that false — every wrapper
137
+ // calls `attachStickyScroll` — and a tick is not what MOVES the header anyway (`syncNativeScroll`).
138
+ // The attach happens after the header has registered, which is the same ordering React's
139
+ // `useEffect` gives it.
140
+ function syncThrottle(owner, sticky) {
141
+ const current = owner.props.scrollEventThrottle;
142
+ // Whatever stands in the key is the APP's unless it is byte-for-byte the value written here —
143
+ // which is what makes the take-back on the last unregister safe.
144
+ const ours = sticky.writtenThrottle !== undefined && current === sticky.writtenThrottle;
145
+ const appThrottle = !ours && typeof current === 'number' ? current : undefined;
146
+ const wanted = resolveScrollForwarding({
147
+ hasStickyHeaders: sticky.members.size > 0,
148
+ // Reads the same probe `syncNativeScroll` decides on, so the two cannot disagree: RN lowers
149
+ // the forced scroll rate once the offset is on the UI thread, since the JS event is then only
150
+ // the settled-value feed and no longer the animation itself.
151
+ nativeStickyAvailable: isNativeAnimatedAvailable(),
152
+ invertStickyHeaders: undefined,
153
+ scrollEventThrottle: appThrottle,
154
+ maintainVisibleContentPosition: undefined,
155
+ snapToAlignment: undefined,
156
+ }).scrollEventThrottle;
157
+ if (wanted === current)
158
+ return;
159
+ // `wanted` IS `appThrottle` whenever the app set one, so nothing is claimed in that case.
160
+ sticky.writtenThrottle = appThrottle === undefined ? wanted : undefined;
161
+ setProp(owner, 'scrollEventThrottle', wanted);
162
+ requestCommitFor(owner);
163
+ }
164
+ // The owner's own layout, wanted by an inverted sticky pin and by the app, and installed while
165
+ // EITHER wants it. Both halves route through here so neither can uninstall the other's.
166
+ export function syncOwnerLayout(owner) {
167
+ const wanted = needsViewportHeight(owner) || appListenerFor(owner, 'layout') !== undefined;
168
+ setBehaviorListener(owner, 'layout', wanted ? event => handleOwnerLayout(owner, event) : undefined);
169
+ }
170
+ function handleOwnerLayout(owner, event) {
171
+ const sticky = stickyOwners.get(owner);
172
+ const height = readLayoutNumber(event, 'height');
173
+ if (sticky !== undefined && height !== undefined) {
174
+ sticky.viewportHeight = height;
175
+ for (const header of sticky.members)
176
+ dispatch(header, { kind: 'inputs-changed' });
177
+ }
178
+ const app = appListenerFor(owner, 'layout');
179
+ if (typeof app === 'function')
180
+ app(event);
181
+ }
182
+ function readContentOffsetY(event) {
183
+ const native = event.nativeEvent;
184
+ if (typeof native !== 'object' || native === null)
185
+ return undefined;
186
+ const offset = Reflect.get(native, 'contentOffset');
187
+ if (typeof offset !== 'object' || offset === null)
188
+ return undefined;
189
+ const y = Reflect.get(offset, 'y');
190
+ return typeof y === 'number' ? y : undefined;
191
+ }
192
+ // The owner's scroll dispatcher: drive the shared value, then hand the app its event. Installed
193
+ // unconditionally, exactly as RN installs `_handleScroll` unconditionally (`ScrollView.js:1145`) —
194
+ // the app's own `onScroll` is an OWNED name and would otherwise evict this one from the single
195
+ // listener slot.
196
+ export function handleOwnerScroll(owner, event) {
197
+ const sticky = stickyOwners.get(owner);
198
+ // Skipped while the offset rides the UI thread: the native attach already drives the value every
199
+ // frame, so writing it again from a JS event is a redundant graph update at a WORSE rate.
200
+ if (sticky !== undefined &&
201
+ sticky.members.size > 0 &&
202
+ sticky.detachNativeScroll === undefined) {
203
+ const y = readContentOffsetY(event);
204
+ if (y !== undefined)
205
+ sticky.scrollValue.setValue(y);
206
+ }
207
+ const app = appListenerFor(owner, 'scroll');
208
+ if (typeof app === 'function')
209
+ app(event);
210
+ }
211
+ // The ScrollView's own teardown. Every header's runtime is released by its own `detach`, so the
212
+ // only thing owed here is the owner state — and cutting each header's back-reference with it, so a
213
+ // header still in flight cannot dispatch into a registry that is gone.
214
+ export function releaseStickyOwner(owner) {
215
+ ownersWithIndexWrappers.delete(owner);
216
+ const sticky = stickyOwners.get(owner);
217
+ if (sticky === undefined)
218
+ return;
219
+ sticky.detachNativeScroll?.();
220
+ sticky.detachNativeScroll = undefined;
221
+ for (const header of sticky.members) {
222
+ const runtime = headerRuntimes.get(header);
223
+ if (runtime !== undefined)
224
+ runtime.owner = undefined;
225
+ }
226
+ stickyOwners.delete(owner);
227
+ }
228
+ // ---------------------------------------------------------------- the index form
229
+ // `stickyHeaderIndices` on the lowered path, and it is deliberately NOT a second machine: a flagged
230
+ // child is MOVED into a synthesized `sticky-header` node, so ordering, cross-talk, the raised
231
+ // throttle, the pin and the teardown are the child form's, unchanged. Indices decide only WHICH
232
+ // children get one.
233
+ //
234
+ // TWO COSTS, both accepted rather than engineered away.
235
+ //
236
+ // `StickyHeaderComponent` — RN's prop naming a custom wrapper component — is NOT honoured: a
237
+ // behavior cannot instantiate a framework component, and Angular's automatic path already made
238
+ // that trade (`projection.ts`, `wrapRecord`). It IS honoured on React/Vue/Solid/Svelte's own
239
+ // component paths today, so this is a real narrowing for them; an app that needs one composes it
240
+ // explicitly around a `<sticky-header>` instead.
241
+ //
242
+ // THE WRAP LANDS ONE COMMIT LATE. `afterCommit` is the only hook that sees `owner.childHost.children`
243
+ // complete, and it runs past `completeRoot` — so a flagged child paints unwrapped for the frame it
244
+ // mounts in and pins from the next commit. Angular escapes this only through a synchronous flush at
245
+ // `RendererFactory2.end()`, a seam a behavior does not have. Pinned by `sticky-indices.test.ts` so
246
+ // it is learned from a green assertion rather than rediscovered on a device.
247
+ //
248
+ // UNSORTED INDICES RESOLVE BY DOCUMENT ORDER, which is what `orderedHeaders` already gives — and it
249
+ // is a choice, because the two existing runners disagree: React reads the next header out of the
250
+ // ARRAY (`ScrollView.js:1695`, `indexOf(index) + 1`), Angular out of the sorted list
251
+ // (`find(entry > index)`). Document order is the one that stays correct, because the value is a
252
+ // COLLISION POINT — the y of the header that pushes this one off — so it has to be the header BELOW
253
+ // on screen; `[2, 0]` under React's rule feeds header 2 the y of a header above it. It is also the
254
+ // only ordering under which the two forms can share a scroll view, since both produce members of
255
+ // one set ordered by where they sit.
256
+ // The nodes this module synthesized, so a later walk can tell its own wrapper from an app's child.
257
+ const indexWrappers = new WeakSet();
258
+ // Owners currently holding one. The gate: a ScrollView that never used the prop pays one WeakSet
259
+ // miss per commit and walks nothing.
260
+ const ownersWithIndexWrappers = new WeakSet();
261
+ function stickyIndexSet(value) {
262
+ if (!Array.isArray(value))
263
+ return undefined;
264
+ const out = new Set();
265
+ for (const entry of value)
266
+ if (typeof entry === 'number')
267
+ out.add(entry);
268
+ return out.size === 0 ? undefined : out;
269
+ }
270
+ function wrapForIndex(slot, child) {
271
+ const descriptor = descriptorFor(STICKY_HEADER_TAG);
272
+ const wrapper = createElement(descriptor.component, descriptor.isText, STICKY_HEADER_TAG);
273
+ indexWrappers.add(wrapper);
274
+ // The slot FIRST, then the child into it: the engine's appendChild detaches from the old parent,
275
+ // so the wrapper takes the position the child vacates and nothing has to be removed.
276
+ insertBefore(slot, wrapper, child);
277
+ appendChild(wrapper, child);
278
+ }
279
+ function unwrapIndex(slot, wrapper) {
280
+ const child = wrapper.children[0];
281
+ if (child !== undefined)
282
+ insertBefore(slot, child, wrapper);
283
+ removeChild(slot, wrapper);
284
+ }
285
+ /**
286
+ * Bring the synthesized wrappers in line with `stickyHeaderIndices`. Called from the ScrollView
287
+ * behavior's `afterCommit`, the one beat at which the app's children are all present.
288
+ *
289
+ * O(slot children) per commit, once — never per mutation. Angular's controller coalesces to one
290
+ * pass per change detection for exactly this reason: its per-mutation walk was O(M²) and died at
291
+ * 801 children.
292
+ */
293
+ export function reconcileStickyIndices(owner) {
294
+ const slot = owner.childHost;
295
+ if (slot === undefined)
296
+ return;
297
+ const wanted = stickyIndexSet(owner.props.stickyHeaderIndices);
298
+ if (wanted === undefined && !ownersWithIndexWrappers.has(owner))
299
+ return;
300
+ let paintIndex = 0;
301
+ let wrapped = 0;
302
+ let changed = false;
303
+ // Snapshot: wrapping and unwrapping both splice the list being walked.
304
+ //
305
+ // A claimed `<RefreshControl>` needs no filter here, unlike Angular's walk — `hostFor` keeps a
306
+ // claimed child on the OWNER, so it never reaches the slot at all.
307
+ for (const child of [...slot.children]) {
308
+ const wrapper = indexWrappers.has(child) ? child : undefined;
309
+ if (wrapper !== undefined) {
310
+ // The framework removes a child from the SLOT, because that is where it appended it — so the
311
+ // engine's `removeChild` finds nothing to splice and only clears `child.parent`, leaving a
312
+ // committed wrapper around a node nobody owns. This walk is the only thing that can see it.
313
+ if (wrapper.children[0]?.parent !== wrapper) {
314
+ removeChild(slot, wrapper);
315
+ changed = true;
316
+ continue;
317
+ }
318
+ }
319
+ else if (isAnchor(child)) {
320
+ // An anchor paints nothing, so RN's own children walk never numbered one. Without this every
321
+ // index below an anchor addresses the wrong child, and a windowed list inserts them freely.
322
+ continue;
323
+ }
324
+ const index = paintIndex;
325
+ paintIndex += 1;
326
+ // A `<sticky-header>` the app wrote is a child like any other and counts — it just must not be
327
+ // wrapped in a second one.
328
+ if (wrapper === undefined && headerRuntimes.has(child))
329
+ continue;
330
+ const shouldWrap = wanted !== undefined && wanted.has(index);
331
+ if (shouldWrap && wrapper === undefined) {
332
+ wrapForIndex(slot, child);
333
+ changed = true;
334
+ wrapped += 1;
335
+ }
336
+ else if (!shouldWrap && wrapper !== undefined) {
337
+ unwrapIndex(slot, wrapper);
338
+ changed = true;
339
+ }
340
+ else if (wrapper !== undefined)
341
+ wrapped += 1;
342
+ }
343
+ if (wrapped > 0)
344
+ ownersWithIndexWrappers.add(owner);
345
+ else
346
+ ownersWithIndexWrappers.delete(owner);
347
+ if (changed) {
348
+ dlog(`sticky indices reconciled (${wrapped} wrapped)`);
349
+ requestCommitFor(owner);
350
+ }
351
+ }
352
+ // ---------------------------------------------------------------- the header half
353
+ function findScrollOwner(node) {
354
+ let current = node.parent;
355
+ while (current !== undefined) {
356
+ if (scrollOwners.has(current))
357
+ return current;
358
+ current = current.parent;
359
+ }
360
+ return undefined;
361
+ }
362
+ function nextHeaderY(runtime, node) {
363
+ const owner = runtime.owner;
364
+ if (owner === undefined)
365
+ return undefined;
366
+ const sticky = stickyOwners.get(owner);
367
+ if (sticky === undefined)
368
+ return undefined;
369
+ const order = orderedHeaders(owner, sticky);
370
+ const next = order[order.indexOf(node) + 1];
371
+ return next === undefined ? undefined : sticky.layoutYs.get(next);
372
+ }
373
+ // The committed half of the pin: the debounced translateY RN pushes into the transform for
374
+ // hit-testing (`passthroughAnimatedPropExplicitValues`), plus the two constants the wrapper always
375
+ // carries. The SMOOTH half rides the AnimatedProps leaf below and never passes through here.
376
+ //
377
+ // A per-node fold, assigned in `attach`, because what it reads is per-node runtime state rather
378
+ // than a prop — the behavior-level `foldPayload` gets props and nothing else.
379
+ function stickyFold(runtime) {
380
+ return props => {
381
+ const pin = { zIndex: STICKY_HEADER_Z_INDEX };
382
+ if (runtime.state.translateY !== null)
383
+ pin.transform = [{ translateY: runtime.state.translateY }];
384
+ return {
385
+ ...props,
386
+ // Over the app's, never under: the pin is the whole point of the element, and a header whose
387
+ // own style set a transform would otherwise cancel it.
388
+ style: [props.style, pin],
389
+ // Yoga may flatten a view that only groups children, and a flattened header has no transform
390
+ // to animate. RN's sticky wrapper sets it for the same reason.
391
+ collapsable: false,
392
+ };
393
+ };
394
+ }
395
+ function dispatch(node, action) {
396
+ const runtime = headerRuntimes.get(node);
397
+ if (runtime === undefined || runtime.owner === undefined)
398
+ return;
399
+ const sticky = stickyOwners.get(runtime.owner);
400
+ const result = reduceSticky(runtime.state, action, {
401
+ os: Platform.OS,
402
+ inverted: runtime.owner.props.invertStickyHeaders === true,
403
+ scrollViewHeight: sticky?.viewportHeight,
404
+ nextHeaderLayoutY: nextHeaderY(runtime, node),
405
+ });
406
+ runEffects(node, runtime, result.effects);
407
+ }
408
+ function runEffects(node, runtime, effects) {
409
+ for (const effect of effects) {
410
+ switch (effect.kind) {
411
+ case 'rebuild-interpolation':
412
+ rebuildInterpolation(node, runtime, effect.inputRange, effect.outputRange);
413
+ break;
414
+ case 'schedule-debounce':
415
+ if (runtime.debounceTimer !== undefined)
416
+ clearTimeout(runtime.debounceTimer);
417
+ runtime.debounceTimer = setTimeout(() => {
418
+ runtime.debounceTimer = undefined;
419
+ dispatch(node, { kind: 'debounce-fired', value: effect.value });
420
+ }, effect.delay);
421
+ break;
422
+ case 'apply-passthrough':
423
+ // The fold reads `runtime.state`, which no prop write touched, so nothing has marked the
424
+ // node — and dirtying is not publishing, hence both calls.
425
+ //
426
+ // NOT witnessed by a headless test, and the reason is worth knowing before deleting it:
427
+ // while the pin is JS-driven the animated leaf's own `setNativeProps` has already written
428
+ // the same transform and marked the node, so removing this line reddens nothing here. It
429
+ // is the NATIVE-driver path this exists for — there the leaf stops writing JS-side and the
430
+ // committed transform is all hit-testing has, which is exactly why RN keeps
431
+ // `passthroughAnimatedPropExplicitValues` beside the animated one.
432
+ markPropsDirty(node);
433
+ requestCommitFor(node);
434
+ break;
435
+ case 'record-header-y':
436
+ // Recorded from the layout handler instead: the reducer emits this only for a runner that
437
+ // owns a child INDEX, and the owner's document order is what replaces indices here.
438
+ break;
439
+ }
440
+ }
441
+ }
442
+ function rebuildInterpolation(node, runtime, inputRange, outputRange) {
443
+ const owner = runtime.owner;
444
+ if (owner === undefined)
445
+ return;
446
+ const sticky = stickyOwners.get(owner);
447
+ if (sticky === undefined)
448
+ return;
449
+ if (runtime.interpolation !== undefined && runtime.listenerId !== undefined)
450
+ runtime.interpolation.removeListener(runtime.listenerId);
451
+ const next = sticky.scrollValue.interpolate({
452
+ inputRange: [...inputRange],
453
+ outputRange: [...outputRange],
454
+ });
455
+ runtime.listenerId = next.addListener(({ value }) => {
456
+ if (typeof value === 'number')
457
+ dispatch(node, { kind: 'animated-tick', value });
458
+ });
459
+ runtime.interpolation = next;
460
+ // A fresh leaf per rebuild, as every other runner does: `AnimatedProps` binds the props map it
461
+ // was constructed with, so a new interpolation node needs a new leaf. No `__makeNative()` — the
462
+ // leaf joins the graph under the scroll value and promotes when that value does.
463
+ const leaf = new AnimatedProps({
464
+ style: { transform: [{ translateY: next }], zIndex: STICKY_HEADER_Z_INDEX },
465
+ });
466
+ leaf.__attach();
467
+ runtime.leaf?.__detach();
468
+ runtime.leaf = leaf;
469
+ runtime.cancelBind?.();
470
+ runtime.cancelBind = whenCommitted(node, () => leaf.setNativeView(node));
471
+ }
472
+ function handleHeaderLayout(node, event) {
473
+ const runtime = headerRuntimes.get(node);
474
+ if (runtime === undefined)
475
+ return;
476
+ const y = readLayoutNumber(event, 'y');
477
+ const height = readLayoutNumber(event, 'height');
478
+ if (runtime.owner !== undefined && y !== undefined) {
479
+ const sticky = stickyOwners.get(runtime.owner);
480
+ if (sticky !== undefined && sticky.layoutYs.get(node) !== y) {
481
+ sticky.layoutYs.set(node, y);
482
+ // The cross-talk: this header's y is the PREVIOUS one's collision point, and nothing else
483
+ // tells that header its input moved.
484
+ const order = orderedHeaders(runtime.owner, sticky);
485
+ const previous = order[order.indexOf(node) - 1];
486
+ if (previous !== undefined)
487
+ dispatch(previous, { kind: 'inputs-changed' });
488
+ }
489
+ }
490
+ // Keep the previous value when a field is absent, as every runner does — RN sets state only on a
491
+ // defined read.
492
+ dispatch(node, {
493
+ kind: 'layout',
494
+ y: y ?? runtime.state.layoutY,
495
+ height: height ?? runtime.state.layoutHeight,
496
+ });
497
+ const app = appListenerFor(node, 'layout');
498
+ if (typeof app === 'function')
499
+ app(event);
500
+ }
501
+ function attach(node) {
502
+ const runtime = {
503
+ state: createInitialStickyState(),
504
+ owner: undefined,
505
+ interpolation: undefined,
506
+ listenerId: undefined,
507
+ debounceTimer: undefined,
508
+ leaf: undefined,
509
+ cancelBind: undefined,
510
+ };
511
+ headerRuntimes.set(node, runtime);
512
+ node.payloadFold = stickyFold(runtime);
513
+ setBehaviorListener(node, 'layout', event => handleHeaderLayout(node, event));
514
+ }
515
+ // The registration waits for a committed tag rather than happening in `attach`, and both halves of
516
+ // that are load-bearing: at `attach` the node has no parent, so there is no owner to find, and the
517
+ // AnimatedProps leaf needs a tag to bind to.
518
+ function attachAfterCommit(node) {
519
+ const runtime = headerRuntimes.get(node);
520
+ if (runtime === undefined || runtime.owner !== undefined)
521
+ return;
522
+ const owner = findScrollOwner(node);
523
+ if (owner === undefined) {
524
+ dlog('sticky header committed outside a ScrollView — the pin is a no-op');
525
+ return;
526
+ }
527
+ runtime.owner = owner;
528
+ const sticky = ownerSticky(owner);
529
+ sticky.members.add(node);
530
+ sticky.ordered = undefined;
531
+ syncThrottle(owner, sticky);
532
+ syncNativeScroll(owner, sticky);
533
+ syncOwnerLayout(owner);
534
+ dlog(`sticky header registered (${sticky.members.size} on this ScrollView)`);
535
+ dispatch(node, { kind: 'inputs-changed' });
536
+ }
537
+ function detach(node) {
538
+ const runtime = headerRuntimes.get(node);
539
+ if (runtime === undefined)
540
+ return;
541
+ runtime.cancelBind?.();
542
+ if (runtime.interpolation !== undefined && runtime.listenerId !== undefined)
543
+ runtime.interpolation.removeListener(runtime.listenerId);
544
+ if (runtime.debounceTimer !== undefined)
545
+ clearTimeout(runtime.debounceTimer);
546
+ runtime.leaf?.__detach();
547
+ const owner = runtime.owner;
548
+ headerRuntimes.delete(node);
549
+ if (owner === undefined)
550
+ return;
551
+ const sticky = stickyOwners.get(owner);
552
+ if (sticky === undefined)
553
+ return;
554
+ sticky.members.delete(node);
555
+ sticky.layoutYs.delete(node);
556
+ sticky.ordered = undefined;
557
+ syncThrottle(owner, sticky);
558
+ syncNativeScroll(owner, sticky);
559
+ syncOwnerLayout(owner);
560
+ }
561
+ export const stickyHeaderBehavior = {
562
+ // The app's own `onLayout` on a sticky header is forwarded, not replaced: the header's measured
563
+ // y is what the whole machine runs on, so the behavior cannot give the slot up.
564
+ ownedListeners: ['layout'],
565
+ attach,
566
+ attachAfterCommit,
567
+ detach,
568
+ };
@@ -1,2 +1,2 @@
1
- export declare const SWITCH_TAG = "symbiote-switch";
1
+ export declare const SWITCH_TAG = "switch";
2
2
  export declare function registerSwitchBehavior(): void;
@@ -38,10 +38,10 @@
38
38
  // needs.
39
39
  import { appListenerFor, dispatchViewCommand, dlog, Platform, registerHostBehavior, setBehaviorListener, } from '@symbiote-native/engine';
40
40
  import { createInitialSwitchState, shouldSnapBack, switchReducer, valueFromChange, } from '../state/switch.js';
41
- // The LOWERED tag — NOT the wrapper's `symbiote-switch-managed` (`render-switch.ts`). One owner
41
+ // The LOWERED tag — NOT the wrapper's `switch-managed` (`render-switch.ts`). One owner
42
42
  // per node: the wrapper already runs this same machine in its own lifecycle, so registering here
43
43
  // under the tag it emits would attach a second, redundant copy.
44
- export const SWITCH_TAG = 'symbiote-switch';
44
+ export const SWITCH_TAG = 'switch';
45
45
  const states = new WeakMap();
46
46
  function stateOf(node) {
47
47
  return states.get(node);
@@ -146,10 +146,14 @@ function onChange(node, event) {
146
146
  // `onValueChange` is not a Fabric event — it is a fold the component wrapper does over the raw
147
147
  // `change` payload (same class as TextInput's `onValueChange`, `text-input.ts`'s
148
148
  // `callValueChange`), so it lands in `node.props` as a plain function key rather than through
149
- // `ownedListeners`.
149
+ // `ownedListeners`. ONE argument, `value` carried on the event (`ISwitchChangeEvent`) — same
150
+ // reason as TextInput: Svelte's compiler forces an individual `on*` attribute through a native
151
+ // listener wrapper that calls with exactly one argument, always a real object.
150
152
  const listener = node.props.onValueChange;
151
- if (typeof listener === 'function')
152
- listener(value, event);
153
+ if (typeof listener === 'function') {
154
+ const changeEvent = Object.assign(event, { value });
155
+ listener(changeEvent);
156
+ }
153
157
  // A raw `change` listener authored directly on the bare tag — not part of any adapter's public
154
158
  // surface today, but `change` is the name this behavior's own dispatcher owns
155
159
  // (`ownedListeners` below), so one is stashed rather than silently evicting the machine.
@@ -1,7 +1,7 @@
1
1
  import { type ISymbioteNode } from '@symbiote-native/engine';
2
2
  import { type ITextInputHandle } from '../state/text-input';
3
- export declare const TEXT_INPUT_TAG = "symbiote-text-input";
4
- export declare const TEXT_INPUT_MULTILINE_TAG = "symbiote-text-input-multiline";
3
+ export declare const TEXT_INPUT_TAG = "text-input";
4
+ export declare const TEXT_INPUT_MULTILINE_TAG = "text-input-multiline";
5
5
  /**
6
6
  * The imperative API RN exposes on a TextInput ref, built over the engine node. Reached through
7
7
  * each adapter's own `host-instance` accessor — the capability, not a shape