@symbiote-native/angular 2.0.0 → 3.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 (97) hide show
  1. package/README.md +5 -4
  2. package/babel-register-composed.cjs +0 -8
  3. package/build/angular/callback-host.d.ts +25 -0
  4. package/build/angular/callback-host.js +211 -0
  5. package/build/angular/callback-host.js.map +1 -0
  6. package/build/angular/change-detection-flush.d.ts +14 -2
  7. package/build/angular/change-detection-flush.js +54 -6
  8. package/build/angular/change-detection-flush.js.map +1 -1
  9. package/build/angular/components/flat-list/index.js +39 -86
  10. package/build/angular/components/flat-list/index.js.map +1 -1
  11. package/build/angular/components/image-shared.js +49 -110
  12. package/build/angular/components/image-shared.js.map +1 -1
  13. package/build/angular/components/keyboard-avoiding-view/index.js +33 -72
  14. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  15. package/build/angular/components/modal/index.js +58 -103
  16. package/build/angular/components/modal/index.js.map +1 -1
  17. package/build/angular/components/pressable-props.d.ts +2 -0
  18. package/build/angular/components/section-list/index.js +20 -103
  19. package/build/angular/components/section-list/index.js.map +1 -1
  20. package/build/angular/components/touchable-props.d.ts +1 -0
  21. package/build/angular/components/virtualized-list/directives.js +32 -22
  22. package/build/angular/components/virtualized-list/directives.js.map +1 -1
  23. package/build/angular/components/virtualized-list/index.d.ts +7 -1
  24. package/build/angular/components/virtualized-list/index.js +350 -359
  25. package/build/angular/components/virtualized-list/index.js.map +1 -1
  26. package/build/angular/components/virtualized-section-list/directives.js +20 -12
  27. package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
  28. package/build/angular/components/virtualized-section-list/index.js +36 -142
  29. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  30. package/build/angular/components.d.ts +1 -1
  31. package/build/angular/components.js +1 -1
  32. package/build/angular/components.js.map +1 -1
  33. package/build/angular/create-portal/index.js +9 -8
  34. package/build/angular/create-portal/index.js.map +1 -1
  35. package/build/angular/create-tunnel/index.js +13 -12
  36. package/build/angular/create-tunnel/index.js.map +1 -1
  37. package/build/angular/descriptor-to-angular/index.js +6 -6
  38. package/build/angular/descriptor-to-angular/index.js.map +1 -1
  39. package/build/angular/diagnostics.d.ts +13 -0
  40. package/build/angular/diagnostics.js +18 -0
  41. package/build/angular/diagnostics.js.map +1 -1
  42. package/build/angular/element-props.d.ts +1 -3
  43. package/build/angular/elements.d.ts +46 -32
  44. package/build/angular/elements.js +217 -434
  45. package/build/angular/elements.js.map +1 -1
  46. package/build/angular/index.d.ts +3 -1
  47. package/build/angular/index.js +44 -13
  48. package/build/angular/index.js.map +1 -1
  49. package/build/angular/modules/animated/animated-leaf-binder.js +0 -2
  50. package/build/angular/modules/animated/animated-leaf-binder.js.map +1 -1
  51. package/build/angular/modules/animated/create-animated-component.js +69 -74
  52. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  53. package/build/angular/modules/animated/index.d.ts +30 -19
  54. package/build/angular/modules/status-bar/index.js +2 -8
  55. package/build/angular/modules/status-bar/index.js.map +1 -1
  56. package/build/angular/primitives/index.d.ts +14 -28
  57. package/build/angular/primitives/index.js +44 -107
  58. package/build/angular/primitives/index.js.map +1 -1
  59. package/build/angular/primitives/shared.d.ts +1 -0
  60. package/build/angular/primitives/shared.js +47 -32
  61. package/build/angular/primitives/shared.js.map +1 -1
  62. package/build/angular/render/index.js +34 -1
  63. package/build/angular/render/index.js.map +1 -1
  64. package/build/angular/renderer/index.d.ts +56 -0
  65. package/build/angular/renderer/index.js +295 -114
  66. package/build/angular/renderer/index.js.map +1 -1
  67. package/build/angular/runtime-matching.d.ts +9 -0
  68. package/build/angular/runtime-matching.js +82 -0
  69. package/build/angular/runtime-matching.js.map +1 -0
  70. package/build/angular/services/color-scheme.service.js +4 -4
  71. package/build/angular/services/color-scheme.service.js.map +1 -1
  72. package/build/angular/services/window-dimensions.service.js +4 -4
  73. package/build/angular/services/window-dimensions.service.js.map +1 -1
  74. package/build/angular/style-host.d.ts +11 -0
  75. package/build/angular/style-host.js +86 -0
  76. package/build/angular/style-host.js.map +1 -0
  77. package/metro-config.cjs +32 -16
  78. package/package.json +5 -5
  79. package/src/callback-host.ts +229 -0
  80. package/src/change-detection-flush.ts +62 -9
  81. package/src/components/flat-list/index.ts +2 -1
  82. package/src/components/keyboard-avoiding-view/index.ts +8 -0
  83. package/src/components/pressable-props.ts +8 -0
  84. package/src/components/touchable-props.ts +4 -0
  85. package/src/components/virtualized-list/index.ts +98 -45
  86. package/src/components.ts +0 -2
  87. package/src/diagnostics.ts +18 -0
  88. package/src/element-props.ts +1 -5
  89. package/src/elements.ts +159 -38
  90. package/src/index.ts +47 -14
  91. package/src/primitives/index.ts +14 -70
  92. package/src/primitives/shared.ts +21 -6
  93. package/src/render/index.ts +44 -0
  94. package/src/renderer/index.ts +347 -104
  95. package/src/runtime-matching.ts +80 -0
  96. package/src/style-host.ts +94 -0
  97. package/tsconfig.angular.base.json +11 -0
@@ -24,7 +24,7 @@
24
24
  // Lists have no Descriptor render fn — the cell content is the framework's own children.
25
25
  // Cells/spacers are plain view host elements.
26
26
  import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, Input, Output, ViewChild, computed, inject, signal, } from '@angular/core';
27
- 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, buildListPlan, buildViewabilityPairs, computeWindow, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readLayoutOffset, readScrollOffset, reduceList, resolveAccessibilityProps, resolveItemKey, buildScrollViewHandle, } from '@symbiote-native/components';
27
+ import { DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildViewabilityPairs, computeWindow, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readLayoutOffset, readScrollOffset, reduceList, resolveAccessibilityProps, resolveItemKey, buildScrollViewHandle, } from '@symbiote-native/components';
28
28
  import { dlog, flattenStyle, isSymbioteNode, } from '@symbiote-native/engine';
29
29
  import { countAngular } from '../../diagnostics';
30
30
  import { anchorHostStyle, SymbioteHostPropsDirective, SymbioteStyleInputDirective, ViewHost, } from '../../primitives';
