@symbiote-native/angular 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +13 -10
  2. package/babel-register-composed.cjs +41 -19
  3. package/bin/symbiote-angular-dev.cjs +16 -4
  4. package/build/angular/anchor-host-registry.js +1 -17
  5. package/build/angular/anchor-host-registry.js.map +1 -1
  6. package/build/angular/change-detection-flush.d.ts +31 -0
  7. package/build/angular/change-detection-flush.js +110 -0
  8. package/build/angular/change-detection-flush.js.map +1 -0
  9. package/build/angular/components/activity-indicator-props.d.ts +2 -0
  10. package/build/angular/components/activity-indicator-props.js +2 -0
  11. package/build/angular/components/activity-indicator-props.js.map +1 -0
  12. package/build/angular/components/button-props.d.ts +2 -0
  13. package/build/angular/components/button-props.js +2 -0
  14. package/build/angular/components/button-props.js.map +1 -0
  15. package/build/angular/components/flat-list/index.d.ts +2 -1
  16. package/build/angular/components/flat-list/index.js +22 -10
  17. package/build/angular/components/flat-list/index.js.map +1 -1
  18. package/build/angular/components/image-background-props.d.ts +6 -0
  19. package/build/angular/components/image-background-props.js +2 -0
  20. package/build/angular/components/image-background-props.js.map +1 -0
  21. package/build/angular/components/{image/shared.d.ts → image-shared.d.ts} +1 -1
  22. package/build/angular/components/{image/shared.js → image-shared.js} +7 -2
  23. package/build/angular/components/image-shared.js.map +1 -0
  24. package/build/angular/components/input-accessory-view-props.d.ts +7 -0
  25. package/build/angular/components/input-accessory-view-props.js +9 -0
  26. package/build/angular/components/input-accessory-view-props.js.map +1 -0
  27. package/build/angular/components/keyboard-avoiding-view/index.js +11 -17
  28. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  29. package/build/angular/components/modal/index.d.ts +1 -0
  30. package/build/angular/components/modal/index.js +19 -12
  31. package/build/angular/components/modal/index.js.map +1 -1
  32. package/build/angular/components/pressable-props.d.ts +32 -0
  33. package/build/angular/components/pressable-props.js +11 -0
  34. package/build/angular/components/pressable-props.js.map +1 -0
  35. package/build/angular/components/refresh-control-props.d.ts +16 -0
  36. package/build/angular/components/refresh-control-props.js +14 -0
  37. package/build/angular/components/refresh-control-props.js.map +1 -0
  38. package/build/angular/components/safe-area-view-props.d.ts +7 -0
  39. package/build/angular/components/safe-area-view-props.js +11 -0
  40. package/build/angular/components/safe-area-view-props.js.map +1 -0
  41. package/build/angular/components/scroll-view-props.d.ts +76 -0
  42. package/build/angular/components/scroll-view-props.js +18 -0
  43. package/build/angular/components/scroll-view-props.js.map +1 -0
  44. package/build/angular/components/text-input-props.d.ts +45 -0
  45. package/build/angular/components/text-input-props.js +18 -0
  46. package/build/angular/components/text-input-props.js.map +1 -0
  47. package/build/angular/components/touchable-native-feedback/touchable-native-feedback-props.d.ts +6 -0
  48. package/build/angular/components/touchable-native-feedback/touchable-native-feedback-props.js +2 -0
  49. package/build/angular/components/touchable-native-feedback/touchable-native-feedback-props.js.map +1 -0
  50. package/build/angular/components/touchable-props.d.ts +14 -0
  51. package/build/angular/components/touchable-props.js +13 -0
  52. package/build/angular/components/touchable-props.js.map +1 -0
  53. package/build/angular/components/touchable-without-feedback/touchable-without-feedback-props.d.ts +3 -0
  54. package/build/angular/components/touchable-without-feedback/touchable-without-feedback-props.js +2 -0
  55. package/build/angular/components/touchable-without-feedback/touchable-without-feedback-props.js.map +1 -0
  56. package/build/angular/components/virtualized-list/index.d.ts +5 -1
  57. package/build/angular/components/virtualized-list/index.js +366 -280
  58. package/build/angular/components/virtualized-list/index.js.map +1 -1
  59. package/build/angular/components.d.ts +20 -26
  60. package/build/angular/components.js +12 -14
  61. package/build/angular/components.js.map +1 -1
  62. package/build/angular/create-portal/index.d.ts +2 -2
  63. package/build/angular/create-portal/index.js +5 -5
  64. package/build/angular/create-tunnel/index.js +1 -1
  65. package/build/angular/element-props.d.ts +53 -0
  66. package/build/angular/element-props.js +2 -0
  67. package/build/angular/element-props.js.map +1 -0
  68. package/build/angular/elements.d.ts +528 -0
  69. package/build/angular/elements.js +1444 -0
  70. package/build/angular/elements.js.map +1 -0
  71. package/build/angular/host-instance/index.js +1 -1
  72. package/build/angular/index.d.ts +4 -2
  73. package/build/angular/index.js +15 -1
  74. package/build/angular/index.js.map +1 -1
  75. package/build/angular/modules/animated/create-animated-component.d.ts +5 -6
  76. package/build/angular/modules/animated/create-animated-component.js +60 -73
  77. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  78. package/build/angular/modules/animated/index.js +1 -1
  79. package/build/angular/modules/animated/index.js.map +1 -1
  80. package/build/angular/modules/image/index.d.ts +3 -0
  81. package/build/angular/modules/image/index.js +16 -0
  82. package/build/angular/modules/image/index.js.map +1 -0
  83. package/build/angular/primitives/index.d.ts +18 -18
  84. package/build/angular/primitives/index.js +40 -36
  85. package/build/angular/primitives/index.js.map +1 -1
  86. package/build/angular/primitives/shared.d.ts +2 -3
  87. package/build/angular/primitives/shared.js +8 -53
  88. package/build/angular/primitives/shared.js.map +1 -1
  89. package/build/angular/register.d.ts +1 -1
  90. package/build/angular/register.js +12 -31
  91. package/build/angular/register.js.map +1 -1
  92. package/build/angular/render/index.js +3 -3
  93. package/build/angular/render/index.js.map +1 -1
  94. package/build/angular/renderer/index.js +113 -67
  95. package/build/angular/renderer/index.js.map +1 -1
  96. package/build/angular/renderer/value-change.d.ts +2 -0
  97. package/build/angular/renderer/value-change.js +9 -0
  98. package/build/angular/renderer/value-change.js.map +1 -0
  99. package/metro-config.cjs +23 -5
  100. package/metro-css-parser.cjs +2 -1
  101. package/package.json +5 -14
  102. package/src/anchor-host-registry.ts +1 -17
  103. package/src/change-detection-flush.ts +120 -0
  104. package/src/components/activity-indicator-props.ts +21 -0
  105. package/src/components/button-props.ts +26 -0
  106. package/src/components/flat-list/index.ts +12 -4
  107. package/src/components/image-background-props.ts +24 -0
  108. package/src/components/{image/shared.ts → image-shared.ts} +6 -1
  109. package/src/components/input-accessory-view-props.ts +21 -0
  110. package/src/components/keyboard-avoiding-view/index.ts +6 -9
  111. package/src/components/modal/index.ts +15 -7
  112. package/src/components/pressable-props.ts +73 -0
  113. package/src/components/refresh-control-props.ts +43 -0
  114. package/src/components/safe-area-view-props.ts +28 -0
  115. package/src/components/scroll-view-props.ts +109 -0
  116. package/src/components/text-input-props.ts +72 -0
  117. package/src/components/touchable-native-feedback/touchable-native-feedback-props.ts +23 -0
  118. package/src/components/touchable-props.ts +30 -0
  119. package/src/components/touchable-without-feedback/touchable-without-feedback-props.ts +21 -0
  120. package/src/components/virtualized-list/index.ts +239 -156
  121. package/src/components.ts +84 -35
  122. package/src/create-portal/index.ts +5 -5
  123. package/src/create-tunnel/index.ts +1 -1
  124. package/src/diagnostics.ts +1 -1
  125. package/src/element-props.ts +90 -0
  126. package/src/elements.ts +919 -0
  127. package/src/host-instance/index.ts +1 -1
  128. package/src/index.ts +52 -15
  129. package/src/modules/animated/create-animated-component.ts +50 -55
  130. package/src/modules/animated/index.ts +1 -1
  131. package/src/modules/image/index.ts +18 -0
  132. package/src/primitives/index.ts +22 -18
  133. package/src/primitives/shared.ts +11 -44
  134. package/src/register.ts +12 -40
  135. package/src/render/index.ts +3 -3
  136. package/src/renderer/index.ts +122 -73
  137. package/src/renderer/value-change.ts +8 -0
  138. package/babel-lower-host-primitives.cjs +0 -636
  139. package/build/angular/components/activity-indicator/index.android.d.ts +0 -13
  140. package/build/angular/components/activity-indicator/index.android.js +0 -29
  141. package/build/angular/components/activity-indicator/index.android.js.map +0 -1
  142. package/build/angular/components/activity-indicator/index.d.ts +0 -1
  143. package/build/angular/components/activity-indicator/index.ios.d.ts +0 -9
  144. package/build/angular/components/activity-indicator/index.ios.js +0 -26
  145. package/build/angular/components/activity-indicator/index.ios.js.map +0 -1
  146. package/build/angular/components/activity-indicator/index.js +0 -4
  147. package/build/angular/components/activity-indicator/index.js.map +0 -1
  148. package/build/angular/components/activity-indicator/shared.d.ts +0 -67
  149. package/build/angular/components/activity-indicator/shared.js +0 -260
  150. package/build/angular/components/activity-indicator/shared.js.map +0 -1
  151. package/build/angular/components/button.d.ts +0 -68
  152. package/build/angular/components/button.js +0 -360
  153. package/build/angular/components/button.js.map +0 -1
  154. package/build/angular/components/image/index.android.d.ts +0 -15
  155. package/build/angular/components/image/index.android.js +0 -83
  156. package/build/angular/components/image/index.android.js.map +0 -1
  157. package/build/angular/components/image/index.d.ts +0 -1
  158. package/build/angular/components/image/index.ios.d.ts +0 -15
  159. package/build/angular/components/image/index.ios.js +0 -83
  160. package/build/angular/components/image/index.ios.js.map +0 -1
  161. package/build/angular/components/image/index.js +0 -3
  162. package/build/angular/components/image/index.js.map +0 -1
  163. package/build/angular/components/image/shared.js.map +0 -1
  164. package/build/angular/components/image-background.d.ts +0 -85
  165. package/build/angular/components/image-background.js +0 -443
  166. package/build/angular/components/image-background.js.map +0 -1
  167. package/build/angular/components/input-accessory-view/index.d.ts +0 -66
  168. package/build/angular/components/input-accessory-view/index.js +0 -287
  169. package/build/angular/components/input-accessory-view/index.js.map +0 -1
  170. package/build/angular/components/pressable/index.d.ts +0 -134
  171. package/build/angular/components/pressable/index.js +0 -514
  172. package/build/angular/components/pressable/index.js.map +0 -1
  173. package/build/angular/components/refresh-control/index.d.ts +0 -91
  174. package/build/angular/components/refresh-control/index.js +0 -382
  175. package/build/angular/components/refresh-control/index.js.map +0 -1
  176. package/build/angular/components/safe-area-view/index.d.ts +0 -64
  177. package/build/angular/components/safe-area-view/index.js +0 -287
  178. package/build/angular/components/safe-area-view/index.js.map +0 -1
  179. package/build/angular/components/scroll-view/index.android.d.ts +0 -20
  180. package/build/angular/components/scroll-view/index.android.js +0 -262
  181. package/build/angular/components/scroll-view/index.android.js.map +0 -1
  182. package/build/angular/components/scroll-view/index.d.ts +0 -3
  183. package/build/angular/components/scroll-view/index.ios.d.ts +0 -8
  184. package/build/angular/components/scroll-view/index.ios.js +0 -92
  185. package/build/angular/components/scroll-view/index.ios.js.map +0 -1
  186. package/build/angular/components/scroll-view/index.js +0 -6
  187. package/build/angular/components/scroll-view/index.js.map +0 -1
  188. package/build/angular/components/scroll-view/projection.d.ts +0 -67
  189. package/build/angular/components/scroll-view/projection.js +0 -560
  190. package/build/angular/components/scroll-view/projection.js.map +0 -1
  191. package/build/angular/components/scroll-view/shared.d.ts +0 -241
  192. package/build/angular/components/scroll-view/shared.js +0 -848
  193. package/build/angular/components/scroll-view/shared.js.map +0 -1
  194. package/build/angular/components/scroll-view/sticky-header.d.ts +0 -40
  195. package/build/angular/components/scroll-view/sticky-header.js +0 -224
  196. package/build/angular/components/scroll-view/sticky-header.js.map +0 -1
  197. package/build/angular/components/switch/index.android.d.ts +0 -24
  198. package/build/angular/components/switch/index.android.js +0 -112
  199. package/build/angular/components/switch/index.android.js.map +0 -1
  200. package/build/angular/components/switch/index.d.ts +0 -1
  201. package/build/angular/components/switch/index.ios.d.ts +0 -23
  202. package/build/angular/components/switch/index.ios.js +0 -111
  203. package/build/angular/components/switch/index.ios.js.map +0 -1
  204. package/build/angular/components/switch/index.js +0 -4
  205. package/build/angular/components/switch/index.js.map +0 -1
  206. package/build/angular/components/switch/shared.d.ts +0 -49
  207. package/build/angular/components/switch/shared.js +0 -191
  208. package/build/angular/components/switch/shared.js.map +0 -1
  209. package/build/angular/components/text-input.d.ts +0 -167
  210. package/build/angular/components/text-input.js +0 -684
  211. package/build/angular/components/text-input.js.map +0 -1
  212. package/build/angular/components/touchable/index.d.ts +0 -283
  213. package/build/angular/components/touchable/index.js +0 -1453
  214. package/build/angular/components/touchable/index.js.map +0 -1
  215. package/build/angular/components/touchable-native-feedback/index.d.ts +0 -87
  216. package/build/angular/components/touchable-native-feedback/index.js +0 -409
  217. package/build/angular/components/touchable-native-feedback/index.js.map +0 -1
  218. package/build/angular/state-style.d.ts +0 -9
  219. package/build/angular/state-style.js +0 -39
  220. package/build/angular/state-style.js.map +0 -1
  221. package/metro-transformer.cjs +0 -69
  222. package/src/components/activity-indicator/index.android.ts +0 -23
  223. package/src/components/activity-indicator/index.ios.ts +0 -21
  224. package/src/components/activity-indicator/index.ts +0 -4
  225. package/src/components/activity-indicator/shared.ts +0 -225
  226. package/src/components/button.ts +0 -237
  227. package/src/components/image/index.android.ts +0 -95
  228. package/src/components/image/index.ios.ts +0 -95
  229. package/src/components/image/index.ts +0 -3
  230. package/src/components/image-background.ts +0 -308
  231. package/src/components/input-accessory-view/index.ts +0 -246
  232. package/src/components/pressable/index.ts +0 -522
  233. package/src/components/refresh-control/index.ts +0 -349
  234. package/src/components/safe-area-view/index.ts +0 -247
  235. package/src/components/scroll-view/index.android.ts +0 -205
  236. package/src/components/scroll-view/index.ios.ts +0 -81
  237. package/src/components/scroll-view/index.ts +0 -7
  238. package/src/components/scroll-view/projection.ts +0 -713
  239. package/src/components/scroll-view/shared.ts +0 -1093
  240. package/src/components/scroll-view/sticky-header.ts +0 -267
  241. package/src/components/switch/index.android.ts +0 -113
  242. package/src/components/switch/index.ios.ts +0 -112
  243. package/src/components/switch/index.ts +0 -4
  244. package/src/components/switch/shared.ts +0 -284
  245. package/src/components/text-input.ts +0 -669
  246. package/src/components/touchable/index.ts +0 -956
  247. package/src/components/touchable-native-feedback/index.ts +0 -327
  248. package/src/state-style.ts +0 -39
