@symbiote-native/vue 0.1.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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/build/components/activity-indicator/index.android.d.ts +4 -0
  4. package/build/components/activity-indicator/index.android.js +10 -0
  5. package/build/components/activity-indicator/index.d.ts +1 -0
  6. package/build/components/activity-indicator/index.ios.d.ts +4 -0
  7. package/build/components/activity-indicator/index.ios.js +10 -0
  8. package/build/components/activity-indicator/index.js +4 -0
  9. package/build/components/activity-indicator/shared.d.ts +8 -0
  10. package/build/components/activity-indicator/shared.js +48 -0
  11. package/build/components/button.d.ts +14 -0
  12. package/build/components/button.js +59 -0
  13. package/build/components/drawer-layout-android/index.android.d.ts +8 -0
  14. package/build/components/drawer-layout-android/index.android.js +144 -0
  15. package/build/components/drawer-layout-android/index.d.ts +8 -0
  16. package/build/components/drawer-layout-android/index.js +36 -0
  17. package/build/components/drawer-layout-android/shared.d.ts +18 -0
  18. package/build/components/drawer-layout-android/shared.js +6 -0
  19. package/build/components/flat-list/index.d.ts +88 -0
  20. package/build/components/flat-list/index.js +189 -0
  21. package/build/components/image-background.d.ts +9 -0
  22. package/build/components/image-background.js +114 -0
  23. package/build/components/image.d.ts +9 -0
  24. package/build/components/image.js +99 -0
  25. package/build/components/input-accessory-view.d.ts +10 -0
  26. package/build/components/input-accessory-view.js +41 -0
  27. package/build/components/keyboard-avoiding-view.d.ts +18 -0
  28. package/build/components/keyboard-avoiding-view.js +121 -0
  29. package/build/components/modal.d.ts +29 -0
  30. package/build/components/modal.js +134 -0
  31. package/build/components/pressable.d.ts +58 -0
  32. package/build/components/pressable.js +221 -0
  33. package/build/components/refresh-control.d.ts +20 -0
  34. package/build/components/refresh-control.js +53 -0
  35. package/build/components/safe-area-view.d.ts +8 -0
  36. package/build/components/safe-area-view.js +33 -0
  37. package/build/components/scroll-view/index.android.d.ts +4 -0
  38. package/build/components/scroll-view/index.android.js +74 -0
  39. package/build/components/scroll-view/index.d.ts +1 -0
  40. package/build/components/scroll-view/index.ios.d.ts +4 -0
  41. package/build/components/scroll-view/index.ios.js +15 -0
  42. package/build/components/scroll-view/index.js +4 -0
  43. package/build/components/scroll-view/shared.d.ts +96 -0
  44. package/build/components/scroll-view/shared.js +297 -0
  45. package/build/components/scroll-view/sticky-header.d.ts +7 -0
  46. package/build/components/scroll-view/sticky-header.js +195 -0
  47. package/build/components/section-list/index.d.ts +39 -0
  48. package/build/components/section-list/index.js +77 -0
  49. package/build/components/switch/index.android.d.ts +12 -0
  50. package/build/components/switch/index.android.js +13 -0
  51. package/build/components/switch/index.d.ts +2 -0
  52. package/build/components/switch/index.ios.d.ts +12 -0
  53. package/build/components/switch/index.ios.js +11 -0
  54. package/build/components/switch/index.js +5 -0
  55. package/build/components/switch/shared.d.ts +23 -0
  56. package/build/components/switch/shared.js +129 -0
  57. package/build/components/text-input/index.d.ts +24 -0
  58. package/build/components/text-input/index.js +257 -0
  59. package/build/components/touchable-native-feedback.d.ts +24 -0
  60. package/build/components/touchable-native-feedback.js +75 -0
  61. package/build/components/touchable.d.ts +44 -0
  62. package/build/components/touchable.js +150 -0
  63. package/build/components/virtualized-list/index.d.ts +121 -0
  64. package/build/components/virtualized-list/index.js +687 -0
  65. package/build/components/virtualized-section-list/index.d.ts +96 -0
  66. package/build/components/virtualized-section-list/index.js +160 -0
  67. package/build/components.d.ts +51 -0
  68. package/build/components.js +26 -0
  69. package/build/composables/use-color-scheme.d.ts +3 -0
  70. package/build/composables/use-color-scheme.js +24 -0
  71. package/build/composables/use-window-dimensions.d.ts +3 -0
  72. package/build/composables/use-window-dimensions.js +34 -0
  73. package/build/create-tunnel.d.ts +9 -0
  74. package/build/create-tunnel.js +37 -0
  75. package/build/descriptor-to-vue.d.ts +3 -0
  76. package/build/descriptor-to-vue.js +14 -0
  77. package/build/host-instance/index.d.ts +2 -0
  78. package/build/host-instance/index.js +34 -0
  79. package/build/index.d.ts +67 -0
  80. package/build/index.js +90 -0
  81. package/build/modules/animated/create-animated-component.d.ts +4 -0
  82. package/build/modules/animated/create-animated-component.js +169 -0
  83. package/build/modules/animated/index.d.ts +55 -0
  84. package/build/modules/animated/index.js +66 -0
  85. package/build/modules/app-registry/index.d.ts +7 -0
  86. package/build/modules/app-registry/index.js +32 -0
  87. package/build/modules/status-bar.d.ts +17 -0
  88. package/build/modules/status-bar.js +65 -0
  89. package/build/render.d.ts +7 -0
  90. package/build/render.js +43 -0
  91. package/build/renderer.d.ts +4 -0
  92. package/build/renderer.js +129 -0
  93. package/build/runtime-helpers.d.ts +28 -0
  94. package/build/runtime-helpers.js +68 -0
  95. package/build/utils/component-helpers.d.ts +2 -0
  96. package/build/utils/component-helpers.js +13 -0
  97. package/build/utils/model-binding.d.ts +4 -0
  98. package/build/utils/model-binding.js +17 -0
  99. package/build/utils/normalize-attrs.d.ts +1 -0
  100. package/build/utils/normalize-attrs.js +30 -0
  101. package/build/utils/slots-to-render-props.d.ts +4 -0
  102. package/build/utils/slots-to-render-props.js +18 -0
  103. package/metro-css-parser.cjs +12 -0
  104. package/package.json +54 -0