@@ -40,250 +40,208 @@ export { VListEmptyDirective, VListFooterDirective, VListHeaderDirective, VListI
40
40
  // ngc's partial evaluator needs the decorator's `template` to be one literal), so the body is
41
41
  // duplicated across the two branches — the same shape Svelte's own `{#if horizontal}` takes.
42
42
  export class VirtualizedList {
43
- // The list's edge/viewability/failure events as real Angular events: `(endReached)="…"`, not
44
- // `[onEndReached]="…"`. See handleRefresh/accessibility*Tick below for how the still-callback-
45
- // shaped scroll-view/refresh-control bag keys are fed from these.
46
- endReached = new EventEmitter();
47
- startReached = new EventEmitter();
48
- refresh = new EventEmitter();
49
- viewableItemsChanged = new EventEmitter();
50
- scrollToIndexFailed = new EventEmitter();
51
- accessibilityAction = new EventEmitter();
52
- accessibilityTap = new EventEmitter();
53
- magicTap = new EventEmitter();
54
- accessibilityEscape = new EventEmitter();
55
- // This wrapper binds the four gated accessibility events on the component it renders, which
56
- // Angular forces to be unconditional and which would light that component's gates on every
57
- // instance. It answers for them instead see `gate-demand.ts`.
58
- gateDemandAbove = injectGateDemandAbove();
43
+ constructor() {
44
+ // The list's edge/viewability/failure events as real Angular events: `(endReached)="…"`, not
45
+ // `[onEndReached]="…"`. See handleRefresh/accessibility*Tick below for how the still-callback-
46
+ // shaped scroll-view/refresh-control bag keys are fed from these.
47
+ this.endReached = new EventEmitter();
48
+ this.startReached = new EventEmitter();
49
+ this.refresh = new EventEmitter();
50
+ this.viewableItemsChanged = new EventEmitter();
51
+ this.scrollToIndexFailed = new EventEmitter();
52
+ this.accessibilityAction = new EventEmitter();
53
+ this.accessibilityTap = new EventEmitter();
54
+ this.magicTap = new EventEmitter();
55
+ this.accessibilityEscape = new EventEmitter();
56
+ // This wrapper binds the four gated accessibility events on the component it renders, which
57
+ // Angular forces to be unconditional and which would light that component's gates on every
58
+ // instance. It answers for them instead — see `gate-demand.ts`.
59
+ this.gateDemandAbove = injectGateDemandAbove();
60
+ // `buildScrollViewHandle` reads the node lazily through the getter above and no-ops before
61
+ // commit — the same function every other adapter feeds from its own host-instance accessor.
62
+ this.scrollHandle = buildScrollViewHandle(() => this.scrollNode);
63
+ // --- template-bound view state, assembled in ngDoCheck (recomputeView) ---
64
+ this.itemCount = EMPTY_OFFSET;
65
+ this.windowCells = [];
66
+ // The nearest sticky-header cell force-mounted outside [first,last] (plan.forcedStickyCell), the
67
+ // Angular twin of RN's _ensureClosestStickyHeader. Rendered ahead of windowCells, right after the
68
+ // leading spacer, with gapSpacerStyle filling the gap to the window's own first cell — see
69
+ // buildListPlan's doc comment for the exact child order this assumes.
70
+ this.forcedStickyCell = null;
71
+ this.leadingSpacerStyle = null;
72
+ this.gapSpacerStyle = null;
73
+ this.trailingSpacerStyle = null;
74
+ this.cellStyle = undefined;
75
+ // Bound to the template's `[style]="resolvedStyle"`, which Angular compiles to the built-in
76
+ // ɵɵstyleMap instruction — it only understands a flat object, never an array (RN's own
77
+ // `style={[a, b]}` composition idiom crashes deep inside Angular's styling engine), so this
78
+ // is always pre-flattened via the engine's own flattenStyle, never left as an array.
79
+ this.resolvedStyle = undefined;
80
+ this.resolvedContentContainerStyle = undefined;
81
+ this.resolvedMaintainVisibleContentPosition = undefined;
82
+ // The offset we are imperatively driving native to before the scroll handle attaches (rides down
83
+ // as contentOffset). A fresh object identity each push so the commit re-applies a repeated value.
84
+ this.commandedOffset = undefined;
85
+ // The one folded state cell (the Angular twin of React's stateRef): scroll offset, viewport,
86
+ // measured lengths, committed window, edge/viewability dedup, MVCP anchor — all in IListState,
87
+ // driven by the shared reduceList. `renderVersion` bumps whenever a transition changes render
88
+ // state, so ngDoCheck's recompute-dedup can detect it (listState is a plain object, not tracked).
89
+ this.listState = createInitialListState();
90
+ this.renderVersion = EMPTY_OFFSET;
91
+ this.cellMeasures = new Map();
92
+ // A cell's context object is the ONLY thing its stamped view reads, and VListOutletDirective
93
+ // refreshes that view whenever the object's IDENTITY changes. recomputeView runs on every frame
94
+ // the window moves, so minting a fresh context per cell made a four-row slide cost a refresh of
95
+ // EVERY cell in the window. MEASURED on PATH B geometry (544 entries, 320px viewport, RN
96
+ // defaults), per fling frame: 233-cell window, 4 cells genuinely entering, 228 outlet updates —
97
+ // each one a copyContextFields plus a markForCheck that walks to the root. Solid, same geometry,
98
+ // same shared reducer, same engine output: 4. Handing back the SAME object for a cell whose item
99
+ // and index did not move keeps `[vListOutletContext]` reference-equal, so ngOnChanges never fires
100
+ // for it. The fast adapters get this by construction — Solid's <For> is keyed on the cell KEY
101
+ // strings precisely so "the plan's freshly-built cell objects" cannot rebuild every row
102
+ // (adapters/solid/src/components/virtualized-list/shared.tsx, the cellKeys memo).
103
+ this.cellContexts = new Map();
104
+ this.nextCellContexts = new Map();
105
+ // Everything a cell's CONTENT can depend on that is not its own item identity. RN's contract for
106
+ // "re-render the cells, the data object itself did not change" is extraData; without it here an
107
+ // extraData flip would reuse every context and repaint nothing.
108
+ this.cellContextEpoch = undefined;
109
+ this.separatorOverrides = new Map();
110
+ // The adapter-owned debounce (minimumViewTime) and incremental-fill timers; the reducer only
111
+ // hands back a delay.
112
+ this.viewableTimer = null;
113
+ this.batchTimer = null;
114
+ // Dedupes the after-commit effects: they run only when the windowing signature changed.
115
+ this.lastEffectSignature = '';
116
+ // Dedupes ngDoCheck's own recompute. WITHOUT this, recomputeView() unconditionally rebuilds
117
+ // windowCells (and each cell's `separators` handle — fresh closures every call) on EVERY CD pass,
118
+ // including ones triggered by something else entirely in the app. A fresh context object flows
119
+ // into VListOutletDirective, which (correctly) sees its `context` @Input() change and calls
120
+ // `viewRef.markForCheck()` — which reschedules ANOTHER change-detection tick via the zoneless
121
+ // scheduler (adapters/angular/src/render.ts). Recomputing unconditionally therefore free-runs
122
+ // forever the moment a list actually has cells to stamp (a real, previously-dormant bug only
123
+ // exposed once flat-list.test.ts's projection fix let cells render for the first time — see that
124
+ // test's comment). Guarding recompute behind "did anything relevant actually change" breaks the
125
+ // cycle: unrelated CD passes reuse the same windowCells/context identities, so
126
+ // VListOutletDirective's ngOnChanges sees no change and never reschedules.
127
+ this.lastRecompute = undefined;
128
+ this.cdr = inject(ChangeDetectorRef);
129
+ // This component's OWN host — the non-painting anchor `class="..."` at the use site resolves
130
+ // onto (see anchorHostStyle's doc comment) — NOT `scrollHostRef` above, which targets the real
131
+ // inner `<scroll-view>`/`<horizontal-scroll-view>` tag one level down.
132
+ this.elementRef = inject(ElementRef);
133
+ // Bridges the non-reactive @Input fields into the reactive graph so `foldedAccessibility` below
134
+ // can memoize a bag derived from them (read its own comment for why signal inputs are not an
135
+ // option while this package's unit suite runs on JIT).
136
+ //
137
+ // ONLY safe for a bag whose every dependency is an @Input. This list's window machinery
138
+ // (listState, renderVersion, windowCells, the measured metrics) is driven from scroll/layout
139
+ // callbacks that never touch ngOnChanges, so nothing derived from it may ride this signal - it
140
+ // would memoize a window that stops re-computing mid-scroll. Make that state its own signal
141
+ // instead of widening this one.
142
+ this.inputsRevision = signal(0, /* @ts-ignore */
143
+ ...(ngDevMode ? [{ debugName: "inputsRevision" }] : /* istanbul ignore next */ []));
144
+ // MEASURED (while ScrollView was still a wrapper): the template fed 20 separate inputs off this
145
+ // one bag, and Angular does not cache a getter across binding expressions - as a getter it
146
+ // rebuilt the object 20 times per refresh, and every consumer saw a fresh reference, so all 20
147
+ // reported "changed" and wrote through. A computed evaluates once and hands back the SAME object
148
+ // until an input actually changes. Every dependency below is an @Input, which is what makes
149
+ // inputsRevision a complete dependency set here — `scrollViewBag()` reuses this result rather
150
+ // than re-running the fold.
151
+ this.foldedAccessibility = computed(() => {
152
+ this.inputsRevision();
153
+ return resolveAccessibilityProps({
154
+ testID: this.testID,
155
+ nativeID: this.nativeID,
156
+ accessible: this.accessible,
157
+ accessibilityLabel: this.accessibilityLabel,
158
+ accessibilityHint: this.accessibilityHint,
159
+ accessibilityRole: this.accessibilityRole,
160
+ accessibilityState: this.accessibilityState,
161
+ accessibilityValue: this.accessibilityValue,
162
+ accessibilityActions: this.accessibilityActions,
163
+ accessibilityLabelledBy: this.accessibilityLabelledBy,
164
+ importantForAccessibility: this.importantForAccessibility,
165
+ accessibilityLiveRegion: this.accessibilityLiveRegion,
166
+ screenReaderFocusable: this.screenReaderFocusable,
167
+ accessibilityViewIsModal: this.accessibilityViewIsModal,
168
+ accessibilityElementsHidden: this.accessibilityElementsHidden,
169
+ accessibilityIgnoresInvertColors: this.accessibilityIgnoresInvertColors,
170
+ accessibilityLanguage: this.accessibilityLanguage,
171
+ accessibilityRespondsToUserInteraction: this.accessibilityRespondsToUserInteraction,
172
+ accessibilityShowsLargeContentViewer: this.accessibilityShowsLargeContentViewer,
173
+ accessibilityLargeContentTitle: this.accessibilityLargeContentTitle,
174
+ role: this.role,
175
+ 'aria-label': this.ariaLabel,
176
+ 'aria-labelledby': this.ariaLabelledBy,
177
+ 'aria-live': this.ariaLive,
178
+ 'aria-hidden': this.ariaHidden,
179
+ 'aria-busy': this.ariaBusy,
180
+ 'aria-checked': this.ariaChecked,
181
+ 'aria-disabled': this.ariaDisabled,
182
+ 'aria-expanded': this.ariaExpanded,
183
+ 'aria-selected': this.ariaSelected,
184
+ 'aria-modal': this.ariaModal,
185
+ 'aria-valuemax': this.ariaValueMax,
186
+ 'aria-valuemin': this.ariaValueMin,
187
+ 'aria-valuenow': this.ariaValueNow,
188
+ 'aria-valuetext': this.ariaValueText,
189
+ });
190
+ }, /* @ts-ignore */
191
+ ...(ngDevMode ? [{ debugName: "foldedAccessibility" }] : /* istanbul ignore next */ []));
192
+ // onScroll stays a plain callback bag key (an Animated.event(...) target must be able to flow
193
+ // through it, which an @Output() can't carry), so this is a stable arrow field read straight into
194
+ // scrollViewBag()'s `onScroll`. onLayout is unconditional too — this list always needs its own
195
+ // layout for windowing, regardless of whether an app subscribes to anything.
196
+ this.onScrollTick = (event) => {
197
+ countAngular('scrollTicks');
198
+ const offset = readScrollOffset(event, this.isHorizontal);
199
+ if (offset === undefined)
200
+ return;
201
+ dlog(`Angular VirtualizedList onScroll offset=${offset}`);
202
+ // A real native scroll supersedes any pending commanded offset.
203
+ this.commandedOffset = undefined;
204
+ this.dispatch({ kind: 'scroll', offset });
205
+ // Compose, don't clobber: internal windowing ran first, now the user's onScroll.
206
+ this.onScroll?.(event);
207
+ };
208
+ this.onLayoutTick = (event) => {
209
+ const length = readLayoutLength(event, this.isHorizontal);
210
+ if (length === undefined)
211
+ return;
212
+ dlog(`Angular VirtualizedList onLayout viewport=${length}`);
213
+ this.dispatch({ kind: 'layout', length });
214
+ };
215
+ // RefreshControl's refresh and the scroll tag's accessibility events reach here as plain
216
+ // listener-prop values in scrollViewBag()/refreshControlBag() — these fields just adapt
217
+ // VirtualizedList's own @Output() into a plain re-emit callback.
218
+ this.handleRefresh = () => {
219
+ this.refresh.emit();
220
+ };
221
+ this.accessibilityActionTick = (event) => {
222
+ this.accessibilityAction.emit(event);
223
+ };
224
+ this.accessibilityTapTick = (event) => {
225
+ this.accessibilityTap.emit(event);
226
+ };
227
+ this.magicTapTick = (event) => {
228
+ this.magicTap.emit(event);
229
+ };
230
+ this.accessibilityEscapeTick = (event) => {
231
+ this.accessibilityEscape.emit(event);
232
+ };
233
+ this.keyFor = (index) => {
234
+ const item = this.getItem(this.data, index);
235
+ return resolveItemKey(item, index, this.keyExtractor);
236
+ };
237
+ }
59
238
  wantsGate(name) {
60
239
  return gateWanted(this.gateDemandAbove, name, this[name]);
61
240
  }
62
- data;
63
- getItem;
64
- getItemCount;
65
- keyExtractor;
66
- getItemLayout;
67
- horizontal;
68
- inverted;
69
- extraData;
70
- onEndReachedThreshold;
71
- onStartReachedThreshold;
72
- refreshing;
73
- progressViewOffset;
74
- // Set explicitly by a wrapper (FlatList/VirtualizedSectionList) that ALWAYS binds
75
- // `(refresh)="refresh.emit()"` on this component to re-forward the event outward — that binding
76
- // itself makes `this.refresh.observed` permanently true (Angular subscribes unconditionally the
77
- // moment a template writes `(refresh)="…"`, regardless of what the handler does), so `.observed`
78
- // can no longer tell "the app actually wants pull-to-refresh" from "a wrapper is just forwarding
79
- // the event". A wrapper passes its OWN public `refresh.observed` here instead; direct usage
80
- // (no wrapper) falls back to this component's own `.observed`, unchanged from before.
81
- refreshRequested;
82
- viewabilityConfig;
83
- viewabilityConfigCallbackPairs;
84
- initialNumToRender;
85
- initialScrollIndex;
86
- maxToRenderPerBatch;
87
- updateCellsBatchingPeriod;
88
- windowSize;
89
- stickyHeaderIndices;
90
- maintainVisibleContentPosition;
91
- onScroll;
92
- onScrollBeginDrag;
93
- onScrollEndDrag;
94
- onMomentumScrollBegin;
95
- onMomentumScrollEnd;
96
- scrollEventThrottle;
97
- keyboardShouldPersistTaps;
98
- keyboardDismissMode;
99
- style;
100
- contentContainerStyle;
101
- testID;
102
- nativeID;
103
- accessible;
104
- accessibilityLabel;
105
- accessibilityHint;
106
- accessibilityRole;
107
- accessibilityState;
108
- accessibilityValue;
109
- accessibilityActions;
110
- accessibilityLabelledBy;
111
- importantForAccessibility;
112
- accessibilityLiveRegion;
113
- screenReaderFocusable;
114
- accessibilityViewIsModal;
115
- accessibilityElementsHidden;
116
- accessibilityIgnoresInvertColors;
117
- accessibilityLanguage;
118
- accessibilityRespondsToUserInteraction;
119
- accessibilityShowsLargeContentViewer;
120
- accessibilityLargeContentTitle;
121
- role;
122
- ariaLabel;
123
- ariaLabelledBy;
124
- ariaLive;
125
- ariaHidden;
126
- ariaBusy;
127
- ariaChecked;
128
- ariaDisabled;
129
- ariaExpanded;
130
- ariaSelected;
131
- ariaModal;
132
- ariaValueMax;
133
- ariaValueMin;
134
- ariaValueNow;
135
- ariaValueText;
136
- // The cell + slot templates the app authors (the Angular twin of renderItem / ListHeaderComponent
137
- // / ListFooterComponent / ListEmptyComponent / ItemSeparatorComponent), captured from projected
138
- // <ng-template> content. Resolved by ngAfterContentInit, read each ngDoCheck (from the 2nd CD on).
139
- itemDir;
140
- headerDir;
141
- footerDir;
142
- emptyDir;
143
- separatorDir;
144
- // The inner scroll TAG's own engine node — a template ref on a bare intrinsic hands back the
145
- // host node directly (`isSymbioteNode(elementRef.nativeElement)`, matching `anchorHostStyle`'s
146
- // own reader below), so there is no wrapper component left to read a handle off. Present from
147
- // whichever branch (`scroll-view` / `horizontal-scroll-view`) is currently mounted, once the
148
- // template has run at least one CD pass.
149
- scrollHostRef;
150
241
  get scrollNode() {
151
242
  const node = this.scrollHostRef?.nativeElement;
152
243
  return isSymbioteNode(node) ? node : null;
153
244
  }
154
- // `buildScrollViewHandle` reads the node lazily through the getter above and no-ops before
155
- // commit — the same function every other adapter feeds from its own host-instance accessor.
156
- scrollHandle = buildScrollViewHandle(() => this.scrollNode);
157
- // --- template-bound view state, assembled in ngDoCheck (recomputeView) ---
158
- itemCount = EMPTY_OFFSET;
159
- windowCells = [];
160
- // The nearest sticky-header cell force-mounted outside [first,last] (plan.forcedStickyCell), the
161
- // Angular twin of RN's _ensureClosestStickyHeader. Rendered ahead of windowCells, right after the
162
- // leading spacer, with gapSpacerStyle filling the gap to the window's own first cell — see
163
- // buildListPlan's doc comment for the exact child order this assumes.
164
- forcedStickyCell = null;
165
- leadingSpacerStyle = null;
166
- gapSpacerStyle = null;
167
- trailingSpacerStyle = null;
168
- cellStyle = undefined;
169
- renderedStickyIndices = undefined;
170
- // Bound to the template's `[style]="resolvedStyle"`, which Angular compiles to the built-in
171
- // ɵɵstyleMap instruction — it only understands a flat object, never an array (RN's own
172
- // `style={[a, b]}` composition idiom crashes deep inside Angular's styling engine), so this
173
- // is always pre-flattened via the engine's own flattenStyle, never left as an array.
174
- resolvedStyle = undefined;
175
- resolvedContentContainerStyle = undefined;
176
- resolvedMaintainVisibleContentPosition = undefined;
177
- // The offset we are imperatively driving native to before the scroll handle attaches (rides down
178
- // as contentOffset). A fresh object identity each push so the commit re-applies a repeated value.
179
- commandedOffset = undefined;
180
- // The one folded state cell (the Angular twin of React's stateRef): scroll offset, viewport,
181
- // measured lengths, committed window, edge/viewability dedup, MVCP anchor — all in IListState,
182
- // driven by the shared reduceList. `renderVersion` bumps whenever a transition changes render
183
- // state, so ngDoCheck's recompute-dedup can detect it (listState is a plain object, not tracked).
184
- listState = createInitialListState();
185
- renderVersion = EMPTY_OFFSET;
186
- cellMeasures = new Map();
187
- // A cell's context object is the ONLY thing its stamped view reads, and VListOutletDirective
188
- // refreshes that view whenever the object's IDENTITY changes. recomputeView runs on every frame
189
- // the window moves, so minting a fresh context per cell made a four-row slide cost a refresh of
190
- // EVERY cell in the window. MEASURED on PATH B geometry (544 entries, 320px viewport, RN
191
- // defaults), per fling frame: 233-cell window, 4 cells genuinely entering, 228 outlet updates —
192
- // each one a copyContextFields plus a markForCheck that walks to the root. Solid, same geometry,
193
- // same shared reducer, same engine output: 4. Handing back the SAME object for a cell whose item
194
- // and index did not move keeps `[vListOutletContext]` reference-equal, so ngOnChanges never fires
195
- // for it. The fast adapters get this by construction — Solid's <For> is keyed on the cell KEY
196
- // strings precisely so "the plan's freshly-built cell objects" cannot rebuild every row
197
- // (adapters/solid/src/components/virtualized-list/shared.tsx, the cellKeys memo).
198
- cellContexts = new Map();
199
- nextCellContexts = new Map();
200
- // Everything a cell's CONTENT can depend on that is not its own item identity. RN's contract for
201
- // "re-render the cells, the data object itself did not change" is extraData; without it here an
202
- // extraData flip would reuse every context and repaint nothing.
203
- cellContextEpoch = undefined;
204
- separatorOverrides = new Map();
205
- // The adapter-owned debounce (minimumViewTime) and incremental-fill timers; the reducer only
206
- // hands back a delay.
207
- viewableTimer = null;
208
- batchTimer = null;
209
- // Dedupes the after-commit effects: they run only when the windowing signature changed.
210
- lastEffectSignature = '';
211
- // Dedupes ngDoCheck's own recompute. WITHOUT this, recomputeView() unconditionally rebuilds
212
- // windowCells (and each cell's `separators` handle — fresh closures every call) on EVERY CD pass,
213
- // including ones triggered by something else entirely in the app. A fresh context object flows
214
- // into VListOutletDirective, which (correctly) sees its `context` @Input() change and calls
215
- // `viewRef.markForCheck()` — which reschedules ANOTHER change-detection tick via the zoneless
216
- // scheduler (adapters/angular/src/render.ts). Recomputing unconditionally therefore free-runs
217
- // forever the moment a list actually has cells to stamp (a real, previously-dormant bug only
218
- // exposed once flat-list.test.ts's projection fix let cells render for the first time — see that
219
- // test's comment). Guarding recompute behind "did anything relevant actually change" breaks the
220
- // cycle: unrelated CD passes reuse the same windowCells/context identities, so
221
- // VListOutletDirective's ngOnChanges sees no change and never reschedules.
222
- lastRecompute = undefined;
223
- cdr = inject(ChangeDetectorRef);
224
- // This component's OWN host — the non-painting anchor `class="..."` at the use site resolves
225
- // onto (see anchorHostStyle's doc comment) — NOT `scrollHostRef` above, which targets the real
226
- // inner `<scroll-view>`/`<horizontal-scroll-view>` tag one level down.
227
- elementRef = inject(ElementRef);
228
- // Bridges the non-reactive @Input fields into the reactive graph so `foldedAccessibility` below
229
- // can memoize a bag derived from them (read its own comment for why signal inputs are not an
230
- // option while this package's unit suite runs on JIT).
231
- //
232
- // ONLY safe for a bag whose every dependency is an @Input. This list's window machinery
233
- // (listState, renderVersion, windowCells, the measured metrics) is driven from scroll/layout
234
- // callbacks that never touch ngOnChanges, so nothing derived from it may ride this signal - it
235
- // would memoize a window that stops re-computing mid-scroll. Make that state its own signal
236
- // instead of widening this one.
237
- inputsRevision = signal(0, /* @ts-ignore */
238
- ...(ngDevMode ? [{ debugName: "inputsRevision" }] : /* istanbul ignore next */ []));
239
- // MEASURED (while ScrollView was still a wrapper): the template fed 20 separate inputs off this
240
- // one bag, and Angular does not cache a getter across binding expressions - as a getter it
241
- // rebuilt the object 20 times per refresh, and every consumer saw a fresh reference, so all 20
242
- // reported "changed" and wrote through. A computed evaluates once and hands back the SAME object
243
- // until an input actually changes. Every dependency below is an @Input, which is what makes
244
- // inputsRevision a complete dependency set here — `scrollViewBag()` reuses this result rather
245
- // than re-running the fold.
246
- foldedAccessibility = computed(() => {
247
- this.inputsRevision();
248
- return resolveAccessibilityProps({
249
- testID: this.testID,
250
- nativeID: this.nativeID,
251
- accessible: this.accessible,
252
- accessibilityLabel: this.accessibilityLabel,
253
- accessibilityHint: this.accessibilityHint,
254
- accessibilityRole: this.accessibilityRole,
255
- accessibilityState: this.accessibilityState,
256
- accessibilityValue: this.accessibilityValue,
257
- accessibilityActions: this.accessibilityActions,
258
- accessibilityLabelledBy: this.accessibilityLabelledBy,
259
- importantForAccessibility: this.importantForAccessibility,
260
- accessibilityLiveRegion: this.accessibilityLiveRegion,
261
- screenReaderFocusable: this.screenReaderFocusable,
262
- accessibilityViewIsModal: this.accessibilityViewIsModal,
263
- accessibilityElementsHidden: this.accessibilityElementsHidden,
264
- accessibilityIgnoresInvertColors: this.accessibilityIgnoresInvertColors,
265
- accessibilityLanguage: this.accessibilityLanguage,
266
- accessibilityRespondsToUserInteraction: this.accessibilityRespondsToUserInteraction,
267
- accessibilityShowsLargeContentViewer: this.accessibilityShowsLargeContentViewer,
268
- accessibilityLargeContentTitle: this.accessibilityLargeContentTitle,
269
- role: this.role,
270
- 'aria-label': this.ariaLabel,
271
- 'aria-labelledby': this.ariaLabelledBy,
272
- 'aria-live': this.ariaLive,
273
- 'aria-hidden': this.ariaHidden,
274
- 'aria-busy': this.ariaBusy,
275
- 'aria-checked': this.ariaChecked,
276
- 'aria-disabled': this.ariaDisabled,
277
- 'aria-expanded': this.ariaExpanded,
278
- 'aria-selected': this.ariaSelected,
279
- 'aria-modal': this.ariaModal,
280
- 'aria-valuemax': this.ariaValueMax,
281
- 'aria-valuemin': this.ariaValueMin,
282
- 'aria-valuenow': this.ariaValueNow,
283
- 'aria-valuetext': this.ariaValueText,
284
- });
285
- }, /* @ts-ignore */
286
- ...(ngDevMode ? [{ debugName: "foldedAccessibility" }] : /* istanbul ignore next */ []));
287
245
  get shouldRenderRefreshControl() {
288
246
  return this.refreshRequested ?? this.refresh.observed;
289
247
  }
@@ -308,7 +266,10 @@ export class VirtualizedList {
308
266
  keyboardShouldPersistTaps: this.keyboardShouldPersistTaps,
309
267
  keyboardDismissMode: this.keyboardDismissMode,
310
268
  contentOffset: this.commandedOffset,
311
- stickyHeaderIndices: this.renderedStickyIndices,
269
+ // `stickyHeaderIndices` is deliberately NOT forwarded — it numbers the scroll view's PAINT
270
+ // children, and a windowed list paints a header, a spacer and a slice, so the positions move
271
+ // every time the window slides and the behavior re-wraps a different child each pass. The
272
+ // cells carry the `sticky-header` TAG instead, which pins by DOCUMENT order.
312
273
  maintainVisibleContentPosition: this.resolvedMaintainVisibleContentPosition,
313
274
  // The four boolean-gated Fabric events (`.claude/rules/fabric-boolean-event-gates.md`): the
314
275
  // TAG has no `.observed` of its own, so `wantsGate` (own subscriber, or the demand from
@@ -354,56 +315,11 @@ export class VirtualizedList {
354
315
  return (this.updateCellsBatchingPeriod ?? DEFAULT_UPDATE_CELLS_BATCHING_PERIOD);
355
316
  }
356
317
  get onEndReachedThresholdValue() {
357
- return this.onEndReachedThreshold ?? DEFAULT_END_REACHED_THRESHOLD;
318
+ return this.onEndReachedThreshold;
358
319
  }
359
320
  get onStartReachedThresholdValue() {
360
- return this.onStartReachedThreshold ?? DEFAULT_START_REACHED_THRESHOLD;
321
+ return this.onStartReachedThreshold;
361
322
  }
362
- // onScroll stays a plain callback bag key (an Animated.event(...) target must be able to flow
363
- // through it, which an @Output() can't carry), so this is a stable arrow field read straight into
364
- // scrollViewBag()'s `onScroll`. onLayout is unconditional too — this list always needs its own
365
- // layout for windowing, regardless of whether an app subscribes to anything.
366
- onScrollTick = (event) => {
367
- countAngular('scrollTicks');
368
- const offset = readScrollOffset(event, this.isHorizontal);
369
- if (offset === undefined)
370
- return;
371
- dlog(`Angular VirtualizedList onScroll offset=${offset}`);
372
- // A real native scroll supersedes any pending commanded offset.
373
- this.commandedOffset = undefined;
374
- this.dispatch({ kind: 'scroll', offset });
375
- // Compose, don't clobber: internal windowing ran first, now the user's onScroll.
376
- this.onScroll?.(event);
377
- };
378
- onLayoutTick = (event) => {
379
- const length = readLayoutLength(event, this.isHorizontal);
380
- if (length === undefined)
381
- return;
382
- dlog(`Angular VirtualizedList onLayout viewport=${length}`);
383
- this.dispatch({ kind: 'layout', length });
384
- };
385
- // RefreshControl's refresh and the scroll tag's accessibility events reach here as plain
386
- // listener-prop values in scrollViewBag()/refreshControlBag() — these fields just adapt
387
- // VirtualizedList's own @Output() into a plain re-emit callback.
388
- handleRefresh = () => {
389
- this.refresh.emit();
390
- };
391
- accessibilityActionTick = (event) => {
392
- this.accessibilityAction.emit(event);
393
- };
394
- accessibilityTapTick = (event) => {
395
- this.accessibilityTap.emit(event);
396
- };
397
- magicTapTick = (event) => {
398
- this.magicTap.emit(event);
399
- };
400
- accessibilityEscapeTick = (event) => {
401
- this.accessibilityEscape.emit(event);
402
- };
403
- keyFor = (index) => {
404
- const item = this.getItem(this.data, index);
405
- return resolveItemKey(item, index, this.keyExtractor);
406
- };
407
323
  // The reducer inputs, folded off the @Input()s each call. The edge/viewability listeners map to
408
324
  // `.observed` (Angular's "is anyone bound to this @Output()"), so the reducer emits only when the
409
325
  // consumer listens — the same on-demand gating the prop-callback era had.
@@ -512,8 +428,12 @@ export class VirtualizedList {
512
428
  const info = effect.info;
513
429
  const map = effect.map;
514
430
  const fire = () => {
515
- for (const pair of pairs)
516
- pair.onViewableItemsChanged(info);
431
+ for (const pair of pairs) {
432
+ pair.onViewableItemsChanged({
433
+ ...info,
434
+ viewabilityConfig: pair.viewabilityConfig,
435
+ });
436
+ }
517
437
  this.dispatch({ kind: 'viewable-fired', map });
518
438
  };
519
439
  if (this.viewableTimer !== null) {
@@ -654,7 +574,6 @@ export class VirtualizedList {
654
574
  this.leadingSpacerStyle = null;
655
575
  this.gapSpacerStyle = null;
656
576
  this.trailingSpacerStyle = null;
657
- this.renderedStickyIndices = undefined;
658
577
  dlog(`Angular VirtualizedList empty (viewport=${this.listState.viewportLength})`);
659
578
  return;
660
579
  }
@@ -679,17 +598,16 @@ export class VirtualizedList {
679
598
  plan.trailingExtent > EMPTY_OFFSET
680
599
  ? this.spacerStyle(plan.trailingExtent)
681
600
  : null;
682
- this.renderedStickyIndices =
683
- stickySet !== undefined && plan.stickyChildPositions.length > 0
684
- ? plan.stickyChildPositions
685
- : undefined;
601
+ // `plan.stickyChildPositions` is deliberately NOT read — it was the input to the index form.
686
602
  dlog(`STICKY[list] stickySet=${stickySet === undefined ? 'undefined' : JSON.stringify([...stickySet])} ` +
687
603
  `childPositions=${JSON.stringify(plan.stickyChildPositions)} ` +
688
604
  `forcedStickyCell=${plan.forcedStickyCell === undefined ? 'none' : plan.forcedStickyCell.index} ` +
689
605
  `window=[${m.first},${m.last}] hasHeader=${this.headerDir !== undefined}`);
690
606
  this.forcedStickyCell =
691
607
  plan.forcedStickyCell !== undefined
692
- ? this.buildWindowCell(plan.forcedStickyCell.index, plan.forcedStickyCell.key, false)
608
+ ? this.buildWindowCell(plan.forcedStickyCell.index, plan.forcedStickyCell.key, false,
609
+ // Sticky by construction — `forcedStickyCell` exists only for an index in `stickySet`.
610
+ true)
693
611
  : null;
694
612
  const cells = [];
695
613
  for (const planned of plan.cells) {
@@ -699,7 +617,7 @@ export class VirtualizedList {
699
617
  // would make a cell's own measured height change as the window slides past it. Device-measured
700
618
  // 2026-08-19 as a run of cells all shifting by exactly the divider's 1px.
701
619
  const includeSeparator = hasSeparators && planned.index < m.count - 1;
702
- cells.push(this.buildWindowCell(planned.index, planned.key, includeSeparator));
620
+ cells.push(this.buildWindowCell(planned.index, planned.key, includeSeparator, stickySet?.has(planned.index) === true));
703
621
  }
704
622
  this.windowCells = cells;
705
623
  // Swap, don't prune: a cell that left the window is simply absent from the pass that just ran,
@@ -711,7 +629,7 @@ export class VirtualizedList {
711
629
  }
712
630
  // Assembles one cell (window or forced-sticky) — the context, measure closure, and optional
713
631
  // separator context — so both plan.cells and plan.forcedStickyCell build identically.
714
- buildWindowCell(index, key, includeSeparator) {
632
+ buildWindowCell(index, key, includeSeparator, isSticky) {
715
633
  const item = this.getItem(this.data, index);
716
634
  return {
717
635
  key,
@@ -721,6 +639,7 @@ export class VirtualizedList {
721
639
  separatorContext: includeSeparator
722
640
  ? this.buildSeparatorContext(index, item)
723
641
  : undefined,
642
+ isSticky,
724
643
  };
725
644
  }
726
645
  // The context for one cell, reused by identity when the cell did not move. See cellContexts.
@@ -883,8 +802,8 @@ export class VirtualizedList {
883
802
  this.commandedOffset = target;
884
803
  this.cdr.markForCheck();
885
804
  }
886
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: VirtualizedList, deps: [], target: i0.ɵɵFactoryTarget.Component });
887
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: VirtualizedList, isStandalone: true, selector: "VirtualizedList", inputs: { data: "data", getItem: "getItem", getItemCount: "getItemCount", keyExtractor: "keyExtractor", getItemLayout: "getItemLayout", horizontal: "horizontal", inverted: "inverted", extraData: "extraData", onEndReachedThreshold: "onEndReachedThreshold", onStartReachedThreshold: "onStartReachedThreshold", refreshing: "refreshing", progressViewOffset: "progressViewOffset", refreshRequested: "refreshRequested", viewabilityConfig: "viewabilityConfig", viewabilityConfigCallbackPairs: "viewabilityConfigCallbackPairs", initialNumToRender: "initialNumToRender", initialScrollIndex: "initialScrollIndex", maxToRenderPerBatch: "maxToRenderPerBatch", updateCellsBatchingPeriod: "updateCellsBatchingPeriod", windowSize: "windowSize", stickyHeaderIndices: "stickyHeaderIndices", maintainVisibleContentPosition: "maintainVisibleContentPosition", onScroll: "onScroll", onScrollBeginDrag: "onScrollBeginDrag", onScrollEndDrag: "onScrollEndDrag", onMomentumScrollBegin: "onMomentumScrollBegin", onMomentumScrollEnd: "onMomentumScrollEnd", scrollEventThrottle: "scrollEventThrottle", keyboardShouldPersistTaps: "keyboardShouldPersistTaps", keyboardDismissMode: "keyboardDismissMode", style: "style", contentContainerStyle: "contentContainerStyle", testID: "testID", nativeID: "nativeID", accessible: "accessible", accessibilityLabel: "accessibilityLabel", accessibilityHint: "accessibilityHint", accessibilityRole: "accessibilityRole", accessibilityState: "accessibilityState", accessibilityValue: "accessibilityValue", accessibilityActions: "accessibilityActions", accessibilityLabelledBy: "accessibilityLabelledBy", importantForAccessibility: "importantForAccessibility", accessibilityLiveRegion: "accessibilityLiveRegion", screenReaderFocusable: "screenReaderFocusable", accessibilityViewIsModal: "accessibilityViewIsModal", accessibilityElementsHidden: "accessibilityElementsHidden", accessibilityIgnoresInvertColors: "accessibilityIgnoresInvertColors", accessibilityLanguage: "accessibilityLanguage", accessibilityRespondsToUserInteraction: "accessibilityRespondsToUserInteraction", accessibilityShowsLargeContentViewer: "accessibilityShowsLargeContentViewer", accessibilityLargeContentTitle: "accessibilityLargeContentTitle", role: "role", ariaLabel: "ariaLabel", ariaLabelledBy: "ariaLabelledBy", ariaLive: "ariaLive", ariaHidden: "ariaHidden", ariaBusy: "ariaBusy", ariaChecked: "ariaChecked", ariaDisabled: "ariaDisabled", ariaExpanded: "ariaExpanded", ariaSelected: "ariaSelected", ariaModal: "ariaModal", ariaValueMax: "ariaValueMax", ariaValueMin: "ariaValueMin", ariaValueNow: "ariaValueNow", ariaValueText: "ariaValueText" }, outputs: { endReached: "endReached", startReached: "startReached", refresh: "refresh", viewableItemsChanged: "viewableItemsChanged", scrollToIndexFailed: "scrollToIndexFailed", accessibilityAction: "accessibilityAction", accessibilityTap: "accessibilityTap", magicTap: "magicTap", accessibilityEscape: "accessibilityEscape" }, queries: [{ propertyName: "itemDir", first: true, predicate: VListItemDirective, descendants: true }, { propertyName: "headerDir", first: true, predicate: VListHeaderDirective, descendants: true }, { propertyName: "footerDir", first: true, predicate: VListFooterDirective, descendants: true }, { propertyName: "emptyDir", first: true, predicate: VListEmptyDirective, descendants: true }, { propertyName: "separatorDir", first: true, predicate: VListSeparatorDirective, descendants: true }], viewQueries: [{ propertyName: "scrollHostRef", first: true, predicate: ["scrollHost"], descendants: true, read: ElementRef }], usesOnChanges: true, hostDirectives: [{ directive: i1.SymbioteStyleInputDirective, inputs: ["style", "style"] }], ngImport: i0, template: `
805
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: VirtualizedList, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
806
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: VirtualizedList, isStandalone: true, selector: "VirtualizedList", inputs: { data: "data", getItem: "getItem", getItemCount: "getItemCount", keyExtractor: "keyExtractor", getItemLayout: "getItemLayout", horizontal: "horizontal", inverted: "inverted", extraData: "extraData", onEndReachedThreshold: "onEndReachedThreshold", onStartReachedThreshold: "onStartReachedThreshold", refreshing: "refreshing", progressViewOffset: "progressViewOffset", refreshRequested: "refreshRequested", viewabilityConfig: "viewabilityConfig", viewabilityConfigCallbackPairs: "viewabilityConfigCallbackPairs", initialNumToRender: "initialNumToRender", initialScrollIndex: "initialScrollIndex", maxToRenderPerBatch: "maxToRenderPerBatch", updateCellsBatchingPeriod: "updateCellsBatchingPeriod", windowSize: "windowSize", stickyHeaderIndices: "stickyHeaderIndices", maintainVisibleContentPosition: "maintainVisibleContentPosition", onScroll: "onScroll", onScrollBeginDrag: "onScrollBeginDrag", onScrollEndDrag: "onScrollEndDrag", onMomentumScrollBegin: "onMomentumScrollBegin", onMomentumScrollEnd: "onMomentumScrollEnd", scrollEventThrottle: "scrollEventThrottle", keyboardShouldPersistTaps: "keyboardShouldPersistTaps", keyboardDismissMode: "keyboardDismissMode", style: "style", contentContainerStyle: "contentContainerStyle", testID: "testID", nativeID: "nativeID", accessible: "accessible", accessibilityLabel: "accessibilityLabel", accessibilityHint: "accessibilityHint", accessibilityRole: "accessibilityRole", accessibilityState: "accessibilityState", accessibilityValue: "accessibilityValue", accessibilityActions: "accessibilityActions", accessibilityLabelledBy: "accessibilityLabelledBy", importantForAccessibility: "importantForAccessibility", accessibilityLiveRegion: "accessibilityLiveRegion", screenReaderFocusable: "screenReaderFocusable", accessibilityViewIsModal: "accessibilityViewIsModal", accessibilityElementsHidden: "accessibilityElementsHidden", accessibilityIgnoresInvertColors: "accessibilityIgnoresInvertColors", accessibilityLanguage: "accessibilityLanguage", accessibilityRespondsToUserInteraction: "accessibilityRespondsToUserInteraction", accessibilityShowsLargeContentViewer: "accessibilityShowsLargeContentViewer", accessibilityLargeContentTitle: "accessibilityLargeContentTitle", role: "role", ariaLabel: "ariaLabel", ariaLabelledBy: "ariaLabelledBy", ariaLive: "ariaLive", ariaHidden: "ariaHidden", ariaBusy: "ariaBusy", ariaChecked: "ariaChecked", ariaDisabled: "ariaDisabled", ariaExpanded: "ariaExpanded", ariaSelected: "ariaSelected", ariaModal: "ariaModal", ariaValueMax: "ariaValueMax", ariaValueMin: "ariaValueMin", ariaValueNow: "ariaValueNow", ariaValueText: "ariaValueText" }, outputs: { endReached: "endReached", startReached: "startReached", refresh: "refresh", viewableItemsChanged: "viewableItemsChanged", scrollToIndexFailed: "scrollToIndexFailed", accessibilityAction: "accessibilityAction", accessibilityTap: "accessibilityTap", magicTap: "magicTap", accessibilityEscape: "accessibilityEscape" }, queries: [{ propertyName: "itemDir", first: true, predicate: VListItemDirective, descendants: true }, { propertyName: "headerDir", first: true, predicate: VListHeaderDirective, descendants: true }, { propertyName: "footerDir", first: true, predicate: VListFooterDirective, descendants: true }, { propertyName: "emptyDir", first: true, predicate: VListEmptyDirective, descendants: true }, { propertyName: "separatorDir", first: true, predicate: VListSeparatorDirective, descendants: true }], viewQueries: [{ propertyName: "scrollHostRef", first: true, predicate: ["scrollHost"], descendants: true, read: ElementRef }], usesOnChanges: true, hostDirectives: [{ directive: i1.SymbioteStyleInputDirective, inputs: ["style", "style"] }], ngImport: i0, template: `
888
807
  @if (isHorizontal) {
889
808
  <horizontal-scroll-view #scrollHost [symbioteHostProps]="scrollViewBag()">
890
809
  @if (shouldRenderRefreshControl) {
@@ -910,7 +829,7 @@ export class VirtualizedList {
910
829
  <view [style]="leadingSpacerStyle"></view>
911
830
  }
912
831
  @if (forcedStickyCell !== null) {
913
- <view
832
+ <sticky-header
914
833
  (layout)="handleCellLayout(forcedStickyCell.measure, $event)"
915
834
  [style]="cellStyle"
916
835
  >
@@ -918,27 +837,45 @@ export class VirtualizedList {
918
837
  [vListOutlet]="itemDir?.templateRef"
919
838
  [vListOutletContext]="forcedStickyCell.context"
920
839
  ></ng-container>
921
- </view>
840
+ </sticky-header>
922
841
  }
923
842
  @if (gapSpacerStyle !== null) {
924
843
  <view [style]="gapSpacerStyle"></view>
925
844
  }
926
845
  @for (cell of windowCells; track cell.key) {
927
- <view
928
- (layout)="handleCellLayout(cell.measure, $event)"
929
- [style]="cellStyle"
930
- >
931
- <ng-container
932
- [vListOutlet]="itemDir?.templateRef"
933
- [vListOutletContext]="cell.context"
934
- ></ng-container>
935
- @if (cell.separatorContext !== undefined) {
846
+ @if (cell.isSticky) {
847
+ <sticky-header
848
+ (layout)="handleCellLayout(cell.measure, $event)"
849
+ [style]="cellStyle"
850
+ >
936
851
  <ng-container
937
- [vListOutlet]="separatorDir?.templateRef"
938
- [vListOutletContext]="cell.separatorContext"
852
+ [vListOutlet]="itemDir?.templateRef"
853
+ [vListOutletContext]="cell.context"
939
854
  ></ng-container>
940
- }
941
- </view>
855
+ @if (cell.separatorContext !== undefined) {
856
+ <ng-container
857
+ [vListOutlet]="separatorDir?.templateRef"
858
+ [vListOutletContext]="cell.separatorContext"
859
+ ></ng-container>
860
+ }
861
+ </sticky-header>
862
+ } @else {
863
+ <view
864
+ (layout)="handleCellLayout(cell.measure, $event)"
865
+ [style]="cellStyle"
866
+ >
867
+ <ng-container
868
+ [vListOutlet]="itemDir?.templateRef"
869
+ [vListOutletContext]="cell.context"
870
+ ></ng-container>
871
+ @if (cell.separatorContext !== undefined) {
872
+ <ng-container
873
+ [vListOutlet]="separatorDir?.templateRef"
874
+ [vListOutletContext]="cell.separatorContext"
875
+ ></ng-container>
876
+ }
877
+ </view>
878
+ }
942
879
  }
943
880
  @if (trailingSpacerStyle !== null) {
944
881
  <view [style]="trailingSpacerStyle"></view>
@@ -976,7 +913,7 @@ export class VirtualizedList {
976
913
  <view [style]="leadingSpacerStyle"></view>
977
914
  }
978
915
  @if (forcedStickyCell !== null) {
979
- <view
916
+ <sticky-header
980
917
  (layout)="handleCellLayout(forcedStickyCell.measure, $event)"
981
918
  [style]="cellStyle"
982
919
  >
@@ -984,7 +921,7 @@ export class VirtualizedList {
984
921
  [vListOutlet]="itemDir?.templateRef"
985
922
  [vListOutletContext]="forcedStickyCell.context"
986
923
  ></ng-container>
987
- </view>
924
+ </sticky-header>
988
925
  }
989
926
  @if (gapSpacerStyle !== null) {
990
927
  <view [style]="gapSpacerStyle"></view>
@@ -997,21 +934,39 @@ export class VirtualizedList {
997
934
  first index moves. Measured at exactly 17px on device 2026-08-19; see
998
935
  .claude/rules/list-geometry-feedback-loop.md. -->
999
936
  @for (cell of windowCells; track cell.key) {
1000
- <view
1001
- (layout)="handleCellLayout(cell.measure, $event)"
1002
- [style]="cellStyle"
1003
- >
1004
- <ng-container
1005
- [vListOutlet]="itemDir?.templateRef"
1006
- [vListOutletContext]="cell.context"
1007
- ></ng-container>
1008
- @if (cell.separatorContext !== undefined) {
937
+ @if (cell.isSticky) {
938
+ <sticky-header
939
+ (layout)="handleCellLayout(cell.measure, $event)"
940
+ [style]="cellStyle"
941
+ >
1009
942
  <ng-container
1010
- [vListOutlet]="separatorDir?.templateRef"
1011
- [vListOutletContext]="cell.separatorContext"
943
+ [vListOutlet]="itemDir?.templateRef"
944
+ [vListOutletContext]="cell.context"
1012
945
  ></ng-container>
1013
- }
1014
- </view>
946
+ @if (cell.separatorContext !== undefined) {
947
+ <ng-container
948
+ [vListOutlet]="separatorDir?.templateRef"
949
+ [vListOutletContext]="cell.separatorContext"
950
+ ></ng-container>
951
+ }
952
+ </sticky-header>
953
+ } @else {
954
+ <view
955
+ (layout)="handleCellLayout(cell.measure, $event)"
956
+ [style]="cellStyle"
957
+ >
958
+ <ng-container
959
+ [vListOutlet]="itemDir?.templateRef"
960
+ [vListOutletContext]="cell.context"
961
+ ></ng-container>
962
+ @if (cell.separatorContext !== undefined) {
963
+ <ng-container
964
+ [vListOutlet]="separatorDir?.templateRef"
965
+ [vListOutletContext]="cell.separatorContext"
966
+ ></ng-container>
967
+ }
968
+ </view>
969
+ }
1015
970
  }
1016
971
  @if (trailingSpacerStyle !== null) {
1017
972
  <view [style]="trailingSpacerStyle"></view>
@@ -1025,7 +980,7 @@ export class VirtualizedList {
1025
980
  }
1026
981
  </scroll-view>
1027
982
  }
1028
- `, isInline: true, dependencies: [{ kind: "directive", type: SymbioteHostPropsDirective, selector: "[symbioteHostProps]", inputs: ["symbioteHostProps"], exportAs: ["symbioteHost"] }, { kind: "directive", type: VListOutletDirective, selector: "[vListOutlet]", inputs: ["vListOutlet", "vListOutletContext"] }, { kind: "component", type: ViewHost, selector: "view" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
983
+ `, isInline: true, dependencies: [{ kind: "directive", type: SymbioteHostPropsDirective, selector: "[symbioteHostProps]", inputs: ["symbioteHostProps"], exportAs: ["symbioteHost"] }, { kind: "directive", type: VListOutletDirective, selector: "[vListOutlet]", inputs: ["vListOutlet", "vListOutletContext"] }, { kind: "component", type: ViewHost, selector: "view" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1029
984
  }
1030
985
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: VirtualizedList, decorators: [{
1031
986
  type: Component,
@@ -1064,7 +1019,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
1064
1019
  <view [style]="leadingSpacerStyle"></view>
1065
1020
  }
1066
1021
  @if (forcedStickyCell !== null) {
1067
- <view
1022
+ <sticky-header
1068
1023
  (layout)="handleCellLayout(forcedStickyCell.measure, $event)"
1069
1024
  [style]="cellStyle"
1070
1025
  >
@@ -1072,27 +1027,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
1072
1027
  [vListOutlet]="itemDir?.templateRef"
1073
1028
  [vListOutletContext]="forcedStickyCell.context"
1074
1029
  ></ng-container>
1075
- </view>
1030
+ </sticky-header>
1076
1031
  }
1077
1032
  @if (gapSpacerStyle !== null) {
1078
1033
  <view [style]="gapSpacerStyle"></view>
1079
1034
  }
1080
1035
  @for (cell of windowCells; track cell.key) {
1081
- <view
1082
- (layout)="handleCellLayout(cell.measure, $event)"
1083
- [style]="cellStyle"
1084
- >
1085
- <ng-container
1086
- [vListOutlet]="itemDir?.templateRef"
1087
- [vListOutletContext]="cell.context"
1088
- ></ng-container>
1089
- @if (cell.separatorContext !== undefined) {
1036
+ @if (cell.isSticky) {
1037
+ <sticky-header
1038
+ (layout)="handleCellLayout(cell.measure, $event)"
1039
+ [style]="cellStyle"
1040
+ >
1090
1041
  <ng-container
1091
- [vListOutlet]="separatorDir?.templateRef"
1092
- [vListOutletContext]="cell.separatorContext"
1042
+ [vListOutlet]="itemDir?.templateRef"
1043
+ [vListOutletContext]="cell.context"
1093
1044
  ></ng-container>
1094
- }
1095
- </view>
1045
+ @if (cell.separatorContext !== undefined) {
1046
+ <ng-container
1047
+ [vListOutlet]="separatorDir?.templateRef"
1048
+ [vListOutletContext]="cell.separatorContext"
1049
+ ></ng-container>
1050
+ }
1051
+ </sticky-header>
1052
+ } @else {
1053
+ <view
1054
+ (layout)="handleCellLayout(cell.measure, $event)"
1055
+ [style]="cellStyle"
1056
+ >
1057
+ <ng-container
1058
+ [vListOutlet]="itemDir?.templateRef"
1059
+ [vListOutletContext]="cell.context"
1060
+ ></ng-container>
1061
+ @if (cell.separatorContext !== undefined) {
1062
+ <ng-container
1063
+ [vListOutlet]="separatorDir?.templateRef"
1064
+ [vListOutletContext]="cell.separatorContext"
1065
+ ></ng-container>
1066
+ }
1067
+ </view>
1068
+ }
1096
1069
  }
1097
1070
  @if (trailingSpacerStyle !== null) {
1098
1071
  <view [style]="trailingSpacerStyle"></view>
@@ -1130,7 +1103,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
1130
1103
  <view [style]="leadingSpacerStyle"></view>
1131
1104
  }
1132
1105
  @if (forcedStickyCell !== null) {
1133
- <view
1106
+ <sticky-header
1134
1107
  (layout)="handleCellLayout(forcedStickyCell.measure, $event)"
1135
1108
  [style]="cellStyle"
1136
1109
  >
@@ -1138,7 +1111,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
1138
1111
  [vListOutlet]="itemDir?.templateRef"
1139
1112
  [vListOutletContext]="forcedStickyCell.context"
1140
1113
  ></ng-container>
1141
- </view>
1114
+ </sticky-header>
1142
1115
  }
1143
1116
  @if (gapSpacerStyle !== null) {
1144
1117
  <view [style]="gapSpacerStyle"></view>
@@ -1151,21 +1124,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
1151
1124
  first index moves. Measured at exactly 17px on device 2026-08-19; see
1152
1125
  .claude/rules/list-geometry-feedback-loop.md. -->
1153
1126
  @for (cell of windowCells; track cell.key) {
1154
- <view
1155
- (layout)="handleCellLayout(cell.measure, $event)"
1156
- [style]="cellStyle"
1157
- >
1158
- <ng-container
1159
- [vListOutlet]="itemDir?.templateRef"
1160
- [vListOutletContext]="cell.context"
1161
- ></ng-container>
1162
- @if (cell.separatorContext !== undefined) {
1127
+ @if (cell.isSticky) {
1128
+ <sticky-header
1129
+ (layout)="handleCellLayout(cell.measure, $event)"
1130
+ [style]="cellStyle"
1131
+ >
1163
1132
  <ng-container
1164
- [vListOutlet]="separatorDir?.templateRef"
1165
- [vListOutletContext]="cell.separatorContext"
1133
+ [vListOutlet]="itemDir?.templateRef"
1134
+ [vListOutletContext]="cell.context"
1166
1135
  ></ng-container>
1167
- }
1168
- </view>
1136
+ @if (cell.separatorContext !== undefined) {
1137
+ <ng-container
1138
+ [vListOutlet]="separatorDir?.templateRef"
1139
+ [vListOutletContext]="cell.separatorContext"
1140
+ ></ng-container>
1141
+ }
1142
+ </sticky-header>
1143
+ } @else {
1144
+ <view
1145
+ (layout)="handleCellLayout(cell.measure, $event)"
1146
+ [style]="cellStyle"
1147
+ >
1148
+ <ng-container
1149
+ [vListOutlet]="itemDir?.templateRef"
1150
+ [vListOutletContext]="cell.context"
1151
+ ></ng-container>
1152
+ @if (cell.separatorContext !== undefined) {
1153
+ <ng-container
1154
+ [vListOutlet]="separatorDir?.templateRef"
1155
+ [vListOutletContext]="cell.separatorContext"
1156
+ ></ng-container>
1157
+ }
1158
+ </view>
1159
+ }
1169
1160
  }
1170
1161
  @if (trailingSpacerStyle !== null) {
1171
1162
  <view [style]="trailingSpacerStyle"></view>