@@ -1,1093 +0,0 @@
1
- // ScrollView, the Angular lifecycle half. The Fabric tree is nested: a scroll view wraps a
2
- // content view holding the children (RN's ScrollView.js shape). The platform-invariant math
3
- // (decelerationRate, per-axis intrinsics, content-size dedupe, imperative handle, native sticky
4
- // scroll-attach, aria/role fold) lives in @symbiote-native/components, shared with React/Vue.
5
- // Angular supplies only the lifecycle: the host node held by IDENTITY through a directive's
6
- // ElementRef, a forwarded-prop bag set via Renderer2 (-> routeProp), and the native sticky attach
7
- // wired through whenCommitted.
8
- //
9
- // RefreshControl integration diverges per platform (.ios/.android, Metro filename-selected): iOS
10
- // renders it as a SIBLING before the content container; Android WRAPS the scroll view in an
11
- // AndroidSwipeRefreshLayout.
12
- //
13
- // Angular cannot transform <ng-content> children the way React children.map / Vue slots() can, so
14
- // ScrollView owns a projection bridge: the content host registers a ScrollViewProjectionController
15
- // with the Angular renderer, which wraps projected children whose indices match
16
- // stickyHeaderIndices. Android RefreshControl projection re-renders the projected component as the
17
- // wrapper around the scroll view (RN's Android shape), excluding it from the content slot.
18
-
19
- import {
20
- Directive,
21
- computed,
22
- signal,
23
- ElementRef,
24
- EventEmitter,
25
- Output,
26
- ViewChild,
27
- ContentChild,
28
- inject,
29
- type AfterContentChecked,
30
- type AfterViewInit,
31
- type DoCheck,
32
- type OnChanges,
33
- type OnDestroy,
34
- } from '@angular/core';
35
- import {
36
- attachStickyScroll,
37
- buildScrollViewHandle,
38
- didContentSizeChange,
39
- forwardScrollEvent,
40
- readLayoutDimension,
41
- resolveAccessibilityProps,
42
- resolveDecelerationRate,
43
- resolveScrollForwarding,
44
- selectScrollIntrinsics,
45
- splitLayoutProps,
46
- type IAccessibilityProps,
47
- type IAccessibilityStateValue,
48
- type IAriaProps,
49
- type IContentSize,
50
- type IScrollViewHandle,
51
- } from '@symbiote-native/components';
52
- import {
53
- AnimatedValue,
54
- dlog,
55
- event as animatedEvent,
56
- isNativeAnimatedAvailable,
57
- isSymbioteNode,
58
- resolveClassName,
59
- whenCommitted,
60
- type IStyleProp,
61
- type ISymbioteEvent,
62
- type ISymbioteNode,
63
- type IViewStyle,
64
- } from '@symbiote-native/engine';
65
- import { anchorHostStyle, SymbioteHostPropsDirective } from '../../primitives';
66
- import {
67
- gateWanted,
68
- injectGateDemand,
69
- type IGatedAccessibilityEvent,
70
- } from '../../gate-demand';
71
- import { RefreshControl } from '../refresh-control';
72
- import { ScrollViewProjectionController } from './projection';
73
-
74
- export type { IScrollViewHandle } from '@symbiote-native/components';
75
-
76
- type IScrollHandler = (event: ISymbioteEvent) => void;
77
- type IContentSizeHandler = (width: number, height: number) => void;
78
-
79
- // The inputs every platform ScrollView accepts, listed on each concrete @Component (the adapter
80
- // convention, mirroring Switch/Animated) so the base fields bind consistently. The aria-* /
81
- // role aliases ride here too and are folded by resolveAccessibilityProps before reaching native.
82
- export const SCROLL_VIEW_INPUTS = [
83
- 'style',
84
- 'contentContainerStyle',
85
- 'horizontal',
86
- 'scrollEnabled',
87
- 'showsVerticalScrollIndicator',
88
- 'showsHorizontalScrollIndicator',
89
- 'pagingEnabled',
90
- 'bounces',
91
- 'decelerationRate',
92
- 'scrollEventThrottle',
93
- 'contentInset',
94
- 'contentOffset',
95
- 'removeClippedSubviews',
96
- 'snapToInterval',
97
- 'snapToOffsets',
98
- 'snapToAlignment',
99
- 'snapToStart',
100
- 'snapToEnd',
101
- 'disableIntervalMomentum',
102
- 'stickyHeaderIndices',
103
- 'invertStickyHeaders',
104
- 'StickyHeaderComponent',
105
- 'keyboardDismissMode',
106
- 'keyboardShouldPersistTaps',
107
- 'maintainVisibleContentPosition',
108
- 'alwaysBounceHorizontal',
109
- 'alwaysBounceVertical',
110
- 'centerContent',
111
- 'scrollIndicatorInsets',
112
- 'indicatorStyle',
113
- 'directionalLockEnabled',
114
- 'automaticallyAdjustKeyboardInsets',
115
- 'contentInsetAdjustmentBehavior',
116
- 'minimumZoomScale',
117
- 'maximumZoomScale',
118
- 'zoomScale',
119
- 'bouncesZoom',
120
- 'pinchGestureEnabled',
121
- 'nestedScrollEnabled',
122
- 'overScrollMode',
123
- 'fadingEdgeLength',
124
- 'persistentScrollbar',
125
- 'endFillColor',
126
- 'onScroll',
127
- 'onScrollBeginDrag',
128
- 'onScrollEndDrag',
129
- 'onMomentumScrollBegin',
130
- 'onMomentumScrollEnd',
131
- 'testID',
132
- 'nativeID',
133
- 'accessible',
134
- 'accessibilityLabel',
135
- 'accessibilityHint',
136
- 'accessibilityRole',
137
- 'accessibilityState',
138
- 'accessibilityValue',
139
- 'accessibilityActions',
140
- 'accessibilityLabelledBy',
141
- 'importantForAccessibility',
142
- 'accessibilityLiveRegion',
143
- 'screenReaderFocusable',
144
- 'accessibilityViewIsModal',
145
- 'accessibilityElementsHidden',
146
- 'accessibilityIgnoresInvertColors',
147
- 'accessibilityLanguage',
148
- 'accessibilityRespondsToUserInteraction',
149
- 'accessibilityShowsLargeContentViewer',
150
- 'accessibilityLargeContentTitle',
151
- 'role',
152
- 'aria-label',
153
- 'aria-labelledby',
154
- 'aria-live',
155
- 'aria-hidden',
156
- 'aria-busy',
157
- 'aria-checked',
158
- 'aria-disabled',
159
- 'aria-expanded',
160
- 'aria-selected',
161
- 'aria-modal',
162
- 'aria-valuemax',
163
- 'aria-valuemin',
164
- 'aria-valuenow',
165
- 'aria-valuetext',
166
- ];
167
-
168
- // The Angular-facing prop surface: React's IScrollViewProps minus the React-coupled fields
169
- // (ReactNode children, ReactElement refreshControl) — Angular takes children via <ng-content> and
170
- // composes RefreshControl through projection instead. Declared per-adapter over the shared
171
- // accessibility base since a framework element/ref field can't live in a shared agnostic type.
172
- export interface IAngularScrollViewProps
173
- extends IAccessibilityProps, IAriaProps {
174
- style?: IStyleProp<IViewStyle>;
175
- // A bare string resolves through the shared style registry, like `class` on the host node.
176
- contentContainerStyle?: IStyleProp<IViewStyle> | string;
177
- horizontal?: boolean;
178
- scrollEnabled?: boolean;
179
- showsVerticalScrollIndicator?: boolean;
180
- showsHorizontalScrollIndicator?: boolean;
181
- pagingEnabled?: boolean;
182
- bounces?: boolean;
183
- decelerationRate?: 'normal' | 'fast' | number;
184
- scrollEventThrottle?: number;
185
- contentInset?: {
186
- top?: number;
187
- left?: number;
188
- bottom?: number;
189
- right?: number;
190
- };
191
- contentOffset?: { x: number; y: number };
192
- removeClippedSubviews?: boolean;
193
- snapToInterval?: number;
194
- snapToOffsets?: number[];
195
- snapToAlignment?: 'start' | 'center' | 'end';
196
- snapToStart?: boolean;
197
- snapToEnd?: boolean;
198
- disableIntervalMomentum?: boolean;
199
- // Sticky headers: RN implements stickiness PURELY IN JS (ScrollView.js wraps each flagged child
200
- // in ScrollViewStickyHeader, driven by the scroll offset). The native scroll view does NOT honor
201
- // an index array. See the header note on the Angular projection boundary for the auto-wrap.
202
- stickyHeaderIndices?: number[];
203
- invertStickyHeaders?: boolean;
204
- // Angular auto projection is renderer-node based, so this input is intentionally explicit-
205
- // composition only: custom sticky wrappers must be written in the template; auto stickyHeaderIndices
206
- // uses the built-in wrapper to stay AOT-safe.
207
- StickyHeaderComponent?: unknown;
208
- keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
209
- keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled';
210
- maintainVisibleContentPosition?: {
211
- minIndexForVisible: number;
212
- autoscrollToTopThreshold?: number;
213
- };
214
- // iOS-only forwarding props (harmless on Android: its manager ignores unknown props).
215
- alwaysBounceHorizontal?: boolean;
216
- alwaysBounceVertical?: boolean;
217
- centerContent?: boolean;
218
- scrollIndicatorInsets?: {
219
- top?: number;
220
- left?: number;
221
- bottom?: number;
222
- right?: number;
223
- };
224
- indicatorStyle?: 'default' | 'black' | 'white';
225
- directionalLockEnabled?: boolean;
226
- automaticallyAdjustKeyboardInsets?: boolean;
227
- contentInsetAdjustmentBehavior?:
228
- 'automatic' | 'scrollableAxes' | 'never' | 'always';
229
- minimumZoomScale?: number;
230
- maximumZoomScale?: number;
231
- zoomScale?: number;
232
- bouncesZoom?: boolean;
233
- pinchGestureEnabled?: boolean;
234
- // Android-only forwarding props (harmless on iOS).
235
- nestedScrollEnabled?: boolean;
236
- overScrollMode?: 'auto' | 'always' | 'never';
237
- fadingEdgeLength?: number;
238
- persistentScrollbar?: boolean;
239
- endFillColor?: string;
240
- onLayout?: IScrollHandler;
241
- onScroll?: IScrollHandler;
242
- onScrollBeginDrag?: IScrollHandler;
243
- onScrollEndDrag?: IScrollHandler;
244
- onMomentumScrollBegin?: IScrollHandler;
245
- onMomentumScrollEnd?: IScrollHandler;
246
- // iOS-only: user tapped the status bar to scroll to top. Inert on Android.
247
- onScrollToTop?: IScrollHandler;
248
- // Synthesized in JS from the content view's onLayout (RN _handleContentOnLayout); deduped.
249
- onContentSizeChange?: IContentSizeHandler;
250
- onAccessibilityAction?: (event: ISymbioteEvent) => void;
251
- onAccessibilityTap?: (event: ISymbioteEvent) => void;
252
- onMagicTap?: (event: ISymbioteEvent) => void;
253
- onAccessibilityEscape?: (event: ISymbioteEvent) => void;
254
- }
255
-
256
- // What ScrollView itself takes as plain @Input()s: the full surface minus the layout/content-size/
257
- // accessibility events it exposes as real @Output() EventEmitters instead (see ScrollViewBase below).
258
- // onScroll and the drag/momentum family stay callback @Input()s permanently — they must also accept
259
- // an Animated.event(...) native-driver marker, which no @Output() binding can carry.
260
- export type IAngularScrollViewInputs = Omit<
261
- IAngularScrollViewProps,
262
- | 'onLayout'
263
- | 'onScrollToTop'
264
- | 'onContentSizeChange'
265
- | 'onAccessibilityAction'
266
- | 'onAccessibilityTap'
267
- | 'onMagicTap'
268
- | 'onAccessibilityEscape'
269
- >;
270
-
271
- // Strip undefined entries so a prop the user never set is not forwarded to the host (an undefined
272
- // reaching Fabric is at best a no-op, at worst clears a default). The Angular twin of React/Vue
273
- // destructuring `...rest` past the defined props.
274
- function compact(bag: Record<string, unknown>): Record<string, unknown> {
275
- const result: Record<string, unknown> = {};
276
- for (const key of Object.keys(bag)) {
277
- if (bag[key] !== undefined) result[key] = bag[key];
278
- }
279
- return result;
280
- }
281
-
282
- // ScrollView's forwarded surface (scroll events + the snap/keyboard/zoom families) is exactly the
283
- // flat-bag shape SymbioteHostPropsDirective spreads onto a host element via Renderer2 (-> routeProp).
284
- // The scroll-event callbacks ride this bag (not the (event) channel) because routeProp must be free
285
- // to wire onScroll as an Animated.event when sticky headers drive the value natively, which the
286
- // listen()/setEventListener channel cannot carry. This mirrors React/Vue putting onScroll on
287
- // outerProps.
288
- @Directive({
289
- selector: '[symbioteScrollViewProjection]',
290
- standalone: true,
291
- inputs: ['symbioteScrollViewProjection'],
292
- })
293
- export class ScrollViewProjectionDirective {
294
- private readonly elementRef = inject<ElementRef<unknown>>(ElementRef);
295
-
296
- set symbioteScrollViewProjection(controller: ScrollViewProjectionController) {
297
- const node = this.elementRef.nativeElement;
298
- if (isSymbioteNode(node)) controller.bindContentNode(node);
299
- }
300
- }
301
-
302
- // @Directive() (no selector) is the Angular-sanctioned decorator for an abstract base that
303
- // declares lifecycle hooks and @ViewChild queries (mirrors AnimatedComponentBase) — without it
304
- // ngtsc rejects the inherited hooks/queries (NG2007). The concrete platform @Components add ONLY a
305
- // decorator + their platform template; all behavior lives here.
306
- @Directive()
307
- export abstract class ScrollViewBase
308
- implements
309
- IAngularScrollViewInputs,
310
- AfterContentChecked,
311
- AfterViewInit,
312
- DoCheck,
313
- OnChanges,
314
- OnDestroy
315
- {
316
- style: IStyleProp<IViewStyle> | undefined;
317
- contentContainerStyle: IStyleProp<IViewStyle> | string | undefined;
318
- horizontal: boolean | undefined;
319
- scrollEnabled: boolean | undefined;
320
- showsVerticalScrollIndicator: boolean | undefined;
321
- showsHorizontalScrollIndicator: boolean | undefined;
322
- pagingEnabled: boolean | undefined;
323
- bounces: boolean | undefined;
324
- decelerationRate: 'normal' | 'fast' | number | undefined;
325
- scrollEventThrottle: number | undefined;
326
- contentInset: IAngularScrollViewProps['contentInset'];
327
- contentOffset: IAngularScrollViewProps['contentOffset'];
328
- removeClippedSubviews: boolean | undefined;
329
- snapToInterval: number | undefined;
330
- snapToOffsets: number[] | undefined;
331
- snapToAlignment: IAngularScrollViewProps['snapToAlignment'];
332
- snapToStart: boolean | undefined;
333
- snapToEnd: boolean | undefined;
334
- disableIntervalMomentum: boolean | undefined;
335
- stickyHeaderIndices: number[] | undefined;
336
- invertStickyHeaders: boolean | undefined;
337
- StickyHeaderComponent: unknown;
338
- keyboardDismissMode: IAngularScrollViewProps['keyboardDismissMode'];
339
- keyboardShouldPersistTaps: IAngularScrollViewProps['keyboardShouldPersistTaps'];
340
- maintainVisibleContentPosition: IAngularScrollViewProps['maintainVisibleContentPosition'];
341
- alwaysBounceHorizontal: boolean | undefined;
342
- alwaysBounceVertical: boolean | undefined;
343
- centerContent: boolean | undefined;
344
- scrollIndicatorInsets: IAngularScrollViewProps['scrollIndicatorInsets'];
345
- indicatorStyle: IAngularScrollViewProps['indicatorStyle'];
346
- directionalLockEnabled: boolean | undefined;
347
- automaticallyAdjustKeyboardInsets: boolean | undefined;
348
- contentInsetAdjustmentBehavior: IAngularScrollViewProps['contentInsetAdjustmentBehavior'];
349
- minimumZoomScale: number | undefined;
350
- maximumZoomScale: number | undefined;
351
- zoomScale: number | undefined;
352
- bouncesZoom: boolean | undefined;
353
- pinchGestureEnabled: boolean | undefined;
354
- nestedScrollEnabled: boolean | undefined;
355
- overScrollMode: IAngularScrollViewProps['overScrollMode'];
356
- fadingEdgeLength: number | undefined;
357
- persistentScrollbar: boolean | undefined;
358
- endFillColor: string | undefined;
359
- onScroll: IScrollHandler | undefined;
360
- onScrollBeginDrag: IScrollHandler | undefined;
361
- onScrollEndDrag: IScrollHandler | undefined;
362
- onMomentumScrollBegin: IScrollHandler | undefined;
363
- onMomentumScrollEnd: IScrollHandler | undefined;
364
-
365
- // The layout/content-size/accessibility events as real Angular events: `(layout)="…"`, not
366
- // `[onLayout]="…"`. onScroll and the drag/momentum family stay plain @Input() callbacks above —
367
- // they must also accept an Animated.event(...) native-driver marker (see the file header note),
368
- // which no @Output() binding can carry.
369
- @Output() readonly layout = new EventEmitter<ISymbioteEvent>();
370
- @Output() readonly scrollToTop = new EventEmitter<ISymbioteEvent>();
371
- @Output() readonly contentSizeChange = new EventEmitter<IContentSize>();
372
- @Output() readonly accessibilityAction = new EventEmitter<ISymbioteEvent>();
373
- @Output() readonly accessibilityTap = new EventEmitter<ISymbioteEvent>();
374
- @Output() readonly magicTap = new EventEmitter<ISymbioteEvent>();
375
- @Output() readonly accessibilityEscape = new EventEmitter<ISymbioteEvent>();
376
-
377
- // Null unless an adapter wrapper renders this ScrollView in its own template — VirtualizedList
378
- // does. An app's own `<ScrollView>` gets null and answers from its own `.observed`.
379
- private readonly gateDemand = injectGateDemand();
380
-
381
- testID: string | undefined;
382
- nativeID: string | undefined;
383
- accessible: boolean | undefined;
384
- accessibilityLabel: string | undefined;
385
- accessibilityHint: string | undefined;
386
- accessibilityRole: IAccessibilityProps['accessibilityRole'];
387
- accessibilityState: IAccessibilityStateValue | undefined;
388
- accessibilityValue: IAccessibilityProps['accessibilityValue'];
389
- accessibilityActions: IAccessibilityProps['accessibilityActions'];
390
- accessibilityLabelledBy: string | string[] | undefined;
391
- importantForAccessibility: IAccessibilityProps['importantForAccessibility'];
392
- accessibilityLiveRegion: IAccessibilityProps['accessibilityLiveRegion'];
393
- screenReaderFocusable: boolean | undefined;
394
- accessibilityViewIsModal: boolean | undefined;
395
- accessibilityElementsHidden: boolean | undefined;
396
- accessibilityIgnoresInvertColors: boolean | undefined;
397
- accessibilityLanguage: string | undefined;
398
- accessibilityRespondsToUserInteraction: boolean | undefined;
399
- accessibilityShowsLargeContentViewer: boolean | undefined;
400
- accessibilityLargeContentTitle: string | undefined;
401
- role: IAriaProps['role'];
402
- 'aria-label': string | undefined;
403
- 'aria-labelledby': string | undefined;
404
- 'aria-live': IAriaProps['aria-live'];
405
- 'aria-hidden': boolean | undefined;
406
- 'aria-busy': boolean | undefined;
407
- 'aria-checked': boolean | 'mixed' | undefined;
408
- 'aria-disabled': boolean | undefined;
409
- 'aria-expanded': boolean | undefined;
410
- 'aria-selected': boolean | undefined;
411
- 'aria-modal': boolean | undefined;
412
- 'aria-valuemax': number | undefined;
413
- 'aria-valuemin': number | undefined;
414
- 'aria-valuenow': number | undefined;
415
- 'aria-valuetext': string | undefined;
416
-
417
- // The host directive on the scroll-view intrinsic; `.node` is the committed SymbioteNode held by
418
- // IDENTITY (Renderer2.createElement returned the engine node, ElementRef hands it back unwrapped).
419
- // Inherited by the decorated subclass — Angular collects base-class @ViewChild queries.
420
- @ViewChild('host', { read: SymbioteHostPropsDirective })
421
- private hostDirective?: SymbioteHostPropsDirective;
422
-
423
- @ContentChild(RefreshControl)
424
- protected projectedRefreshControl?: RefreshControl;
425
-
426
- // This component's OWN anchor host — where a `class="..."` at the use site resolves (see
427
- // anchorHostStyle's doc comment) — NOT `#host` in the platform templates, which targets the real
428
- // inner scroll-view primitive one level down. Injected in the BASE because both platform
429
- // subclasses merged the identical anchor style into their `scrollProps` override.
430
- private readonly elementRef = inject<ElementRef<unknown>>(ElementRef);
431
-
432
- // The anchor's class-derived style is written by the RENDERER (addClass/removeClass ->
433
- // commitClassStyle), never through an @Input, so `inputsRevision` cannot cover it and a bag
434
- // memoized on plain reads of it would keep a stale class forever. It gets its own signal, polled
435
- // in ngDoCheck below: that hook runs on exactly the checks that could have changed it (Angular
436
- // applies the parent's class bindings before flushing this directive's pre-order hooks), which is
437
- // the same cadence the old `scrollProps` getter was re-read at.
438
- protected readonly anchorStyle = signal<unknown>(undefined);
439
-
440
- // A single AnimatedValue tracks the scroll offset and drives every sticky header's translateY
441
- // (RN's _scrollAnimatedValue). A stable field (allocated once, held by identity — Angular does
442
- // not proxy class fields, so no markRaw needed); the native attach below feeds it on the UI
443
- // thread. Allocated unconditionally; inert until a sticky header consumes it.
444
- protected readonly scrollAnimatedValue = new AnimatedValue(0);
445
-
446
- // The last-seen content size, used to dedupe onContentSizeChange: RN fires the content onLayout
447
- // on every layout pass; only real size changes emit (didContentSizeChange).
448
- private lastContentSize: IContentSize | null = null;
449
- // Inverted sticky headers stick to the BOTTOM, so they need the viewport height (RN _handleLayout).
450
- private viewportHeight: number | undefined = undefined;
451
- // What the projection controller was last told about the projected RefreshControl, so
452
- // ngAfterContentChecked can tell a real content change from an ordinary check.
453
- private lastHasProjectedRefreshControl = false;
454
-
455
- protected readonly projectionController = new ScrollViewProjectionController({
456
- stickyHeaderIndices: undefined,
457
- invertStickyHeaders: undefined,
458
- scrollViewHeight: undefined,
459
- scrollAnimatedValue: this.scrollAnimatedValue,
460
- customStickyHeaderComponent: undefined,
461
- excludeRefreshControl: false,
462
- });
463
-
464
- // Native sticky-scroll attach (RN attachNativeEvent): when the native module is available the
465
- // scroll value is driven on the UI thread so the interpolations ride scroll natively (no JS
466
- // jitter). Through whenCommitted because under Angular's zoneless batched CD the host's Fabric
467
- // tag does not exist yet at ngAfterViewInit time (the async-commit gotcha Vue documents); the
468
- // bind runs now if committed, else after the commit that assigns the tag. Detached on destroy.
469
- private detachStickyScroll: (() => void) | undefined;
470
- private cancelStickyBind: (() => void) | undefined;
471
- // What the current attach was made for, so a re-run that changes nothing stays a no-op.
472
- private stickyAttachedEnabled = false;
473
- private stickyAttachedNode: ISymbioteNode | null = null;
474
-
475
- private lastLoggedIsHorizontal: boolean | undefined = undefined;
476
-
477
- // Stable reference (bound once): inverted sticky headers need the viewport height captured off
478
- // the scroll-view's own onLayout before forwarding to the user (RN _handleLayout). A getter that
479
- // built this arrow function fresh on every `scrollProps` access would hand `[symbioteHostProps]`
480
- // a new function reference every change-detection pass touching this component — jsonEqual
481
- // (commit.ts) falls back to Object.is on a function leaf, so even a structurally-identical bag
482
- // reads as "props changed" and forces a real Fabric re-clone that cascades up every ancestor
483
- // (see AnimatedComponentBase.reconcile()'s identical warning).
484
- private readonly handleInvertedStickyLayout = (
485
- event: ISymbioteEvent,
486
- ): void => {
487
- const height = readLayoutDimension(event, 'height');
488
- // The projection controller is pushed from here rather than picked up on the next prop-bag
489
- // read: viewportHeight is internal mutable state, so once `scrollProps` became a memoized
490
- // computed nothing would re-read it. Guarded on a real change - an onLayout that reports the
491
- // same height must not cost a sticky reconcile walk.
492
- if (height !== undefined && height !== this.viewportHeight) {
493
- this.viewportHeight = height;
494
- this.updateProjectionController();
495
- }
496
- this.layout.emit(event);
497
- };
498
-
499
- // Same reasoning as handleInvertedStickyLayout: the content view's onLayout synthesizes
500
- // onContentSizeChange (deduped) and must stay a stable reference across `contentProps` re-reads.
501
- private readonly handleContentLayout = (event: ISymbioteEvent): void => {
502
- const width = readLayoutDimension(event, 'width');
503
- const height = readLayoutDimension(event, 'height');
504
- if (width === undefined || height === undefined) return;
505
- if (!didContentSizeChange(this.lastContentSize, { width, height })) return;
506
- this.lastContentSize = { width, height };
507
- dlog(`Angular ScrollView contentSizeChange ${width}x${height}`);
508
- this.contentSizeChange.emit({ width, height });
509
- };
510
-
511
- // Memoizes the wrapper each `emitterCallback` builds, keyed by the emitter it closes over.
512
- // Without this, `scrollProps`/`refreshControlProps` would hand a fresh closure to the forwarded
513
- // prop bag on every evaluation — the same jsonEqual/Object.is function-leaf problem as the arrow
514
- // fields above. `.observed` is checked BEFORE the cache so an unbound event still resolves to
515
- // `undefined` rather than a stale wrapper from before the last unsubscribe.
516
- private readonly emitterCallbackCache = new WeakMap<
517
- EventEmitter<ISymbioteEvent>,
518
- IScrollHandler
519
- >();
520
-
521
- // The sticky JS-path Animated.event handler depends on a REAL input (onScroll), so it can't be a
522
- // plain stable field the way handleInvertedStickyLayout/handleContentLayout are — it must track
523
- // `this.onScroll` genuinely changing, mirroring sticky-header.ts's own rebuildInterpolation cache
524
- // (rebuild only when the tracked dependency's reference actually differs, not on every getter
525
- // read). A fresh `animatedEvent(...)` call every `scrollProps` access would otherwise construct a
526
- // new listener wrapper (and a new AnimatedEvent walking `collectMappedValues`) on every unrelated
527
- // change-detection pass while sticky headers are active.
528
- private cachedStickyOnScrollSource: IScrollHandler | undefined;
529
- private cachedStickyOnScrollHandler:
530
- ((...args: readonly unknown[]) => void) | undefined;
531
-
532
- get isHorizontal(): boolean {
533
- const value = this.horizontal === true;
534
- if (value !== this.lastLoggedIsHorizontal) {
535
- this.lastLoggedIsHorizontal = value;
536
- dlog(
537
- `Angular ScrollView isHorizontal changed to ${value} (horizontal input=${this.horizontal})`,
538
- );
539
- }
540
- return value;
541
- }
542
-
543
- private get hasStickyHeaders(): boolean {
544
- return (
545
- this.stickyHeaderIndices !== undefined &&
546
- this.stickyHeaderIndices.length > 0
547
- );
548
- }
549
-
550
- // A class-name string resolves through the shared registry before it reaches
551
- // selectScrollIntrinsics, which only understands style objects/arrays.
552
- private get resolvedContentContainerStyle():
553
- IStyleProp<IViewStyle> | undefined {
554
- return typeof this.contentContainerStyle === 'string'
555
- ? resolveClassName(this.contentContainerStyle)
556
- : this.contentContainerStyle;
557
- }
558
-
559
- private get scrollViewBaseStyle(): IViewStyle {
560
- return selectScrollIntrinsics(
561
- this.isHorizontal,
562
- this.resolvedContentContainerStyle,
563
- ).scrollViewBaseStyle;
564
- }
565
-
566
- // Bridges non-reactive @Input fields into the reactive graph, so a computed() can memoize a
567
- // bag derived from them. Signal inputs would make this unnecessary, but `input()` is visible
568
- // only to the AOT compiler and this package's unit suite runs on JIT - see the
569
- // `angular-adapter-change-detection` skill, §6. `signal()`/`computed()` themselves are plain
570
- // runtime APIs and need no compiler support, which is what makes this bridge possible at all.
571
- //
572
- // ONLY safe for a bag whose every dependency is an @Input. A bag that also reads internal
573
- // mutable state (viewportHeight, lastContentSize, the anchor's class-derived style) would go
574
- // stale, because nothing bumps this for those - make that state its own signal (see
575
- // `anchorStyle`) instead of widening this one.
576
- private readonly inputsRevision = signal(0);
577
-
578
- // The forwarded host-prop bag for the scroll-view node: aria/role folded, the native pass-through
579
- // families, the scroll-event callbacks, then the lifecycle-managed overrides (nestedScrollEnabled
580
- // default ON, horizontal when defined, decelerationRate resolved per-platform, the sticky onScroll
581
- // path). Base style UNDER user style so an explicit user value still wins. Mirrors Vue's outerProps
582
- // + scrollProps assembly.
583
- //
584
- // MEASURED 2026-08-16: as a getter this rebuilt the whole bag on every refresh of this view, so
585
- // the `symbioteHostProps` reference check failed every time and every key was re-pushed through
586
- // the renderer - once per scroll frame per host binding (2 before `contentProps` was memoized,
587
- // 1 after, 0 now; the pin lives in prop-bag-stability.test.ts).
588
- //
589
- // It is a `computed` on the BASE and no longer overridable: a subclass getter cannot override a
590
- // field. The platform subclasses used to override it with byte-identical bodies (merge the
591
- // anchor's class-derived style ahead of the resolved style), so that merge moved down here
592
- // rather than into a `decorateScrollProps` hook both of them would implement the same way.
593
- readonly scrollProps = computed<Record<string, unknown>>(() => {
594
- this.inputsRevision();
595
- const bag: Record<string, unknown> = compact({
596
- ...resolveAccessibilityProps(this.accessibilityInputs()),
597
- scrollEnabled: this.scrollEnabled,
598
- showsVerticalScrollIndicator: this.showsVerticalScrollIndicator,
599
- showsHorizontalScrollIndicator: this.showsHorizontalScrollIndicator,
600
- pagingEnabled: this.pagingEnabled,
601
- bounces: this.bounces,
602
- contentInset: this.contentInset,
603
- contentOffset: this.contentOffset,
604
- removeClippedSubviews: this.removeClippedSubviews,
605
- snapToInterval: this.snapToInterval,
606
- snapToOffsets: this.snapToOffsets,
607
- snapToAlignment: this.snapToAlignment,
608
- snapToStart: this.snapToStart,
609
- snapToEnd: this.snapToEnd,
610
- disableIntervalMomentum: this.disableIntervalMomentum,
611
- keyboardDismissMode: this.keyboardDismissMode,
612
- keyboardShouldPersistTaps: this.keyboardShouldPersistTaps,
613
- maintainVisibleContentPosition: this.maintainVisibleContentPosition,
614
- alwaysBounceHorizontal: this.alwaysBounceHorizontal,
615
- alwaysBounceVertical: this.alwaysBounceVertical,
616
- centerContent: this.centerContent,
617
- scrollIndicatorInsets: this.scrollIndicatorInsets,
618
- indicatorStyle: this.indicatorStyle,
619
- directionalLockEnabled: this.directionalLockEnabled,
620
- automaticallyAdjustKeyboardInsets: this.automaticallyAdjustKeyboardInsets,
621
- contentInsetAdjustmentBehavior: this.contentInsetAdjustmentBehavior,
622
- minimumZoomScale: this.minimumZoomScale,
623
- maximumZoomScale: this.maximumZoomScale,
624
- zoomScale: this.zoomScale,
625
- bouncesZoom: this.bouncesZoom,
626
- pinchGestureEnabled: this.pinchGestureEnabled,
627
- overScrollMode: this.overScrollMode,
628
- fadingEdgeLength: this.fadingEdgeLength,
629
- persistentScrollbar: this.persistentScrollbar,
630
- endFillColor: this.endFillColor,
631
- onScrollBeginDrag: this.onScrollBeginDrag,
632
- onScrollEndDrag: this.onScrollEndDrag,
633
- onMomentumScrollBegin: this.onMomentumScrollBegin,
634
- onMomentumScrollEnd: this.onMomentumScrollEnd,
635
- onScrollToTop: this.emitterCallback(this.scrollToTop),
636
- onAccessibilityAction: this.gatedAccessibilityCallback(
637
- 'accessibilityAction',
638
- ),
639
- onAccessibilityTap: this.gatedAccessibilityCallback('accessibilityTap'),
640
- onMagicTap: this.gatedAccessibilityCallback('magicTap'),
641
- onAccessibilityEscape: this.gatedAccessibilityCallback(
642
- 'accessibilityEscape',
643
- ),
644
- });
645
-
646
- // RN defaults nested scrolling ON (ScrollView.js `nestedScrollEnabled ?? true`); Android needs
647
- // it to scroll a nested scrollable independently, iOS handles nesting natively (a no-op there).
648
- bag.nestedScrollEnabled = this.nestedScrollEnabled ?? true;
649
- // iOS needs `horizontal` to flip RCTScrollView's axis; Android's dedicated manager ignores it.
650
- if (this.horizontal !== undefined) bag.horizontal = this.horizontal;
651
- if (this.decelerationRate !== undefined) {
652
- bag.decelerationRate = resolveDecelerationRate(this.decelerationRate);
653
- }
654
-
655
- // onScroll: when sticky headers are active, the offset must reach the AnimatedValue (RN
656
- // _scrollAnimatedValueAttachment). With the native module, the post-commit attach below drives
657
- // it on the UI thread (throttle 1, ScrollView.js:1798); without it, Animated.event drives the
658
- // value each frame (JS jitter). The DECISIONS (which path, throttle defaults, whether to
659
- // capture viewport height) are folded out to the shared resolveScrollForwarding; Angular only
660
- // EXECUTES them, keeping stable-reference handlers so a fresh closure never forces a re-clone.
661
- const nativeStickyAvailable =
662
- this.hasStickyHeaders && isNativeAnimatedAvailable();
663
- const forwarding = resolveScrollForwarding({
664
- hasStickyHeaders: this.hasStickyHeaders,
665
- nativeStickyAvailable,
666
- invertStickyHeaders: this.invertStickyHeaders,
667
- scrollEventThrottle: this.scrollEventThrottle,
668
- maintainVisibleContentPosition: this.maintainVisibleContentPosition,
669
- snapToAlignment: this.snapToAlignment,
670
- });
671
- // onScroll: the JS-fallback path uses the cached Animated.event handler; the native + plain paths
672
- // forward the user handler as-is (the native driver attaches the value on the UI thread).
673
- if (forwarding.mode === 'sticky-js') {
674
- bag.onScroll = this.stickyOnScrollHandler(this.onScroll);
675
- } else if (this.onScroll !== undefined) {
676
- bag.onScroll = this.onScroll;
677
- }
678
- if (forwarding.scrollEventThrottle !== undefined) {
679
- bag.scrollEventThrottle = forwarding.scrollEventThrottle;
680
- }
681
- // Inverted sticky headers need the viewport height (RN _handleLayout): capture it on the
682
- // scroll-view onLayout, then call the user's handler. Otherwise forward the layout emitter.
683
- if (forwarding.capturesViewportHeight) {
684
- bag.onLayout = this.handleInvertedStickyLayout;
685
- } else {
686
- const layoutCallback = this.emitterCallback(this.layout);
687
- if (layoutCallback !== undefined) bag.onLayout = layoutCallback;
688
- }
689
-
690
- // Thunks, not strings: an eagerly built message would be paid with logging off (see dlog's own
691
- // comment). Less often than it used to fire now that this bag is memoized, but the recompute
692
- // still rides every real input change.
693
- dlog(
694
- () =>
695
- `Angular ScrollView -> ${this.isHorizontal ? 'horizontal' : 'vertical'} (sticky=${this.hasStickyHeaders})`,
696
- );
697
- dlog(
698
- () =>
699
- `STICKY[sv] forwarding mode=${forwarding.mode} throttle=${forwarding.scrollEventThrottle} ` +
700
- `nativeStickyAvailable=${nativeStickyAvailable} onScrollBound=${bag.onScroll !== undefined}`,
701
- );
702
-
703
- // The anchor's class-derived style goes FIRST: flattenStyle's later-wins collapse keeps an
704
- // explicit [style] winning over the ambient class. Flat rather than the nested
705
- // `[anchor, [base, style]]` the platform overrides used to build - flattenStyle folds nested
706
- // arrays in the same order, so the resolved value is unchanged.
707
- bag.style = [this.anchorStyle(), this.scrollViewBaseStyle, this.style];
708
- return bag;
709
- });
710
-
711
- // Overridable hook, NOT `this.style` directly: `splitLayoutProps` below decides which layout
712
- // properties (flex/height/gap/…) go on the Android outer refresh-control wrapper vs. the inner
713
- // scroll view. `this.style` alone only carries the explicit `[style]` @Input — a composed
714
- // component's own anchor host can hold ADDITIONAL class-derived layout style invisible to this
715
- // split, so the wrapper collapses to zero size on real Android devices. Angular's Android
716
- // ScrollView (index.android.ts) overrides this to merge in `anchorHostStyle` (mirrors Vue's
717
- // identical `layoutSplitStyle` field and the device bug it fixes).
718
- protected get layoutSplitStyle(): IStyleProp<IViewStyle> {
719
- return this.style;
720
- }
721
-
722
- // Memoizable for the same reasons `scrollProps` is: `layoutSplitStyle` resolves to @Input `style`
723
- // plus (on Android) the `anchorStyle` signal, both covered. The anchor style is deliberately NOT
724
- // re-merged into `props.style` here - `splitLayoutProps` already routed its layout half to the
725
- // outer refresh-control wrapper and left the rest in `inner`.
726
- readonly androidWrappedScrollProps = computed<Record<string, unknown>>(() => {
727
- this.inputsRevision();
728
- const props = { ...this.scrollProps() };
729
- const { inner, outer } = splitLayoutProps(this.layoutSplitStyle);
730
- dlog(
731
- () =>
732
- `Angular ScrollView splitProbe layoutSplitStyle=${JSON.stringify(this.layoutSplitStyle)} ` +
733
- `outer=${JSON.stringify(outer)} inner=${JSON.stringify(inner)}`,
734
- );
735
- props.style = [this.scrollViewBaseStyle, inner];
736
- props.nestedScrollEnabled = true;
737
- return props;
738
- });
739
-
740
- // Deliberately still GETTERS, unlike every other bag on this class. Their whole content comes
741
- // from the PROJECTED RefreshControl's own @Input()s (refreshing, tintColor, colors, …), which
742
- // Angular routes through THAT component's ngOnChanges - `inputsRevision` here never moves for
743
- // them. Memoizing on it would freeze `refreshing` at its first value and strand the native
744
- // spinner forever, which is exactly the silent-staleness class this file avoids elsewhere.
745
- // Making them memoizable means giving RefreshControl its own revision signal to read here.
746
- get iosRefreshControlProps(): Record<string, unknown> {
747
- return this.refreshControlProps();
748
- }
749
-
750
- get androidRefreshControlProps(): Record<string, unknown> {
751
- const { outer } = splitLayoutProps(this.layoutSplitStyle);
752
- dlog(
753
- () =>
754
- `Angular ScrollView refreshControlProbe outer=${JSON.stringify(outer)} ` +
755
- `hasRefresh=${this.projectedRefreshControl !== undefined}`,
756
- );
757
- return this.refreshControlProps(outer);
758
- }
759
-
760
- protected handleProjectedRefresh(nativeNode: unknown): void {
761
- this.projectedRefreshControl?.handleRefresh(nativeNode);
762
- }
763
-
764
- private refreshControlProps(
765
- style?: IStyleProp<IViewStyle>,
766
- ): Record<string, unknown> {
767
- const refresh = this.projectedRefreshControl;
768
- if (refresh === undefined) return {};
769
- return compact({
770
- ...refresh.folded,
771
- refreshing: refresh.refreshing,
772
- tintColor: refresh.tintColor,
773
- title: refresh.title,
774
- titleColor: refresh.titleColor,
775
- progressViewOffset: refresh.progressViewOffset,
776
- colors: refresh.colors,
777
- progressBackgroundColor: refresh.progressBackgroundColor,
778
- size: refresh.size,
779
- enabled: refresh.enabled,
780
- style,
781
- testID: refresh.testID,
782
- nativeID: refresh.nativeID,
783
- accessible: refresh.accessible,
784
- onAccessibilityAction: this.emitterCallback(refresh.accessibilityAction),
785
- onAccessibilityTap: this.emitterCallback(refresh.accessibilityTap),
786
- onMagicTap: this.emitterCallback(refresh.magicTap),
787
- onAccessibilityEscape: this.emitterCallback(refresh.accessibilityEscape),
788
- });
789
- }
790
-
791
- get hasProjectedRefreshControl(): boolean {
792
- return this.projectedRefreshControl !== undefined;
793
- }
794
-
795
- // The content (inner) view's prop bag. `collapsable: false` keeps the layout-only content view a
796
- // real native view (Android Fabric view-flattens it away otherwise, hoisting cells as direct
797
- // children of a scroll view that hosts exactly one — an addViewAt crash). collapsableChildren
798
- // false preserves the cell views maintainVisibleContentPosition/snapToAlignment anchor against.
799
- // onLayout synthesizes onContentSizeChange (deduped). iOS never flattens; both are no-ops there.
800
- readonly contentProps = computed<Record<string, unknown>>(() => {
801
- this.inputsRevision();
802
- const { contentStyle } = selectScrollIntrinsics(
803
- this.isHorizontal,
804
- this.resolvedContentContainerStyle,
805
- );
806
- const bag: Record<string, unknown> = {
807
- style: contentStyle,
808
- collapsable: false,
809
- };
810
- if (
811
- this.maintainVisibleContentPosition !== undefined ||
812
- this.snapToAlignment !== undefined
813
- ) {
814
- bag.collapsableChildren = false;
815
- }
816
- bag.onLayout = this.handleContentLayout;
817
- return bag;
818
- });
819
-
820
- // Wraps an @Output() as a plain callback only while it has a subscriber, so an unbound event
821
- // still leaves the forwarded prop bag key absent — the same "undefined means nobody cares"
822
- // contract the old @Input() callbacks had. Mirrors Pressable's emitterHandler. The wrapper itself
823
- // is memoized per emitter (emitterCallbackCache) rather than rebuilt on every call: see that
824
- // field's comment for why an unstable closure here breaks the forwarded prop bag.
825
- //
826
- // `.observed` is NOT reactive, and `scrollProps` memoizes over it. That is sound because Angular
827
- // subscribes a template's (event) listeners in its CREATION pass, before any input is written and
828
- // before this component's own view is first refreshed - so `observed` is already settled at the
829
- // first evaluation and cannot flip afterwards without destroying the whole component.
830
- // The four GATED accessibility events, which `emitterCallback` cannot answer alone: VirtualizedList
831
- // renders this component and binds all four, so `.observed` is true on every list in the app
832
- // whether or not anything listens. A demand from above decides instead — see `gate-demand.ts`.
833
- //
834
- // Deliberately NOT applied to the projected RefreshControl's four, one site below: that emitter
835
- // belongs to a component the APP wrote and projected in, so its `.observed` is honest.
836
- private gatedAccessibilityCallback(
837
- name: IGatedAccessibilityEvent,
838
- ): IScrollHandler | undefined {
839
- const emitter = this[name];
840
- if (!gateWanted(this.gateDemand, name, emitter)) return undefined;
841
- return this.emitterCallback(emitter);
842
- }
843
-
844
- private emitterCallback(
845
- emitter: EventEmitter<ISymbioteEvent>,
846
- ): IScrollHandler | undefined {
847
- if (!emitter.observed) return undefined;
848
- let cached = this.emitterCallbackCache.get(emitter);
849
- if (cached === undefined) {
850
- cached = (event: ISymbioteEvent) => emitter.emit(event);
851
- this.emitterCallbackCache.set(emitter, cached);
852
- }
853
- return cached;
854
- }
855
-
856
- // Rebuilds the JS-path Animated.event handler only when the tracked `onScroll` input actually
857
- // changes reference (see cachedStickyOnScrollHandler's comment).
858
- private stickyOnScrollHandler(
859
- userOnScroll: IScrollHandler | undefined,
860
- ): (...args: readonly unknown[]) => void {
861
- if (
862
- this.cachedStickyOnScrollHandler === undefined ||
863
- this.cachedStickyOnScrollSource !== userOnScroll
864
- ) {
865
- this.cachedStickyOnScrollSource = userOnScroll;
866
- this.cachedStickyOnScrollHandler = animatedEvent(
867
- [{ nativeEvent: { contentOffset: { y: this.scrollAnimatedValue } } }],
868
- userOnScroll === undefined
869
- ? undefined
870
- : {
871
- listener: (...args: readonly unknown[]) =>
872
- forwardScrollEvent(userOnScroll, args),
873
- },
874
- );
875
- }
876
- return this.cachedStickyOnScrollHandler;
877
- }
878
-
879
- // The committed scroll-view node held by IDENTITY (the directive's ElementRef). null until the
880
- // element commits; the imperative handle and the sticky attach both read it through this getter.
881
- private get hostNode(): ISymbioteNode | null {
882
- const native = this.hostDirective?.node;
883
- return isSymbioteNode(native) ? native : null;
884
- }
885
-
886
- // The imperative API a parent reaches via @ViewChild(ScrollView). buildScrollViewHandle is the
887
- // shared, proven handle (React/Vue use it verbatim); it reads the node through the LAZY getter on
888
- // every call, so a command before commit no-ops rather than freezing a null node.
889
- private readonly handle: IScrollViewHandle = buildScrollViewHandle(
890
- () => this.hostNode,
891
- );
892
-
893
- scrollTo(options?: { x?: number; y?: number; animated?: boolean }): void {
894
- this.handle.scrollTo(options);
895
- }
896
-
897
- scrollToEnd(options?: { animated?: boolean }): void {
898
- this.handle.scrollToEnd(options);
899
- }
900
-
901
- flashScrollIndicators(): void {
902
- this.handle.flashScrollIndicators();
903
- }
904
-
905
- getScrollNode(): ISymbioteNode | null {
906
- return this.handle.getScrollNode();
907
- }
908
-
909
- ngAfterViewInit(): void {
910
- dlog('STICKY[sv] ngAfterViewInit');
911
- // Runs once the template has bound the content node, so this is the reconcile that actually
912
- // wraps the sticky children (the ngOnChanges one above fires before the content node exists).
913
- this.updateProjectionController();
914
- this.attachSticky();
915
- }
916
-
917
- // Re-read the anchor's class-derived style. It is written by the renderer's addClass/removeClass,
918
- // never through an @Input, so this poll is the only thing that can invalidate a bag holding it -
919
- // ngOnChanges alone would strand a class toggled after mount (covered by
920
- // scroll-view-class-style.test.ts's toggle case, which goes red the moment this hook stops
921
- // polling). ngDoCheck is the right hook, not a near-enough one: render3's `callHooks` flushes a
922
- // node's pre-order hooks at the next `ɵɵadvance` PAST it, which is after the parent wrote this
923
- // element's `ɵɵclassProp` and before this component's own view refreshes. It also runs under
924
- // `setActiveConsumer(null)`, so the write registers no dependency on the caller's view and cannot
925
- // throw NG0600. And `commitClassStyle` allocates a fresh style array only when a class token
926
- // actually moved, so `signal.set`'s own Object.is check makes an unchanged poll dirty nothing.
927
- ngDoCheck(): void {
928
- this.anchorStyle.set(anchorHostStyle(this.elementRef));
929
- // The attach can want a node that does not exist yet, and nothing else would ask again.
930
- // ngOnChanges covers "indices arrived late"; it does NOT cover "indices arrived late AND the host
931
- // node had not committed yet", which is exactly what a VirtualizedList does - it derives
932
- // stickyHeaderIndices from the measured window, so they land after this component's
933
- // ngAfterViewInit and before its host node resolves. Device-diagnosed 2026-08-18 on
934
- // examples/angular BenchmarkScreen STICKY PATH B, whose log ends on `attachSticky NOT attaching
935
- // (wantsAttach=true hasNode=false)` - sticky never turned on, while PATH A (literal indices in
936
- // the template) worked because ngAfterViewInit already saw a node. Retried ONLY from that stuck
937
- // state, so a settled ScrollView pays nothing per check. Device-verified the same day.
938
- // Not covered headless: sticky-native-attach.test.ts's late-indices case has its node resolved by
939
- // the time the indices land, and the deferred-creation ordering under `@if` never rendered the
940
- // ScrollView at all.
941
- if (this.stickyAttachedEnabled && this.stickyAttachedNode === null)
942
- this.attachSticky();
943
- }
944
-
945
- // The projected RefreshControl is a @ContentChild, not an @Input, so ngOnChanges never fires when
946
- // one is added or removed at the use site - and `excludeRefreshControl` decides whether the
947
- // projection walk pulls it out of the scroll content. Angular resolves the query before this
948
- // hook. Guarded on the flag itself so a check that changed nothing skips the reconcile walk,
949
- // which the old getter-driven update could not do.
950
- ngAfterContentChecked(): void {
951
- if (this.hasProjectedRefreshControl === this.lastHasProjectedRefreshControl)
952
- return;
953
- this.lastHasProjectedRefreshControl = this.hasProjectedRefreshControl;
954
- this.updateProjectionController();
955
- }
956
-
957
- // `stickyHeaderIndices` is an ordinary @Input, so it can arrive AFTER the first change-detection
958
- // pass - any app deriving it from data rather than writing a literal in the template does exactly
959
- // that. ngAfterViewInit runs once, so without this the native attach below would be skipped on
960
- // that first pass and never retried: the wrapper still projects (that half self-heals), the
961
- // header sits at the right place with the right z-index, and simply never moves. Device-reported
962
- // 2026-08-16, regression-covered in sticky-native-attach.test.ts. The other three adapters never
963
- // had this hole because they re-run the attach from a reactive effect keyed on the same
964
- // condition (Svelte's `$effect` in index.svelte, React's useEffect deps, Vue's watcher).
965
- ngOnChanges(): void {
966
- dlog('STICKY[sv] ngOnChanges');
967
- // Every computed() prop bag below reads plain @Input fields, which are NOT reactive on their
968
- // own - this bump is what tells them an input changed. It must stay in ngOnChanges: that is
969
- // the single moment Angular has finished writing every changed input for this pass.
970
- this.inputsRevision.update(revision => revision + 1);
971
- // stickyHeaderIndices / invertStickyHeaders / StickyHeaderComponent are @Inputs, so this is
972
- // where the projection controller learns about them. It used to be a side effect of reading
973
- // the `scrollProps` getter - which a memoized computed no longer performs per pass.
974
- this.updateProjectionController();
975
- this.attachSticky();
976
- }
977
-
978
- // Wire the native sticky-scroll attach once the view exists AND sticky is actually on. Re-runs
979
- // on every input change, so it must be idempotent: see the guard below.
980
- private attachSticky(): void {
981
- const wantsAttach = this.hasStickyHeaders && isNativeAnimatedAvailable();
982
- const node = wantsAttach ? this.hostNode : null;
983
- dlog(
984
- () =>
985
- `STICKY[sv] attachSticky indices=${JSON.stringify(this.stickyHeaderIndices)} ` +
986
- `hasSticky=${this.hasStickyHeaders} nativeAvailable=${isNativeAnimatedAvailable()} ` +
987
- `wantsAttach=${wantsAttach} hasNode=${node !== null} ` +
988
- `wasEnabled=${this.stickyAttachedEnabled} nodeChanged=${node !== this.stickyAttachedNode}`,
989
- );
990
- // Nothing that decides the attach has changed - return rather than detach and rebind the
991
- // native scroll event, which every unrelated @Input change would otherwise churn now that
992
- // ngOnChanges drives this too.
993
- if (
994
- wantsAttach === this.stickyAttachedEnabled &&
995
- node === this.stickyAttachedNode
996
- ) {
997
- dlog(
998
- 'STICKY[sv] attachSticky skipped (nothing about the attach changed)',
999
- );
1000
- return;
1001
- }
1002
-
1003
- this.cancelStickyBind?.();
1004
- this.cancelStickyBind = undefined;
1005
- if (this.detachStickyScroll !== undefined) {
1006
- this.detachStickyScroll();
1007
- this.detachStickyScroll = undefined;
1008
- }
1009
- // Recorded even when the node is not resolved yet, so the NEXT call (which will see a real
1010
- // node) reads as a change and attaches.
1011
- this.stickyAttachedEnabled = wantsAttach;
1012
- this.stickyAttachedNode = node;
1013
- if (!wantsAttach || node === null) {
1014
- dlog(
1015
- `STICKY[sv] attachSticky NOT attaching (wantsAttach=${wantsAttach} hasNode=${node !== null})`,
1016
- );
1017
- return;
1018
- }
1019
- dlog(
1020
- 'STICKY[sv] attachSticky waiting for commit to bind the native scroll event',
1021
- );
1022
- this.cancelStickyBind = whenCommitted(node, () => {
1023
- dlog('STICKY[sv] attachSticky node committed -> attachStickyScroll');
1024
- this.detachStickyScroll = attachStickyScroll(
1025
- node,
1026
- this.scrollAnimatedValue,
1027
- );
1028
- });
1029
- }
1030
-
1031
- ngOnDestroy(): void {
1032
- this.cancelStickyBind?.();
1033
- this.cancelStickyBind = undefined;
1034
- if (this.detachStickyScroll !== undefined) this.detachStickyScroll();
1035
- }
1036
-
1037
- private updateProjectionController(): void {
1038
- this.projectionController.update({
1039
- stickyHeaderIndices: this.stickyHeaderIndices,
1040
- invertStickyHeaders: this.invertStickyHeaders,
1041
- scrollViewHeight: this.viewportHeight,
1042
- scrollAnimatedValue: this.scrollAnimatedValue,
1043
- customStickyHeaderComponent: this.StickyHeaderComponent,
1044
- excludeRefreshControl: this.hasProjectedRefreshControl,
1045
- });
1046
- }
1047
-
1048
- // Typed as the a11y intersection WITH the string index so resolveAccessibilityProps's result
1049
- // stays assignable into the forwarded bag (a genuine narrowing, built at that type — no cast).
1050
- private accessibilityInputs(): IAccessibilityProps &
1051
- IAriaProps &
1052
- Record<string, unknown> {
1053
- return {
1054
- testID: this.testID,
1055
- nativeID: this.nativeID,
1056
- accessible: this.accessible,
1057
- accessibilityLabel: this.accessibilityLabel,
1058
- accessibilityHint: this.accessibilityHint,
1059
- accessibilityRole: this.accessibilityRole,
1060
- accessibilityState: this.accessibilityState,
1061
- accessibilityValue: this.accessibilityValue,
1062
- accessibilityActions: this.accessibilityActions,
1063
- accessibilityLabelledBy: this.accessibilityLabelledBy,
1064
- importantForAccessibility: this.importantForAccessibility,
1065
- accessibilityLiveRegion: this.accessibilityLiveRegion,
1066
- screenReaderFocusable: this.screenReaderFocusable,
1067
- accessibilityViewIsModal: this.accessibilityViewIsModal,
1068
- accessibilityElementsHidden: this.accessibilityElementsHidden,
1069
- accessibilityIgnoresInvertColors: this.accessibilityIgnoresInvertColors,
1070
- accessibilityLanguage: this.accessibilityLanguage,
1071
- accessibilityRespondsToUserInteraction:
1072
- this.accessibilityRespondsToUserInteraction,
1073
- accessibilityShowsLargeContentViewer:
1074
- this.accessibilityShowsLargeContentViewer,
1075
- accessibilityLargeContentTitle: this.accessibilityLargeContentTitle,
1076
- role: this.role,
1077
- 'aria-label': this['aria-label'],
1078
- 'aria-labelledby': this['aria-labelledby'],
1079
- 'aria-live': this['aria-live'],
1080
- 'aria-hidden': this['aria-hidden'],
1081
- 'aria-busy': this['aria-busy'],
1082
- 'aria-checked': this['aria-checked'],
1083
- 'aria-disabled': this['aria-disabled'],
1084
- 'aria-expanded': this['aria-expanded'],
1085
- 'aria-selected': this['aria-selected'],
1086
- 'aria-modal': this['aria-modal'],
1087
- 'aria-valuemax': this['aria-valuemax'],
1088
- 'aria-valuemin': this['aria-valuemin'],
1089
- 'aria-valuenow': this['aria-valuenow'],
1090
- 'aria-valuetext': this['aria-valuetext'],
1091
- };
1092
- }
1093
- }