@@ -0,0 +1,687 @@
1
+ // VirtualizedList, the Vue lifecycle half. The windowing engine (offset table, window
2
+ // compute, batch throttle, viewability, edge-reached, the child PLAN, the imperative-handle
3
+ // surface) lives in @symbiote-native/components/state, shared verbatim with the React adapter. Here
4
+ // Vue supplies only the reactivity: refs for scroll offset / viewport / measurement bumps, a
5
+ // `computed` that runs the shared windowing math, post-flush watchers for the after-commit
6
+ // work (batch fill, onEndReached/onStartReached, viewability, initialScroll, MVCP), and the
7
+ // imperative handle via expose(). This is the Vue twin of the React adapter's
8
+ // useState/useRef/useEffect over the same shared functions. It drives the Vue ScrollView,
9
+ // exactly as the React list drives the React ScrollView.
10
+ //
11
+ // Typed-emits generic component (mirrors FlatList): a GENERIC setup function
12
+ // `<ItemT,>(props: IVirtualizedListProps<ItemT>, ctx: ICtx<IVirtualizedListEmits<ItemT>>)` so the
13
+ // five adapter-synthesized events emit with ItemT-typed payloads (`@viewable-items-changed` carries
14
+ // ItemT, not unknown). For that to infer at the call site, the generic INPUTS (data/getItem/
15
+ // renderItem/…) are read from typed `props`, declared in the runtime `props` array; the raw native
16
+ // scroll events (onScroll/onScrollBeginDrag/…) + any unknown attrs ride through `$attrs` onto the
17
+ // inner ScrollView. RefreshControl + viewability stay GATED on listener presence, so each emit
18
+ // bridge is wired ONLY when the consumer actually listens (read off the instance vnode props),
19
+ // keeping behavior identical to the prop-callback era.
20
+ //
21
+ // Lists have no Descriptor render fn (the cell content is the framework's own children); see
22
+ // core/components/.docs-note-lists.md. Cells/spacers are built with h() directly off the plan.
23
+ //
24
+ // Reactivity gotcha (.claude/skills/vue-adapter-reactivity): the ScrollView's exposed handle
25
+ // is held in a shallowRef so the engine node it closes over is reached by identity; a deep
26
+ // ref would proxy it and the imperative scroll commands would silently no-op.
27
+ import { computed, defineComponent, getCurrentInstance, h, isVNode, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
28
+ import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, NO_CONTENT_LENGTH_SENT, averageMeasuredLength, buildListPlan, buildOffsets, buildViewabilityPairs, computeEndReached, computeStartReached, computeViewableSet, computeWindow, diffViewable, highestMeasuredIndex, maxMinimumViewTime, offsetForIndex, readLayoutLength, readScrollOffset, throttleWindow, } from '@symbiote-native/components';
29
+ import { dlog, } from '@symbiote-native/engine';
30
+ import { ScrollView } from '../scroll-view';
31
+ import { RefreshControl } from '../refresh-control';
32
+ import { normalizeVueAttrs } from '../../utils/normalize-attrs';
33
+ import { componentFromSlot } from '../../utils/slots-to-render-props';
34
+ // The typed inputs VirtualizedList reads off `props`; everything else (raw scroll, keyboard*,
35
+ // accessibility, testID, …) falls through $attrs onto the inner ScrollView. Listed for the runtime
36
+ // `props` declaration (keyof can't derive it: the index signature widens keyof to `string`). The
37
+ // five raw-scroll events and the five emit events are deliberately absent.
38
+ const PROP_KEYS = [
39
+ 'data',
40
+ 'getItem',
41
+ 'getItemCount',
42
+ 'keyExtractor',
43
+ 'getItemLayout',
44
+ 'viewabilityConfig',
45
+ 'viewabilityConfigCallbackPairs',
46
+ 'extraData',
47
+ 'horizontal',
48
+ 'inverted',
49
+ 'onEndReachedThreshold',
50
+ 'onStartReachedThreshold',
51
+ 'refreshing',
52
+ 'progressViewOffset',
53
+ 'initialNumToRender',
54
+ 'initialScrollIndex',
55
+ 'maxToRenderPerBatch',
56
+ 'updateCellsBatchingPeriod',
57
+ 'windowSize',
58
+ 'stickyHeaderIndices',
59
+ 'maintainVisibleContentPosition',
60
+ 'style',
61
+ 'contentContainerStyle',
62
+ 'keyboardShouldPersistTaps',
63
+ 'keyboardDismissMode',
64
+ ];
65
+ const EMIT_KEYS = [
66
+ 'viewableItemsChanged',
67
+ 'endReached',
68
+ 'startReached',
69
+ 'refresh',
70
+ 'scrollToIndexFailed',
71
+ ];
72
+ function isHandler(value) {
73
+ return typeof value === 'function';
74
+ }
75
+ function isRecord(value) {
76
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
77
+ }
78
+ function isComponent(value) {
79
+ return typeof value === 'function' || (typeof value === 'object' && value !== null);
80
+ }
81
+ function asNumber(value, fallback) {
82
+ return typeof value === 'number' ? value : fallback;
83
+ }
84
+ // The attrs that arrive via $attrs but must NOT be forwarded raw onto the inner ScrollView: VL
85
+ // composes its own onScroll (windowing offset + the user's onScroll) and its own onLayout (viewport
86
+ // measure), so a consumer-passed one is dropped here and re-set explicitly on the scroll props.
87
+ const HANDLED_ATTRS = ['onScroll', 'onLayout'];
88
+ function forwardAttrs(attrs) {
89
+ const result = {};
90
+ for (const key of Object.keys(attrs)) {
91
+ if (!HANDLED_ATTRS.includes(key))
92
+ result[key] = attrs[key];
93
+ }
94
+ return result;
95
+ }
96
+ // A list component element (ListHeaderComponent / ListEmptyComponent / ListFooterComponent) is
97
+ // either a Vue component (invoked via h) or a ready VNode (passed through). RN resolves the same
98
+ // `Component | element` union; we narrow with isVNode before isComponent (a VNode is an object).
99
+ function resolveElement(value) {
100
+ if (value === undefined || value === null)
101
+ return undefined;
102
+ if (isVNode(value))
103
+ return value;
104
+ if (isComponent(value))
105
+ return h(value);
106
+ return undefined;
107
+ }
108
+ function renderSeparatorElement(component, leadingItem, trailingItem, overrides) {
109
+ if (component === undefined)
110
+ return undefined;
111
+ const props = {
112
+ highlighted: false,
113
+ leadingItem,
114
+ trailingItem,
115
+ ...overrides,
116
+ };
117
+ return h(component, props);
118
+ }
119
+ function isScrollViewHandle(value) {
120
+ return isRecord(value) && typeof value.scrollTo === 'function';
121
+ }
122
+ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, slots, }) => {
123
+ // Reactive lifecycle state (the Vue twin of React's useState): a change in any of these
124
+ // re-runs the windowing `computed` and the render fn.
125
+ const scrollOffset = ref(EMPTY_OFFSET);
126
+ const viewportLength = ref(EMPTY_OFFSET);
127
+ // The offset we are imperatively driving native to. A fresh object identity each push so the
128
+ // commit path re-applies it even when the numeric value repeats. undefined = none pending.
129
+ const commandedOffset = ref(undefined);
130
+ // Bumped when a NEW cell measurement lands or a batch tick fires, to request a re-render
131
+ // without thrashing on already-known cells (React's measuredRef + setMeasureVersion).
132
+ const measureVersion = ref(EMPTY_OFFSET);
133
+ const separatorVersion = ref(EMPTY_OFFSET);
134
+ // shallowRef, NOT ref: the ScrollView handle closes over the engine scroll node, reached by
135
+ // identity through the engine's WeakMap mirror. A deep ref would proxy the object and every
136
+ // imperative scroll (scrollToOffset/Index/…) would miss the node and silently no-op. See
137
+ // .claude/skills/vue-adapter-reactivity.
138
+ const scrollHandle = shallowRef(null);
139
+ const setScrollHandle = (instance) => {
140
+ scrollHandle.value = isScrollViewHandle(instance) ? instance : null;
141
+ };
142
+ // The five list events are emits, so Vue strips their onX listeners from $attrs. Detect listener
143
+ // presence off the instance's own vnode props (what the parent passed), exactly like FlatList, so
144
+ // an emit bridge is wired ONLY when the consumer actually listens (preserving the RefreshControl /
145
+ // viewability gating of the prop-callback era).
146
+ const instance = getCurrentInstance();
147
+ const listens = (onName) => {
148
+ const vnodeProps = instance?.vnode.props;
149
+ return vnodeProps != null && typeof vnodeProps[onName] === 'function';
150
+ };
151
+ // Non-reactive setup-scope state (the Vue twin of React's refs that never trigger a render):
152
+ const measured = new Map();
153
+ // The previously committed window, so throttleWindow grows it by at most maxToRenderPerBatch
154
+ // per tick instead of snapping.
155
+ let committedWindow = { first: FIRST_INDEX, last: -1 };
156
+ let sentEndForContentLength = NO_CONTENT_LENGTH_SENT;
157
+ let sentStartForContentLength = NO_CONTENT_LENGTH_SENT;
158
+ let lastViewable = new Map();
159
+ let viewableTimer = null;
160
+ let batchTimer = null;
161
+ let hasInteracted = false;
162
+ const separatorOverrides = new Map();
163
+ let appliedInitialScroll = false;
164
+ let firstVisibleKey = null;
165
+ const narrowed = computed(() => {
166
+ // extraData has no field of its own; reading it tracks it so a change forces a re-render
167
+ // (RN's extraData contract).
168
+ void props.extraData;
169
+ const folded = normalizeVueAttrs(attrs);
170
+ return {
171
+ data: props.data,
172
+ getItem: props.getItem,
173
+ getItemCount: props.getItemCount,
174
+ // Cell + chrome come from scoped slots. #item IS the render fn (a slot fn returning
175
+ // VNode|VNode[], which the cell wrapper accepts directly). #separator carries scope props
176
+ // (h(component, props)) so it goes through componentFromSlot; #header/#footer/#empty are
177
+ // scopeless, so the bare slot fn is handed to resolveElement (its isComponent branch).
178
+ renderItem: slots.item,
179
+ keyExtractor: props.keyExtractor,
180
+ getItemLayout: props.getItemLayout,
181
+ itemSeparatorComponent: componentFromSlot(slots.separator),
182
+ listHeaderComponent: slots.header,
183
+ listFooterComponent: slots.footer,
184
+ listEmptyComponent: slots.empty,
185
+ horizontal: props.horizontal === true,
186
+ inverted: props.inverted === true,
187
+ onEndReached: listens('onEndReached')
188
+ ? (info) => emit('endReached', info)
189
+ : undefined,
190
+ onEndReachedThreshold: asNumber(props.onEndReachedThreshold, DEFAULT_END_REACHED_THRESHOLD),
191
+ onStartReached: listens('onStartReached')
192
+ ? (info) => emit('startReached', info)
193
+ : undefined,
194
+ onStartReachedThreshold: asNumber(props.onStartReachedThreshold, DEFAULT_START_REACHED_THRESHOLD),
195
+ onRefresh: listens('onRefresh') ? () => emit('refresh') : undefined,
196
+ refreshing: props.refreshing === true,
197
+ progressViewOffset: props.progressViewOffset,
198
+ onViewableItemsChanged: listens('onViewableItemsChanged')
199
+ ? (info) => emit('viewableItemsChanged', info)
200
+ : undefined,
201
+ viewabilityConfig: props.viewabilityConfig,
202
+ viewabilityConfigCallbackPairs: props.viewabilityConfigCallbackPairs,
203
+ onScrollToIndexFailed: listens('onScrollToIndexFailed')
204
+ ? (info) => emit('scrollToIndexFailed', info)
205
+ : undefined,
206
+ initialNumToRender: asNumber(props.initialNumToRender, DEFAULT_INITIAL_NUM_TO_RENDER),
207
+ initialScrollIndex: props.initialScrollIndex,
208
+ maxToRenderPerBatch: asNumber(props.maxToRenderPerBatch, DEFAULT_MAX_TO_RENDER_PER_BATCH),
209
+ updateCellsBatchingPeriod: asNumber(props.updateCellsBatchingPeriod, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD),
210
+ windowSize: asNumber(props.windowSize, DEFAULT_WINDOW_SIZE),
211
+ stickyHeaderIndices: props.stickyHeaderIndices,
212
+ maintainVisibleContentPosition: props.maintainVisibleContentPosition,
213
+ userOnScroll: isHandler(folded.onScroll) ? folded.onScroll : undefined,
214
+ style: props.style,
215
+ contentContainerStyle: props.contentContainerStyle,
216
+ keyboardShouldPersistTaps: props.keyboardShouldPersistTaps,
217
+ keyboardDismissMode: props.keyboardDismissMode,
218
+ forwarded: forwardAttrs(folded),
219
+ };
220
+ });
221
+ const keyFor = (index) => {
222
+ const p = narrowed.value;
223
+ const item = p.getItem(p.data, index);
224
+ return p.keyExtractor ? p.keyExtractor(item, index) : String(index);
225
+ };
226
+ // The windowing math, run through the shared @symbiote-native/components functions. It mutates the
227
+ // non-reactive committedWindow as it throttles (the controlled side effect React runs during
228
+ // render): committedWindow is plain state, so the write triggers no reactivity loop.
229
+ const metrics = computed(() => {
230
+ const p = narrowed.value;
231
+ void measureVersion.value;
232
+ const count = p.getItemCount(p.data);
233
+ const gil = p.getItemLayout;
234
+ const data = p.data;
235
+ const fixedLayout = gil !== undefined
236
+ ? (index) => {
237
+ const layout = gil(data, index);
238
+ return { length: layout.length, offset: layout.offset };
239
+ }
240
+ : undefined;
241
+ const averageLength = fixedLayout !== undefined
242
+ ? count > FIRST_INDEX
243
+ ? fixedLayout(FIRST_INDEX).length
244
+ : EMPTY_OFFSET
245
+ : averageMeasuredLength(measured);
246
+ const { offsets, lengths, total } = buildOffsets(count, measured, fixedLayout, averageLength);
247
+ const target = computeWindow(count, offsets, lengths, scrollOffset.value, viewportLength.value, p.windowSize, p.initialNumToRender);
248
+ const throttled = throttleWindow(target, committedWindow, p.maxToRenderPerBatch);
249
+ committedWindow = throttled;
250
+ return {
251
+ count,
252
+ offsets,
253
+ lengths,
254
+ total,
255
+ first: throttled.first,
256
+ last: throttled.last,
257
+ target,
258
+ fixedLayout,
259
+ averageLength,
260
+ };
261
+ });
262
+ const scrollToPixel = (offset, animated) => {
263
+ const p = narrowed.value;
264
+ const clamped = Math.max(EMPTY_OFFSET, offset);
265
+ const targetOffset = p.horizontal
266
+ ? { x: clamped, y: EMPTY_OFFSET }
267
+ : { x: EMPTY_OFFSET, y: clamped };
268
+ const handle = scrollHandle.value;
269
+ if (handle !== null) {
270
+ dlog(`Vue VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${p.horizontal})`);
271
+ handle.scrollTo({ x: targetOffset.x, y: targetOffset.y, animated });
272
+ return;
273
+ }
274
+ dlog(`Vue VirtualizedList scrollTo offset=${clamped} pending-ref`);
275
+ commandedOffset.value = targetOffset;
276
+ };
277
+ const offsetForIndexLocal = (index, viewPosition, viewOffset) => {
278
+ const m = metrics.value;
279
+ return offsetForIndex(index, viewPosition, viewOffset, m.count, m.offsets, m.lengths, viewportLength.value);
280
+ };
281
+ const onScroll = (event) => {
282
+ const p = narrowed.value;
283
+ const offset = readScrollOffset(event, p.horizontal);
284
+ if (offset === undefined)
285
+ return;
286
+ dlog(`Vue VirtualizedList onScroll offset=${offset}`);
287
+ // First scroll is the interaction that ungates waitForInteraction configs.
288
+ hasInteracted = true;
289
+ scrollOffset.value = offset;
290
+ // A real native scroll supersedes any pending commanded offset.
291
+ commandedOffset.value = undefined;
292
+ // Compose, don't clobber: internal windowing ran first, now the user's onScroll.
293
+ if (p.userOnScroll !== undefined)
294
+ p.userOnScroll(event);
295
+ };
296
+ const onViewportLayout = (event) => {
297
+ const length = readLayoutLength(event, narrowed.value.horizontal);
298
+ if (length === undefined)
299
+ return;
300
+ dlog(`Vue VirtualizedList onLayout viewport=${length}`);
301
+ viewportLength.value = length;
302
+ };
303
+ const makeCellMeasure = (index) => (event) => {
304
+ const p = narrowed.value;
305
+ if (p.getItemLayout !== undefined)
306
+ return;
307
+ const length = readLayoutLength(event, p.horizontal);
308
+ if (length === undefined)
309
+ return;
310
+ if (measured.get(index) === length)
311
+ return;
312
+ measured.set(index, length);
313
+ dlog(`Vue VirtualizedList cell ${index} measured length=${length}`);
314
+ measureVersion.value += 1;
315
+ };
316
+ const mergeSeparator = (gapIndex, patch) => {
317
+ const count = metrics.value.count;
318
+ if (gapIndex < FIRST_INDEX || gapIndex > count - 2)
319
+ return;
320
+ separatorOverrides.set(gapIndex, { ...separatorOverrides.get(gapIndex), ...patch });
321
+ separatorVersion.value += 1;
322
+ };
323
+ const makeSeparators = (index) => ({
324
+ highlight: () => {
325
+ dlog(`Vue VirtualizedList separator highlight cell=${index}`);
326
+ mergeSeparator(index - 1, { highlighted: true });
327
+ mergeSeparator(index, { highlighted: true });
328
+ },
329
+ unhighlight: () => {
330
+ dlog(`Vue VirtualizedList separator unhighlight cell=${index}`);
331
+ mergeSeparator(index - 1, { highlighted: false });
332
+ mergeSeparator(index, { highlighted: false });
333
+ },
334
+ updateProps: (select, newProps) => {
335
+ mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
336
+ },
337
+ });
338
+ // ---- imperative handle (the shared IVirtualizedListHandle surface) ------
339
+ const handle = {
340
+ scrollToOffset: (params) => {
341
+ scrollToPixel(params.offset, params.animated ?? true);
342
+ },
343
+ scrollToIndex: (params) => {
344
+ const p = narrowed.value;
345
+ const m = metrics.value;
346
+ // No getItemLayout AND the target is past the last measured cell: report the failure
347
+ // instead of scrolling to a fabricated estimate (RN VirtualizedList.js:179-195).
348
+ if (p.getItemLayout === undefined && params.index > highestMeasuredIndex(measured)) {
349
+ dlog(`Vue VirtualizedList onScrollToIndexFailed index=${params.index} ` +
350
+ `highestMeasured=${highestMeasuredIndex(measured)} (no getItemLayout)`);
351
+ p.onScrollToIndexFailed?.({
352
+ index: params.index,
353
+ highestMeasuredFrameIndex: highestMeasuredIndex(measured),
354
+ averageItemLength: m.averageLength,
355
+ });
356
+ return;
357
+ }
358
+ scrollToPixel(offsetForIndexLocal(params.index, params.viewPosition ?? FIRST_INDEX, params.viewOffset ?? EMPTY_OFFSET), params.animated ?? true);
359
+ },
360
+ scrollToItem: (params) => {
361
+ const p = narrowed.value;
362
+ const count = metrics.value.count;
363
+ for (let index = FIRST_INDEX; index < count; index += 1) {
364
+ if (p.getItem(p.data, index) === params.item) {
365
+ scrollToPixel(offsetForIndexLocal(index, params.viewPosition ?? FIRST_INDEX, EMPTY_OFFSET), params.animated ?? true);
366
+ return;
367
+ }
368
+ }
369
+ dlog('Vue VirtualizedList scrollToItem: item not found');
370
+ },
371
+ scrollToEnd: (params) => {
372
+ scrollToPixel(Math.max(EMPTY_OFFSET, metrics.value.total - viewportLength.value), params?.animated ?? true);
373
+ },
374
+ flashScrollIndicators: () => {
375
+ scrollHandle.value?.flashScrollIndicators();
376
+ },
377
+ getNativeScrollRef: () => scrollHandle.value,
378
+ getScrollableNode: () => scrollHandle.value,
379
+ getScrollResponder: () => scrollHandle.value,
380
+ getScrollNode: () => scrollHandle.value?.getScrollNode() ?? null,
381
+ recordInteraction: () => {
382
+ hasInteracted = true;
383
+ },
384
+ };
385
+ expose(handle);
386
+ // ---- after-commit effects (post-flush watchers, the Vue twin of useEffect) ----
387
+ // Batch fill: when the throttled window has not reached the target, schedule a re-render so
388
+ // the window keeps filling toward target over successive ticks (React's batch effect).
389
+ watch(metrics, m => {
390
+ if (batchTimer !== null) {
391
+ clearTimeout(batchTimer);
392
+ batchTimer = null;
393
+ }
394
+ if (m.first <= m.target.first && m.last >= m.target.last)
395
+ return;
396
+ batchTimer = setTimeout(() => {
397
+ batchTimer = null;
398
+ measureVersion.value += 1;
399
+ }, narrowed.value.updateCellsBatchingPeriod);
400
+ }, { flush: 'post' });
401
+ // onEndReached: fire only when the actual last cell is rendered AND within threshold; dedup by
402
+ // content length; re-arm on scroll away from the end (RN _maybeCallOnEdgeReached).
403
+ watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
404
+ const p = narrowed.value;
405
+ if (p.onEndReached === undefined || viewportLength.value <= EMPTY_OFFSET)
406
+ return;
407
+ const m = metrics.value;
408
+ const { distanceFromEnd, withinThreshold } = computeEndReached(m.total, scrollOffset.value, viewportLength.value, p.onEndReachedThreshold);
409
+ const lastCellRendered = m.last === m.count - 1;
410
+ if (withinThreshold && lastCellRendered && sentEndForContentLength !== m.total) {
411
+ sentEndForContentLength = m.total;
412
+ dlog(`Vue VirtualizedList onEndReached distanceFromEnd=${distanceFromEnd} ` +
413
+ `(last=${m.last} of ${m.count}, contentLength=${m.total})`);
414
+ p.onEndReached({ distanceFromEnd });
415
+ }
416
+ if (!withinThreshold)
417
+ sentEndForContentLength = NO_CONTENT_LENGTH_SENT;
418
+ }, { flush: 'post' });
419
+ // onStartReached: the top-edge twin of onEndReached.
420
+ watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
421
+ const p = narrowed.value;
422
+ if (p.onStartReached === undefined || viewportLength.value <= EMPTY_OFFSET)
423
+ return;
424
+ const m = metrics.value;
425
+ const { distanceFromStart, withinThreshold } = computeStartReached(scrollOffset.value, viewportLength.value, p.onStartReachedThreshold);
426
+ const firstCellRendered = m.first === FIRST_INDEX;
427
+ if (withinThreshold && firstCellRendered && sentStartForContentLength !== m.total) {
428
+ sentStartForContentLength = m.total;
429
+ dlog(`Vue VirtualizedList onStartReached distanceFromStart=${distanceFromStart} ` +
430
+ `(first=${m.first}, contentLength=${m.total})`);
431
+ p.onStartReached({ distanceFromStart });
432
+ }
433
+ if (!withinThreshold)
434
+ sentStartForContentLength = NO_CONTENT_LENGTH_SENT;
435
+ }, { flush: 'post' });
436
+ // Viewability: recompute which rendered cells clear the threshold and, if the viewable set
437
+ // changed, fire onViewableItemsChanged + every config/callback pair, honoring minimumViewTime.
438
+ watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
439
+ const p = narrowed.value;
440
+ const m = metrics.value;
441
+ const pairs = buildViewabilityPairs(p.onViewableItemsChanged, p.viewabilityConfig, p.viewabilityConfigCallbackPairs);
442
+ if (pairs.length === EMPTY_OFFSET || viewportLength.value <= EMPTY_OFFSET)
443
+ return;
444
+ if (m.count === FIRST_INDEX)
445
+ return;
446
+ const { tokens, map } = computeViewableSet({
447
+ first: m.first,
448
+ last: m.last,
449
+ count: m.count,
450
+ offsets: m.offsets,
451
+ lengths: m.lengths,
452
+ scrollOffset: scrollOffset.value,
453
+ viewportLength: viewportLength.value,
454
+ data: p.data,
455
+ getItem: p.getItem,
456
+ keyExtractor: p.keyExtractor,
457
+ pairs,
458
+ hasInteracted,
459
+ });
460
+ const diff = diffViewable(lastViewable, map, tokens);
461
+ if (!diff.hasChanged)
462
+ return;
463
+ const commitAndFire = () => {
464
+ lastViewable = map;
465
+ dlog(`Vue VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
466
+ `(window [${m.first}, ${m.last}])`);
467
+ const info = {
468
+ viewableItems: tokens,
469
+ changed: diff.changed,
470
+ };
471
+ for (const pair of pairs)
472
+ pair.onViewableItemsChanged(info);
473
+ };
474
+ const minimumViewTime = maxMinimumViewTime(pairs);
475
+ if (viewableTimer !== null) {
476
+ clearTimeout(viewableTimer);
477
+ viewableTimer = null;
478
+ }
479
+ if (minimumViewTime > EMPTY_OFFSET) {
480
+ dlog(`Vue VirtualizedList viewability debounce ${minimumViewTime}ms (window [${m.first}, ${m.last}])`);
481
+ viewableTimer = setTimeout(() => {
482
+ viewableTimer = null;
483
+ commitAndFire();
484
+ }, minimumViewTime);
485
+ return;
486
+ }
487
+ commitAndFire();
488
+ }, { flush: 'post' });
489
+ // initialScrollIndex: once the first viewport is known, jump to that index a single time.
490
+ watch(() => [narrowed.value, viewportLength.value, metrics.value], () => {
491
+ const p = narrowed.value;
492
+ if (p.initialScrollIndex === undefined || appliedInitialScroll)
493
+ return;
494
+ if (viewportLength.value <= EMPTY_OFFSET || metrics.value.count === FIRST_INDEX)
495
+ return;
496
+ appliedInitialScroll = true;
497
+ // The initial jump is instant (RN does not animate initialScrollIndex).
498
+ scrollToPixel(offsetForIndexLocal(p.initialScrollIndex, FIRST_INDEX, EMPTY_OFFSET), false);
499
+ }, { flush: 'post' });
500
+ // maintainVisibleContentPosition JS anchor adjustment: track the key of the item at
501
+ // minIndexForVisible; when a prepend moves it down, add the inserted extent in the leading
502
+ // SPACER (the off-window items native MVCP cannot see) to scrollOffset so the anchored item
503
+ // does not jump (RN getDerivedStateFromProps). Runs post-flush so the correction lands fast.
504
+ watch(() => [narrowed.value, metrics.value, scrollOffset.value], () => {
505
+ const p = narrowed.value;
506
+ const m = metrics.value;
507
+ if (p.maintainVisibleContentPosition === undefined || m.count === FIRST_INDEX) {
508
+ firstVisibleKey = null;
509
+ return;
510
+ }
511
+ const minIndexForVisible = p.maintainVisibleContentPosition.minIndexForVisible;
512
+ const newFirstVisibleKey = m.count > minIndexForVisible ? keyFor(minIndexForVisible) : null;
513
+ const prevKey = firstVisibleKey;
514
+ if (prevKey !== null && newFirstVisibleKey !== null && prevKey !== newFirstVisibleKey) {
515
+ let anchorIndex = -1;
516
+ for (let index = minIndexForVisible; index < m.count; index += 1) {
517
+ if (keyFor(index) === prevKey) {
518
+ anchorIndex = index;
519
+ break;
520
+ }
521
+ }
522
+ if (anchorIndex > minIndexForVisible) {
523
+ const spacerEnd = Math.min(anchorIndex, committedWindow.first);
524
+ const insertedExtent = spacerEnd > minIndexForVisible
525
+ ? m.offsets[spacerEnd] - m.offsets[minIndexForVisible]
526
+ : EMPTY_OFFSET;
527
+ if (insertedExtent > EMPTY_OFFSET) {
528
+ const autoThreshold = p.maintainVisibleContentPosition.autoscrollToTopThreshold;
529
+ const anchoredNearTop = autoThreshold !== undefined && scrollOffset.value <= autoThreshold;
530
+ if (anchoredNearTop) {
531
+ dlog(`Vue VirtualizedList MVCP autoscroll-to-top (offset=${scrollOffset.value} <= ${autoThreshold})`);
532
+ scrollToPixel(EMPTY_OFFSET, true);
533
+ }
534
+ else {
535
+ dlog(`Vue VirtualizedList MVCP adjust +${insertedExtent}px ` +
536
+ `(anchor "${prevKey}" moved ${minIndexForVisible}->${anchorIndex})`);
537
+ scrollToPixel(scrollOffset.value + insertedExtent, false);
538
+ }
539
+ }
540
+ }
541
+ }
542
+ firstVisibleKey = newFirstVisibleKey;
543
+ }, { flush: 'post' });
544
+ onBeforeUnmount(() => {
545
+ if (viewableTimer !== null)
546
+ clearTimeout(viewableTimer);
547
+ if (batchTimer !== null)
548
+ clearTimeout(batchTimer);
549
+ });
550
+ return () => {
551
+ const p = narrowed.value;
552
+ const m = metrics.value;
553
+ // Read the version refs so a separator/measurement bump re-renders.
554
+ void separatorVersion.value;
555
+ if (p.renderItem === undefined)
556
+ dlog('Vue VirtualizedList: no #item slot provided — cells render empty');
557
+ dlog(`Vue VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
558
+ `(offset=${scrollOffset.value}, viewport=${viewportLength.value}, rendered=${Math.max(0, m.last - m.first + 1)})`);
559
+ const children = [];
560
+ const stickySet = p.stickyHeaderIndices !== undefined ? new Set(p.stickyHeaderIndices) : undefined;
561
+ const header = resolveElement(p.listHeaderComponent);
562
+ if (header !== undefined) {
563
+ children.push(h('symbiote-view', { key: 'list-header' }, [header]));
564
+ }
565
+ let renderedStickyIndices = [];
566
+ if (m.count === FIRST_INDEX) {
567
+ const empty = resolveElement(p.listEmptyComponent);
568
+ if (empty !== undefined) {
569
+ children.push(h('symbiote-view', { key: 'list-empty' }, [empty]));
570
+ }
571
+ }
572
+ else {
573
+ const plan = buildListPlan({
574
+ count: m.count,
575
+ first: m.first,
576
+ last: m.last,
577
+ offsets: m.offsets,
578
+ lengths: m.lengths,
579
+ total: m.total,
580
+ keyFor,
581
+ stickyIndices: stickySet,
582
+ hasHeader: header !== undefined,
583
+ hasSeparators: p.itemSeparatorComponent !== undefined,
584
+ });
585
+ renderedStickyIndices = plan.stickyChildPositions;
586
+ if (plan.leadingExtent > EMPTY_OFFSET) {
587
+ children.push(h('symbiote-view', {
588
+ key: 'spacer-leading',
589
+ style: p.horizontal ? { width: plan.leadingExtent } : { height: plan.leadingExtent },
590
+ }));
591
+ }
592
+ const cellInverted = p.inverted
593
+ ? p.horizontal
594
+ ? INVERTED_X_STYLE
595
+ : INVERTED_Y_STYLE
596
+ : undefined;
597
+ for (let cellPos = 0; cellPos < plan.cells.length; cellPos += 1) {
598
+ const cell = plan.cells[cellPos];
599
+ const item = p.getItem(p.data, cell.index);
600
+ const content = p.renderItem?.({
601
+ item,
602
+ index: cell.index,
603
+ separators: makeSeparators(cell.index),
604
+ });
605
+ children.push(h('symbiote-view', {
606
+ key: `cell-${cell.key}`,
607
+ onLayout: makeCellMeasure(cell.index),
608
+ style: cellInverted,
609
+ }, [content]));
610
+ if (cell.index < m.last) {
611
+ const separator = renderSeparatorElement(p.itemSeparatorComponent, item, p.getItem(p.data, cell.index + 1), separatorOverrides.get(cell.index));
612
+ if (separator !== undefined) {
613
+ children.push(h('symbiote-view', { key: `sep-${cell.key}` }, [separator]));
614
+ }
615
+ }
616
+ }
617
+ if (plan.trailingExtent > EMPTY_OFFSET) {
618
+ children.push(h('symbiote-view', {
619
+ key: 'spacer-trailing',
620
+ style: p.horizontal
621
+ ? { width: plan.trailingExtent }
622
+ : { height: plan.trailingExtent },
623
+ }));
624
+ }
625
+ }
626
+ const footer = resolveElement(p.listFooterComponent);
627
+ if (footer !== undefined) {
628
+ children.push(h('symbiote-view', { key: 'list-footer' }, [footer]));
629
+ }
630
+ const resolvedContentContainerStyle = p.horizontal
631
+ ? [p.contentContainerStyle, { width: m.total }]
632
+ : p.contentContainerStyle;
633
+ const resolvedStyle = p.inverted
634
+ ? [p.style, p.horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE]
635
+ : p.style;
636
+ const scrollProps = {
637
+ ...p.forwarded,
638
+ style: resolvedStyle,
639
+ contentContainerStyle: resolvedContentContainerStyle,
640
+ horizontal: p.horizontal,
641
+ onScroll,
642
+ onLayout: onViewportLayout,
643
+ ref: setScrollHandle,
644
+ };
645
+ // The raw scroll-lifecycle callbacks (onScrollBeginDrag/…) and scrollEventThrottle are NOT in
646
+ // PROP_KEYS, so they already ride through via ...p.forwarded. The keyboard props come from
647
+ // typed props (PROP_KEYS), so re-set them explicitly here (only when provided).
648
+ if (p.keyboardShouldPersistTaps !== undefined) {
649
+ scrollProps.keyboardShouldPersistTaps = p.keyboardShouldPersistTaps;
650
+ }
651
+ if (p.keyboardDismissMode !== undefined) {
652
+ scrollProps.keyboardDismissMode = p.keyboardDismissMode;
653
+ }
654
+ // A pending imperative/initial scroll rides down as contentOffset (fallback for the
655
+ // pre-mount window before the handle attaches).
656
+ if (commandedOffset.value !== undefined)
657
+ scrollProps.contentOffset = commandedOffset.value;
658
+ // Headers in the window stick; an empty list leaves the prop off entirely.
659
+ if (renderedStickyIndices.length > 0)
660
+ scrollProps.stickyHeaderIndices = renderedStickyIndices;
661
+ // Forward maintainVisibleContentPosition to the ScrollView so it anchors the in-window cells.
662
+ // minIndexForVisible is bumped by 1 when a ListHeaderComponent occupies child 0.
663
+ if (p.maintainVisibleContentPosition !== undefined) {
664
+ scrollProps.maintainVisibleContentPosition = {
665
+ ...p.maintainVisibleContentPosition,
666
+ minIndexForVisible: p.maintainVisibleContentPosition.minIndexForVisible + (header !== undefined ? 1 : 0),
667
+ };
668
+ }
669
+ // Pull-to-refresh: when a @refresh listener is present, build a RefreshControl for the
670
+ // ScrollView's refreshControl prop (iOS sibling / Android wrap, owned by ScrollView). The
671
+ // onRefresh bridge is gated on the listener, so an unlistened list builds no control.
672
+ if (p.onRefresh !== undefined) {
673
+ dlog('Vue VirtualizedList wiring RefreshControl (@refresh listened)');
674
+ scrollProps.refreshControl = h(RefreshControl, {
675
+ refreshing: p.refreshing,
676
+ onRefresh: p.onRefresh,
677
+ progressViewOffset: p.progressViewOffset,
678
+ });
679
+ }
680
+ return h(ScrollView, scrollProps, { default: () => children });
681
+ };
682
+ }, {
683
+ name: 'VirtualizedList',
684
+ inheritAttrs: false,
685
+ props: PROP_KEYS,
686
+ emits: EMIT_KEYS,
687
+ });