@symbiote-native/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/build/components/activity-indicator/index.android.d.ts +2 -0
  4. package/build/components/activity-indicator/index.android.js +13 -0
  5. package/build/components/activity-indicator/index.d.ts +1 -0
  6. package/build/components/activity-indicator/index.ios.d.ts +2 -0
  7. package/build/components/activity-indicator/index.ios.js +10 -0
  8. package/build/components/activity-indicator/index.js +4 -0
  9. package/build/components/activity-indicator/shared.d.ts +7 -0
  10. package/build/components/activity-indicator/shared.js +20 -0
  11. package/build/components/button.d.ts +6 -0
  12. package/build/components/button.js +42 -0
  13. package/build/components/drawer-layout-android/index.android.d.ts +3 -0
  14. package/build/components/drawer-layout-android/index.android.js +93 -0
  15. package/build/components/drawer-layout-android/index.d.ts +3 -0
  16. package/build/components/drawer-layout-android/index.js +19 -0
  17. package/build/components/drawer-layout-android/shared.d.ts +19 -0
  18. package/build/components/drawer-layout-android/shared.js +7 -0
  19. package/build/components/flat-list/index.d.ts +73 -0
  20. package/build/components/flat-list/index.js +86 -0
  21. package/build/components/image/index.d.ts +9 -0
  22. package/build/components/image/index.js +28 -0
  23. package/build/components/image-background/index.d.ts +10 -0
  24. package/build/components/image-background/index.js +46 -0
  25. package/build/components/input-accessory-view/index.d.ts +11 -0
  26. package/build/components/input-accessory-view/index.js +14 -0
  27. package/build/components/keyboard-avoiding-view/index.d.ts +17 -0
  28. package/build/components/keyboard-avoiding-view/index.js +85 -0
  29. package/build/components/modal/index.d.ts +24 -0
  30. package/build/components/modal/index.js +56 -0
  31. package/build/components/pressable/index.d.ts +30 -0
  32. package/build/components/pressable/index.js +100 -0
  33. package/build/components/refresh-control/index.d.ts +17 -0
  34. package/build/components/refresh-control/index.js +21 -0
  35. package/build/components/safe-area-view/index.d.ts +11 -0
  36. package/build/components/safe-area-view/index.js +18 -0
  37. package/build/components/scroll-view/index.android.d.ts +3 -0
  38. package/build/components/scroll-view/index.android.js +53 -0
  39. package/build/components/scroll-view/index.d.ts +1 -0
  40. package/build/components/scroll-view/index.ios.d.ts +3 -0
  41. package/build/components/scroll-view/index.ios.js +27 -0
  42. package/build/components/scroll-view/index.js +4 -0
  43. package/build/components/scroll-view/shared.d.ts +98 -0
  44. package/build/components/scroll-view/shared.js +195 -0
  45. package/build/components/scroll-view/sticky-header.d.ts +9 -0
  46. package/build/components/scroll-view/sticky-header.js +161 -0
  47. package/build/components/section-list/index.d.ts +8 -0
  48. package/build/components/section-list/index.js +12 -0
  49. package/build/components/switch/index.android.d.ts +2 -0
  50. package/build/components/switch/index.android.js +12 -0
  51. package/build/components/switch/index.d.ts +1 -0
  52. package/build/components/switch/index.ios.d.ts +2 -0
  53. package/build/components/switch/index.ios.js +10 -0
  54. package/build/components/switch/index.js +6 -0
  55. package/build/components/switch/shared.d.ts +10 -0
  56. package/build/components/switch/shared.js +59 -0
  57. package/build/components/text-input/index.d.ts +50 -0
  58. package/build/components/text-input/index.js +167 -0
  59. package/build/components/touchable/index.d.ts +20 -0
  60. package/build/components/touchable/index.js +95 -0
  61. package/build/components/touchable-native-feedback/index.d.ts +18 -0
  62. package/build/components/touchable-native-feedback/index.js +32 -0
  63. package/build/components/virtualized-list/index.d.ts +73 -0
  64. package/build/components/virtualized-list/index.js +552 -0
  65. package/build/components/virtualized-section-list/index.d.ts +66 -0
  66. package/build/components/virtualized-section-list/index.js +99 -0
  67. package/build/components.d.ts +54 -0
  68. package/build/components.js +17 -0
  69. package/build/create-portal.d.ts +4 -0
  70. package/build/create-portal.js +23 -0
  71. package/build/create-tunnel.d.ts +12 -0
  72. package/build/create-tunnel.js +60 -0
  73. package/build/descriptor-to-react/index.d.ts +3 -0
  74. package/build/descriptor-to-react/index.js +15 -0
  75. package/build/hooks/use-color-scheme.d.ts +2 -0
  76. package/build/hooks/use-color-scheme.js +13 -0
  77. package/build/hooks/use-window-dimensions.d.ts +2 -0
  78. package/build/hooks/use-window-dimensions.js +30 -0
  79. package/build/host-config.d.ts +7 -0
  80. package/build/host-config.js +169 -0
  81. package/build/host-instance.d.ts +3 -0
  82. package/build/host-instance.js +20 -0
  83. package/build/index.d.ts +98 -0
  84. package/build/index.js +85 -0
  85. package/build/modules/accessibility-info/index.android.d.ts +2 -0
  86. package/build/modules/accessibility-info/index.android.js +4 -0
  87. package/build/modules/accessibility-info/index.d.ts +1 -0
  88. package/build/modules/accessibility-info/index.ios.d.ts +2 -0
  89. package/build/modules/accessibility-info/index.ios.js +3 -0
  90. package/build/modules/accessibility-info/index.js +5 -0
  91. package/build/modules/accessibility-info/shared.d.ts +1 -0
  92. package/build/modules/accessibility-info/shared.js +1 -0
  93. package/build/modules/action-sheet-ios.d.ts +2 -0
  94. package/build/modules/action-sheet-ios.js +4 -0
  95. package/build/modules/alert.d.ts +2 -0
  96. package/build/modules/alert.js +5 -0
  97. package/build/modules/animated/create-animated-component.d.ts +12 -0
  98. package/build/modules/animated/create-animated-component.js +125 -0
  99. package/build/modules/animated/index.d.ts +52 -0
  100. package/build/modules/animated/index.js +84 -0
  101. package/build/modules/app-registry/index.d.ts +7 -0
  102. package/build/modules/app-registry/index.js +26 -0
  103. package/build/modules/app-state.d.ts +2 -0
  104. package/build/modules/app-state.js +3 -0
  105. package/build/modules/appearance.d.ts +2 -0
  106. package/build/modules/appearance.js +3 -0
  107. package/build/modules/back-handler.d.ts +2 -0
  108. package/build/modules/back-handler.js +3 -0
  109. package/build/modules/dimensions.d.ts +2 -0
  110. package/build/modules/dimensions.js +3 -0
  111. package/build/modules/i18n-manager.d.ts +2 -0
  112. package/build/modules/i18n-manager.js +4 -0
  113. package/build/modules/keyboard.d.ts +2 -0
  114. package/build/modules/keyboard.js +3 -0
  115. package/build/modules/layout-animation.d.ts +2 -0
  116. package/build/modules/layout-animation.js +3 -0
  117. package/build/modules/linking.d.ts +2 -0
  118. package/build/modules/linking.js +4 -0
  119. package/build/modules/permissions-android.d.ts +2 -0
  120. package/build/modules/permissions-android.js +3 -0
  121. package/build/modules/settings.d.ts +1 -0
  122. package/build/modules/settings.js +4 -0
  123. package/build/modules/share.d.ts +2 -0
  124. package/build/modules/share.js +4 -0
  125. package/build/modules/status-bar/index.android.d.ts +3 -0
  126. package/build/modules/status-bar/index.android.js +89 -0
  127. package/build/modules/status-bar/index.d.ts +1 -0
  128. package/build/modules/status-bar/index.ios.d.ts +3 -0
  129. package/build/modules/status-bar/index.ios.js +64 -0
  130. package/build/modules/status-bar/index.js +4 -0
  131. package/build/modules/status-bar/shared.d.ts +6 -0
  132. package/build/modules/status-bar/shared.js +6 -0
  133. package/build/modules/text-input-state.d.ts +1 -0
  134. package/build/modules/text-input-state.js +3 -0
  135. package/build/modules/toast-android.d.ts +1 -0
  136. package/build/modules/toast-android.js +4 -0
  137. package/build/modules/vibration.d.ts +1 -0
  138. package/build/modules/vibration.js +4 -0
  139. package/build/reconciler-constants.d.ts +7 -0
  140. package/build/reconciler-constants.js +12 -0
  141. package/build/render.d.ts +7 -0
  142. package/build/render.js +60 -0
  143. package/build/utils/responder-props.d.ts +1 -0
  144. package/build/utils/responder-props.js +1 -0
  145. package/build/utils/styles.d.ts +1 -0
  146. package/build/utils/styles.js +1 -0
  147. package/metro-css-parser.cjs +11 -0
  148. package/package.json +53 -0
@@ -0,0 +1,98 @@
1
+ import { type ReactElement, type ReactNode, type RefObject } from 'react';
2
+ import { AnimatedValue, type ISymbioteEvent, type ISymbioteNode } from '@symbiote-native/engine';
3
+ import { type ISymbioteIntrinsic } from '@symbiote-native/components';
4
+ import { type IAccessibilityProps, type IAriaProps } from '@symbiote-native/components';
5
+ import type { IStyleProp, IViewStyle } from '../../utils/styles';
6
+ import { type IStickyHeaderComponentType } from './sticky-header';
7
+ export type { IScrollViewHandle } from '@symbiote-native/components';
8
+ type IScrollHandler = (event: ISymbioteEvent) => void;
9
+ type ILayoutHandler = (event: ISymbioteEvent) => void;
10
+ export interface IScrollViewProps extends IAccessibilityProps, IAriaProps {
11
+ style?: IStyleProp<IViewStyle>;
12
+ contentContainerStyle?: IStyleProp<IViewStyle> | string;
13
+ horizontal?: boolean;
14
+ scrollEnabled?: boolean;
15
+ showsVerticalScrollIndicator?: boolean;
16
+ showsHorizontalScrollIndicator?: boolean;
17
+ pagingEnabled?: boolean;
18
+ bounces?: boolean;
19
+ decelerationRate?: 'normal' | 'fast' | number;
20
+ scrollEventThrottle?: number;
21
+ contentInset?: {
22
+ top?: number;
23
+ left?: number;
24
+ bottom?: number;
25
+ right?: number;
26
+ };
27
+ contentOffset?: {
28
+ x: number;
29
+ y: number;
30
+ };
31
+ refreshControl?: ReactElement<IClonableRefreshControl>;
32
+ removeClippedSubviews?: boolean;
33
+ onContentSizeChange?: (width: number, height: number) => void;
34
+ snapToInterval?: number;
35
+ snapToOffsets?: number[];
36
+ snapToAlignment?: 'start' | 'center' | 'end';
37
+ snapToStart?: boolean;
38
+ snapToEnd?: boolean;
39
+ disableIntervalMomentum?: boolean;
40
+ stickyHeaderIndices?: number[];
41
+ invertStickyHeaders?: boolean;
42
+ StickyHeaderComponent?: IStickyHeaderComponentType;
43
+ keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
44
+ keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled';
45
+ maintainVisibleContentPosition?: {
46
+ minIndexForVisible: number;
47
+ autoscrollToTopThreshold?: number;
48
+ };
49
+ alwaysBounceHorizontal?: boolean;
50
+ alwaysBounceVertical?: boolean;
51
+ centerContent?: boolean;
52
+ scrollIndicatorInsets?: {
53
+ top?: number;
54
+ left?: number;
55
+ bottom?: number;
56
+ right?: number;
57
+ };
58
+ indicatorStyle?: 'default' | 'black' | 'white';
59
+ directionalLockEnabled?: boolean;
60
+ automaticallyAdjustKeyboardInsets?: boolean;
61
+ contentInsetAdjustmentBehavior?: 'automatic' | 'scrollableAxes' | 'never' | 'always';
62
+ minimumZoomScale?: number;
63
+ maximumZoomScale?: number;
64
+ zoomScale?: number;
65
+ bouncesZoom?: boolean;
66
+ pinchGestureEnabled?: boolean;
67
+ nestedScrollEnabled?: boolean;
68
+ overScrollMode?: 'auto' | 'always' | 'never';
69
+ fadingEdgeLength?: number;
70
+ persistentScrollbar?: boolean;
71
+ endFillColor?: string;
72
+ onLayout?: ILayoutHandler;
73
+ onScroll?: IScrollHandler;
74
+ onScrollBeginDrag?: IScrollHandler;
75
+ onScrollEndDrag?: IScrollHandler;
76
+ onMomentumScrollBegin?: IScrollHandler;
77
+ onMomentumScrollEnd?: IScrollHandler;
78
+ onScrollToTop?: IScrollHandler;
79
+ children?: ReactNode;
80
+ className?: string;
81
+ }
82
+ export interface IClonableRefreshControl {
83
+ style?: IStyleProp<IViewStyle>;
84
+ children?: ReactNode;
85
+ }
86
+ export interface IPreparedScrollView {
87
+ scrollViewIntrinsic: ISymbioteIntrinsic;
88
+ scrollViewBaseStyle: IStyleProp<IViewStyle> | undefined;
89
+ outerProps: Record<string, unknown>;
90
+ style: IStyleProp<IViewStyle> | undefined;
91
+ layoutSplitStyle: IStyleProp<IViewStyle>;
92
+ content: ReactElement;
93
+ refreshControl: ReactElement<IClonableRefreshControl> | undefined;
94
+ scrollAnimatedValue: AnimatedValue;
95
+ nativeStickyAvailable: boolean;
96
+ }
97
+ export declare function usePreparedScrollView(rawProps: IScrollViewProps): IPreparedScrollView;
98
+ export declare function useNativeStickyScrollAttach(scrollNodeRef: RefObject<ISymbioteNode | null>, scrollAnimatedValue: AnimatedValue, enabled: boolean): void;
@@ -0,0 +1,195 @@
1
+ // ScrollView: shared core. The Fabric tree is nested: the scroll view wraps a content
2
+ // view that holds the children (RN's own ScrollView.js shape). Building that content
3
+ // node, resolving decelerationRate, and the prop plumbing are platform-invariant and
4
+ // live here. What diverges (ADR 0020) is how a RefreshControl integrates: on iOS it is a
5
+ // CHILD of the scroll view (sibling of the content), on Android it WRAPS the scroll view
6
+ // (AndroidSwipeRefreshLayout is the parent, ScrollView nested inside). So the .ios/.android
7
+ // files assemble the final element; the filename selects, no Platform.OS read.
8
+ //
9
+ // The framework-agnostic pieces (decelerationRate, the per-axis intrinsics/base style, the
10
+ // content-size dedupe, the imperative handle, splitLayoutProps, the sticky math, the native
11
+ // scroll-attach) live in @symbiote-native/components (ADR 0024); this file holds only the React
12
+ // lifecycle (refs/state/effects) and the element assembly that consumes them.
13
+ import { createElement, useEffect, useRef, useState, } from 'react';
14
+ import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, resolveClassName, } from '@symbiote-native/engine';
15
+ import { attachStickyScroll, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveDecelerationRate, selectScrollIntrinsics, } from '@symbiote-native/components';
16
+ import { resolveAccessibilityProps, } from '@symbiote-native/components';
17
+ import { wrapStickyHeaders } from './sticky-header';
18
+ export function usePreparedScrollView(rawProps) {
19
+ // ScrollView forwards its outer props straight to the native scroll view (not a View
20
+ // wrapper), so it folds aria/role into accessibility* here before forwarding.
21
+ const props = resolveAccessibilityProps(rawProps);
22
+ const { style, className, contentContainerStyle, horizontal, decelerationRate, refreshControl, children, onContentSizeChange, stickyHeaderIndices, invertStickyHeaders, StickyHeaderComponent, onLayout, onScroll, scrollEventThrottle, ...outer } = props;
23
+ const isHorizontal = horizontal === true;
24
+ const hasStickyHeaders = stickyHeaderIndices !== undefined && stickyHeaderIndices.length > 0;
25
+ // A single AnimatedValue tracks the scroll offset and drives every sticky header's
26
+ // translateY (RN's _scrollAnimatedValue). Stable across renders via a ref so the headers'
27
+ // bindings survive re-renders. Allocated even when no sticky headers are present (hooks
28
+ // run unconditionally: usePreparedScrollView is always called at the top of the render body).
29
+ const scrollAnimatedValueRef = useRef(null);
30
+ if (scrollAnimatedValueRef.current === null)
31
+ scrollAnimatedValueRef.current = new AnimatedValue(0);
32
+ const scrollAnimatedValue = scrollAnimatedValueRef.current;
33
+ // Inverted sticky headers stick to the BOTTOM, so they need the viewport height (RN reads
34
+ // it in _handleLayout). Tracked here and fed back into the wrapped headers.
35
+ const [viewportHeight, setViewportHeight] = useState(undefined);
36
+ // Sticky-header cross-talk (RN ScrollView.js _headerLayoutYs, line 754): a child-index→measured-y
37
+ // map the parent keeps so each header can learn where the NEXT sticky header starts (its push-off
38
+ // collision point). The map lives in a ref (mutated imperatively from each header's onLayout, like
39
+ // RN's _onStickyHeaderLayout), and a state bump forces the re-render that feeds the freshly-recorded
40
+ // y forward into the previous header's nextHeaderLayoutY prop.
41
+ const headerLayoutYsRef = useRef(null);
42
+ if (headerLayoutYsRef.current === null)
43
+ headerLayoutYsRef.current = new Map();
44
+ const headerLayoutYs = headerLayoutYsRef.current;
45
+ const [, bumpHeaderLayout] = useState(0);
46
+ const onHeaderLayoutY = (index, y) => {
47
+ if (headerLayoutYs.get(index) === y)
48
+ return;
49
+ headerLayoutYs.set(index, y);
50
+ dlog(`ScrollView sticky-header layoutY index=${index} y=${y}`);
51
+ bumpHeaderLayout(tick => tick + 1);
52
+ };
53
+ // A class-name string resolves through the shared registry before it reaches the
54
+ // framework-agnostic selector below, which only understands style objects/arrays.
55
+ const resolvedContentContainerStyle = typeof contentContainerStyle === 'string'
56
+ ? resolveClassName(contentContainerStyle)
57
+ : contentContainerStyle;
58
+ // The per-axis intrinsics, base style, and content style come from the shared selector
59
+ // (@symbiote-native/components): on Android horizontal resolves to its own ViewManager, on iOS both
60
+ // map back to RCTScrollView; here we only pass the axis.
61
+ const { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle } = selectScrollIntrinsics(isHorizontal, resolvedContentContainerStyle);
62
+ const outerProps = { ...outer };
63
+ // className is pulled out above (unlike the rest of `...outer`) so `layoutSplitStyle` below can
64
+ // resolve it eagerly; re-added here so the simple single-node paths (iOS, Android with no
65
+ // refreshControl) keep forwarding it raw, exactly as it did when it flowed through `...outer`.
66
+ if (className !== undefined)
67
+ outerProps.className = className;
68
+ // RN defaults nested scrolling ON (ScrollView.js:1862 `nestedScrollEnabled ?? true`).
69
+ // Android needs the flag to scroll a scrollable nested inside another scroll view
70
+ // independently; without it the inner one stays put. iOS handles nesting natively, so
71
+ // it is a no-op there. Default to true so nested lists scroll out of the box, like RN.
72
+ outerProps.nestedScrollEnabled = props.nestedScrollEnabled ?? true;
73
+ // iOS needs `horizontal` to flip RCTScrollView's axis; Android's dedicated horizontal
74
+ // manager ignores it. Harmless on Android, load-bearing on iOS, so always forward it.
75
+ if (horizontal !== undefined)
76
+ outerProps.horizontal = horizontal;
77
+ if (decelerationRate !== undefined) {
78
+ outerProps.decelerationRate = resolveDecelerationRate(decelerationRate);
79
+ }
80
+ // onScroll: when sticky headers are active, the offset must reach the AnimatedValue, so
81
+ // we wrap the user's handler with Animated.event (it fires the listener passthrough). RN
82
+ // does the same with _scrollAnimatedValueAttachment. Without sticky headers, forward as-is.
83
+ const nativeStickyAvailable = hasStickyHeaders && isNativeAnimatedAvailable();
84
+ if (hasStickyHeaders) {
85
+ if (nativeStickyAvailable) {
86
+ // Native path (RN attachNativeEvent): the scroll value is driven on the UI thread by the
87
+ // imperative attach in the platform component's effect (useNativeStickyScrollAttach), so
88
+ // onScroll only forwards to the user, zero JS per frame. RN uses throttle 1 when sticky
89
+ // (ScrollView.js:1798); the native driver can afford it.
90
+ if (onScroll !== undefined)
91
+ outerProps.onScroll = onScroll;
92
+ outerProps.scrollEventThrottle = scrollEventThrottle ?? 1;
93
+ }
94
+ else {
95
+ // JS fallback (no native module): Animated.event drives the value each frame and forwards
96
+ // the user's handler as the listener passthrough. Correct, but lags a frame under fast
97
+ // scroll (the jitter), which the native path above removes on a real host.
98
+ outerProps.onScroll = animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], onScroll === undefined
99
+ ? undefined
100
+ : { listener: (...args) => forwardScrollEvent(onScroll, args) });
101
+ outerProps.scrollEventThrottle = scrollEventThrottle ?? 16;
102
+ }
103
+ }
104
+ else {
105
+ if (onScroll !== undefined)
106
+ outerProps.onScroll = onScroll;
107
+ if (scrollEventThrottle !== undefined)
108
+ outerProps.scrollEventThrottle = scrollEventThrottle;
109
+ }
110
+ // onLayout on the scroll-view node: capture the viewport height for inverted sticky headers
111
+ // (RN _handleLayout), then call the user's handler. Pass through unchanged otherwise.
112
+ if (hasStickyHeaders && invertStickyHeaders === true) {
113
+ outerProps.onLayout = (layoutEvent) => {
114
+ const height = readLayoutDimension(layoutEvent, 'height');
115
+ if (height !== undefined)
116
+ setViewportHeight(height);
117
+ onLayout?.(layoutEvent);
118
+ };
119
+ }
120
+ else if (onLayout !== undefined) {
121
+ outerProps.onLayout = onLayout;
122
+ }
123
+ dlog(`ScrollView -> ${scrollViewIntrinsic} (horizontal=${isHorizontal} sticky=${hasStickyHeaders})`);
124
+ // onContentSizeChange is synthesized from the content view's own onLayout (RN
125
+ // _handleContentOnLayout): read width/height off nativeEvent.layout and fire only when the
126
+ // size actually changed (dedupe via a ref, like RN). Composed with any content onLayout.
127
+ const lastContentSizeRef = useRef(null);
128
+ const contentProps = { style: contentStyle, collapsable: false };
129
+ // maintainVisibleContentPosition (and Android snapToAlignment) anchor against the metrics
130
+ // of MOUNTED cell views. Android Fabric view-flattens layout-only cells away, so the native
131
+ // MaintainVisibleScrollPositionHelper has nothing to anchor to and the list jumps on prepend.
132
+ // RN keeps the cells as real views via collapsableChildren={false} on the content container
133
+ // (ScrollView.js:1731-1748 `preserveChildren`). iOS never flattens, so it is a no-op there.
134
+ if (props.maintainVisibleContentPosition !== undefined || props.snapToAlignment !== undefined) {
135
+ contentProps.collapsableChildren = false;
136
+ }
137
+ if (onContentSizeChange !== undefined) {
138
+ contentProps.onLayout = (layoutEvent) => {
139
+ const width = readLayoutDimension(layoutEvent, 'width');
140
+ const height = readLayoutDimension(layoutEvent, 'height');
141
+ if (width === undefined || height === undefined)
142
+ return;
143
+ const last = lastContentSizeRef.current;
144
+ if (!didContentSizeChange(last, { width, height }))
145
+ return;
146
+ lastContentSizeRef.current = { width, height };
147
+ dlog(`ScrollView onContentSizeChange ${width}x${height}`);
148
+ onContentSizeChange(width, height);
149
+ };
150
+ }
151
+ // Sticky headers are a pure-JS layer (the native scroll view ignores stickyHeaderIndices);
152
+ // wrap the flagged children so they pin to the scroll offset. No-op when none are flagged.
153
+ const contentChildren = hasStickyHeaders
154
+ ? wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight, StickyHeaderComponent, headerLayoutYs, onHeaderLayoutY)
155
+ : children;
156
+ // `collapsable: false` is load-bearing on Android. The content container is a
157
+ // layout-only View, which Android Fabric view-flattens away, hoisting the cells
158
+ // up as DIRECT children of the scroll view, which strictly hosts exactly one
159
+ // child ("ScrollView can host only one direct child" → addViewAt crash). RN pins
160
+ // its own NativeScrollContentView the same way (ScrollView.js, collapsable={false};
161
+ // ReactScrollView.java: "the 'content' View … non-collapsable so it will never be
162
+ // View-flattened away"). iOS doesn't flatten, so this is a no-op there.
163
+ const content = createElement(contentIntrinsic, contentProps, contentChildren);
164
+ // resolveClassName(undefined) is a cheap {} no-op, so this is safe with no className too.
165
+ const resolvedClassName = isClassNameValue(className) ? className : undefined;
166
+ const layoutSplitStyle = [resolveClassName(resolvedClassName), style];
167
+ return {
168
+ scrollViewIntrinsic,
169
+ scrollViewBaseStyle,
170
+ outerProps,
171
+ style,
172
+ layoutSplitStyle,
173
+ content,
174
+ refreshControl,
175
+ scrollAnimatedValue,
176
+ nativeStickyAvailable,
177
+ };
178
+ }
179
+ // Attach the scroll event to the scroll-offset value on the NATIVE driver, RN's
180
+ // _updateAnimatedNodeAttachment / AnimatedImplementation.attachNativeEvent (ScrollView.js:1087).
181
+ // Called by each platform ScrollView with its committed scroll-node ref; the value then tracks
182
+ // scroll on the UI thread and the sticky-header interpolations ride it natively (no JS jitter).
183
+ // No-op when native sticky is unavailable or the node hasn't committed. Detaches on unmount.
184
+ // The attach/detach itself lives in @symbiote-native/components (attachStickyScroll); this is the React
185
+ // effect that drives it.
186
+ export function useNativeStickyScrollAttach(scrollNodeRef, scrollAnimatedValue, enabled) {
187
+ useEffect(() => {
188
+ if (!enabled)
189
+ return;
190
+ const node = scrollNodeRef.current;
191
+ if (node === null)
192
+ return;
193
+ return attachStickyScroll(node, scrollAnimatedValue);
194
+ }, [scrollNodeRef, scrollAnimatedValue, enabled]);
195
+ }
@@ -0,0 +1,9 @@
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ import { AnimatedValue } from '@symbiote-native/engine';
3
+ import { type IStickyHeaderProps } from '@symbiote-native/components';
4
+ export type IStickyHeaderComponentProps = IStickyHeaderProps & {
5
+ children?: ReactNode;
6
+ };
7
+ export type IStickyHeaderComponentType = ComponentType<IStickyHeaderComponentProps>;
8
+ export declare const ScrollViewStickyHeader: IStickyHeaderComponentType;
9
+ export declare function wrapStickyHeaders(children: ReactNode, stickyHeaderIndices: number[] | undefined, scrollAnimatedValue: AnimatedValue, invertStickyHeaders: boolean | undefined, scrollViewHeight: number | undefined, StickyHeaderComponent: IStickyHeaderComponentType | undefined, headerLayoutYs: ReadonlyMap<number, number>, onHeaderLayoutY: (index: number, y: number) => void): ReactNode;
@@ -0,0 +1,161 @@
1
+ // Sticky headers: the JS layer RN implements in ScrollView.js / ScrollViewStickyHeader.js.
2
+ //
3
+ // VERDICT (source-based): RN does stickiness PURELY IN JS. ScrollView.js (render, ~line
4
+ // 1690) wraps each child whose index is in `stickyHeaderIndices` in a ScrollViewStickyHeader,
5
+ // fed by a single `_scrollAnimatedValue` an Animated.event drives from `onScroll`
6
+ // (ScrollView.js ~line 1095). The native Fabric scroll view does NOT honor the index array on
7
+ // its own. Forwarding `stickyHeaderIndices` to native is a silent no-op. So we replicate the
8
+ // JS layer: subscribe each flagged child to the scroll offset and translate it to stay pinned.
9
+ // The interpolation mirrors ScrollViewStickyHeader.js (non-inverted + inverted branches) and now
10
+ // lives, framework-agnostic, in @symbiote-native/components (computeStickyInterpolation, ADR 0024); this
11
+ // file holds the React component shell, the layout state, and the child-wrapping.
12
+ import { Children, createElement, isValidElement, useCallback, useEffect, useRef, useState, } from 'react';
13
+ import { AnimatedInterpolation, AnimatedValue, Platform, dlog, } from '@symbiote-native/engine';
14
+ import { computeStickyInterpolation, nextStickyHeaderY, readLayoutNumber, stickyDebounceMs, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
15
+ import { Animated } from '../../modules/animated';
16
+ function readChildOnLayout(child) {
17
+ const childProps = child.props;
18
+ if (typeof childProps !== 'object' || childProps === null)
19
+ return undefined;
20
+ const handler = Reflect.get(childProps, 'onLayout');
21
+ return typeof handler === 'function' ? handler : undefined;
22
+ }
23
+ function firstChild(children) {
24
+ const first = Children.toArray(children)[0];
25
+ return isValidElement(first) ? first : undefined;
26
+ }
27
+ // One sticky header. Measures its own y/height via onLayout, interpolates the shared scroll
28
+ // offset into a translateY that keeps it pinned to the top (or bottom, inverted) until the next
29
+ // header collides with it, and drives that translate through the native driver when available so
30
+ // the pin tracks scroll on the UI thread (no JS jitter). Ported from
31
+ // ScrollViewStickyHeader.js, including the Fabric ShadowTree debounce path.
32
+ export const ScrollViewStickyHeader = props => {
33
+ const { inverted, scrollViewHeight, scrollAnimatedValue, nextHeaderLayoutY, children } = props;
34
+ const [measured, setMeasured] = useState(false);
35
+ const [layoutY, setLayoutY] = useState(0);
36
+ const [layoutHeight, setLayoutHeight] = useState(0);
37
+ // The animated node that drives the transform (RN's animatedTranslateY). When the scroll value
38
+ // is native (attachNativeEvent), this interpolation runs on the UI thread: the smooth pin.
39
+ const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({ inputRange: [-1, 0], outputRange: [0, 0] }));
40
+ // The debounced EXPLICIT translateY pushed to the committed transform via
41
+ // passthroughAnimatedPropExplicitValues, so the Fabric ShadowTree (hit-testing) knows the pinned
42
+ // position while the native driver animates. null until the listener first fires.
43
+ const [translateY, setTranslateY] = useState(null);
44
+ const haveReceivedInitialZeroTranslateY = useRef(true);
45
+ const debounceTimer = useRef(null);
46
+ useEffect(() => {
47
+ if (translateY !== 0 && translateY !== null)
48
+ haveReceivedInitialZeroTranslateY.current = false;
49
+ }, [translateY]);
50
+ // The animated value updates several times per frame during scroll; debounce it and push the
51
+ // settled value into the committed transform so hit detection stays current (RN: a Fabric-only
52
+ // issue, symbiote is always Fabric, and worse on Android).
53
+ const animatedValueListener = useCallback(({ value }) => {
54
+ if (typeof value !== 'number')
55
+ return;
56
+ const timeout = stickyDebounceMs(Platform.OS);
57
+ // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero (RN).
58
+ if (value === 0 && !haveReceivedInitialZeroTranslateY.current) {
59
+ haveReceivedInitialZeroTranslateY.current = true;
60
+ return;
61
+ }
62
+ if (debounceTimer.current !== null)
63
+ clearTimeout(debounceTimer.current);
64
+ debounceTimer.current = setTimeout(() => setTranslateY(value), timeout);
65
+ }, []);
66
+ useEffect(() => {
67
+ const { inputRange, outputRange } = computeStickyInterpolation({
68
+ measured,
69
+ inverted,
70
+ scrollViewHeight,
71
+ layoutY,
72
+ layoutHeight,
73
+ nextHeaderLayoutY,
74
+ });
75
+ const newAnimatedTranslateY = scrollAnimatedValue.interpolate({ inputRange, outputRange });
76
+ // symbiote is always Fabric: listen to the settled value to keep the ShadowTree transform
77
+ // current for hit-testing (RN attaches this listener only under Fabric).
78
+ const listenerId = newAnimatedTranslateY.addListener(animatedValueListener);
79
+ setAnimatedTranslateY(newAnimatedTranslateY);
80
+ return () => {
81
+ newAnimatedTranslateY.removeListener(listenerId);
82
+ if (debounceTimer.current !== null)
83
+ clearTimeout(debounceTimer.current);
84
+ };
85
+ }, [
86
+ measured,
87
+ layoutY,
88
+ layoutHeight,
89
+ scrollViewHeight,
90
+ nextHeaderLayoutY,
91
+ inverted,
92
+ scrollAnimatedValue,
93
+ animatedValueListener,
94
+ ]);
95
+ const onLayout = (event) => {
96
+ const y = readLayoutNumber(event, 'y');
97
+ const height = readLayoutNumber(event, 'height');
98
+ if (y !== undefined)
99
+ setLayoutY(y);
100
+ if (height !== undefined)
101
+ setLayoutHeight(height);
102
+ setMeasured(true);
103
+ props.onLayout(event);
104
+ const child = firstChild(children);
105
+ const childOnLayout = child === undefined ? undefined : readChildOnLayout(child);
106
+ childOnLayout?.(event);
107
+ };
108
+ // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
109
+ // `animatedTranslateY` does the smooth (native-driven) pin, per RN ScrollViewStickyHeader.js.
110
+ const passthroughAnimatedPropExplicitValues = translateY !== null ? { style: { transform: [{ translateY }] } } : null;
111
+ // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
112
+ // OVER the rows scrolling under it. `style` is `unknown` on Animated.View, so the interpolation
113
+ // transform passes with no cast.
114
+ return createElement(Animated.View, {
115
+ style: { transform: [{ translateY: animatedTranslateY }], zIndex: STICKY_HEADER_Z_INDEX },
116
+ onLayout,
117
+ collapsable: false,
118
+ passthroughAnimatedPropExplicitValues,
119
+ }, children);
120
+ };
121
+ ScrollViewStickyHeader.displayName = 'ScrollViewStickyHeader';
122
+ // Wrap each child flagged by `stickyHeaderIndices` in the sticky header component, fed by the
123
+ // shared scroll AnimatedValue. Mirrors ScrollView.js's render-time children.map (~line 1690).
124
+ // Returns the children unchanged when no indices are flagged.
125
+ //
126
+ // Cross-talk plumbing (RN's _headerLayoutYs + _onStickyHeaderLayout, ScrollView.js:1115-1143):
127
+ // `headerLayoutYs` is a child-index→measured-y map the parent keeps; each header reports its own
128
+ // y through `onHeaderLayoutY` as it measures, and we feed every header the y of the NEXT flagged
129
+ // header (the collision point past which it scrolls off) by looking up its successor's index in
130
+ // `stickyHeaderIndices`. The LAST flagged header has no successor, so its `nextHeaderLayoutY`
131
+ // stays undefined and it sticks indefinitely (correct).
132
+ export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent, headerLayoutYs, onHeaderLayoutY) {
133
+ if (stickyHeaderIndices === undefined || stickyHeaderIndices.length === 0)
134
+ return children;
135
+ const Wrapper = StickyHeaderComponent ?? ScrollViewStickyHeader;
136
+ return Children.toArray(children).map((child, index) => {
137
+ const indexOfIndex = stickyHeaderIndices.indexOf(index);
138
+ if (indexOfIndex === -1 || !isValidElement(child))
139
+ return child;
140
+ // The next flagged header's measured y, by index order in stickyHeaderIndices (RN
141
+ // ScrollView.js:1695 nextIndex). undefined until that header has measured (or for the last).
142
+ const nextIndex = stickyHeaderIndices[indexOfIndex + 1];
143
+ const nextHeaderLayoutY = nextStickyHeaderY(stickyHeaderIndices, indexOfIndex, headerLayoutYs);
144
+ dlog(`ScrollView sticky-header wrap index=${index} next=${nextIndex} nextY=${nextHeaderLayoutY}`);
145
+ return createElement(Wrapper, {
146
+ key: child.key ?? `sticky-${index}`,
147
+ nextHeaderLayoutY,
148
+ // RN _onStickyHeaderLayout: record this header's own y, then push it to the previous
149
+ // header as its nextHeaderLayoutY. We record into the parent map; the lookup above feeds
150
+ // it forward on the resulting re-render.
151
+ onLayout: (event) => {
152
+ const y = readLayoutNumber(event, 'y');
153
+ if (y !== undefined)
154
+ onHeaderLayoutY(index, y);
155
+ },
156
+ scrollAnimatedValue,
157
+ inverted: invertStickyHeaders,
158
+ scrollViewHeight,
159
+ }, child);
160
+ });
161
+ }
@@ -0,0 +1,8 @@
1
+ import { type ReactElement, type Ref } from 'react';
2
+ import { type IVirtualizedSectionListHandle, type IVirtualizedSectionListProps } from '../virtualized-section-list';
3
+ export type { ISection } from '../virtualized-section-list';
4
+ export type ISectionListProps<ItemT> = IVirtualizedSectionListProps<ItemT>;
5
+ export type ISectionListHandle = IVirtualizedSectionListHandle;
6
+ export declare function SectionList<ItemT>(props: ISectionListProps<ItemT> & {
7
+ ref?: Ref<ISectionListHandle>;
8
+ }): ReactElement;
@@ -0,0 +1,12 @@
1
+ // SectionList: the public, high-level list-of-sections component. It is a thin
2
+ // wrapper over VirtualizedSectionList, mirroring RN's layering
3
+ // (SectionList -> VirtualizedSectionList -> VirtualizedList). All the
4
+ // section-flattening / windowing / imperative-scroll logic lives in
5
+ // VirtualizedSectionList; this layer only re-exposes the same surface under the
6
+ // SectionList name so existing consumers and the canary keep importing it from
7
+ // here, threading the imperative ref straight through.
8
+ import { createElement } from 'react';
9
+ import { VirtualizedSectionList, } from '../virtualized-section-list';
10
+ export function SectionList(props) {
11
+ return createElement((VirtualizedSectionList), props);
12
+ }
@@ -0,0 +1,2 @@
1
+ export type { ISwitchProps, ISwitchTrackColor } from './shared';
2
+ export declare const Switch: import("react").FC<import("./shared").ISwitchProps>;
@@ -0,0 +1,12 @@
1
+ // Switch, Android host binding. AndroidSwitch takes trackColorForTrue / trackColorForFalse
2
+ // plus trackTintColor (the color for the CURRENT value, which RN computes as value ? true :
3
+ // false), and snaps native back via the `setNativeValue` command (Switch.js:221-225).
4
+ import { createSwitch } from './shared';
5
+ export const Switch = createSwitch({
6
+ snapBackCommand: 'setNativeValue',
7
+ trackColorProps: (value, trackColor) => ({
8
+ trackColorForFalse: trackColor?.false,
9
+ trackColorForTrue: trackColor?.true,
10
+ trackTintColor: value ? trackColor?.true : trackColor?.false,
11
+ }),
12
+ });
@@ -0,0 +1 @@
1
+ export * from './index.ios';
@@ -0,0 +1,2 @@
1
+ export type { ISwitchProps, ISwitchTrackColor } from './shared';
2
+ export declare const Switch: import("react").FC<import("./shared").ISwitchProps>;
@@ -0,0 +1,10 @@
1
+ // Switch, iOS host binding. iOS's native Switch takes onTintColor (ON-track) / tintColor
2
+ // (OFF-track), and snaps native back via the `setValue` command (Switch.js:221-225).
3
+ import { createSwitch } from './shared';
4
+ export const Switch = createSwitch({
5
+ snapBackCommand: 'setValue',
6
+ trackColorProps: (_value, trackColor) => ({
7
+ onTintColor: trackColor?.true,
8
+ tintColor: trackColor?.false,
9
+ }),
10
+ });
@@ -0,0 +1,6 @@
1
+ // Base / default Switch: re-exports the iOS build. Metro overrides this with switch.ios.ts /
2
+ // switch.android.ts on a real host; under tsx / tsc / web the host config resolves here.
3
+ // Filename is the selector, no Platform.OS read. See ADR 0020. The three-layer split (logic
4
+ // in @symbiote-native/components/state, render in @symbiote-native/components/view, hook in switch-shared)
5
+ // replaced the former monolithic Platform.OS-branching component.
6
+ export * from './index.ios';
@@ -0,0 +1,10 @@
1
+ import type { FC } from 'react';
2
+ import type { ISwitchPlatform, ISwitchProps as ISwitchBaseProps } from '@symbiote-native/components';
3
+ export type { ISwitchTrackColor } from '@symbiote-native/components';
4
+ export type ISwitchProps = ISwitchBaseProps & {
5
+ className?: string;
6
+ };
7
+ type ISwitchHostPlatform = ISwitchPlatform & {
8
+ snapBackCommand: string;
9
+ };
10
+ export declare function createSwitch(platform: ISwitchHostPlatform): FC<ISwitchProps>;
@@ -0,0 +1,59 @@
1
+ // Switch is the React lifecycle half. The logic (the lastNativeReport reducer, valueFromChange,
2
+ // the snap-back decision) lives in @symbiote-native/components/state, the render (value fold, track
3
+ // colors, ios_backgroundColor) in @symbiote-native/components/view; here the adapter only owns the
4
+ // hook: useReducer over the shared reducer, a ref for the imperative command, and the
5
+ // useLayoutEffect that snaps native back when the parent rejects a toggle.
6
+ //
7
+ // Switch is controlled exactly like RN's: the parent's onValueChange MUST update `value` for
8
+ // the toggle to stick. If the handler is a no-op, native has already flipped its own grip, so
9
+ // JS commands the old value back down (a plain prop re-push cannot cover the no-op case: the
10
+ // prop never changes, so the retained tree never diverges). The command name is platform-
11
+ // specific (iOS setValue / Android setNativeValue) and arrives via `platform`.
12
+ import { useCallback, useLayoutEffect, useReducer, useRef } from 'react';
13
+ import { renderSwitch, switchReducer, createInitialSwitchState, shouldSnapBack, valueFromChange, } from '@symbiote-native/components';
14
+ import { dispatchViewCommand, dlog, } from '@symbiote-native/engine';
15
+ import { resolveAccessibilityProps } from '@symbiote-native/components';
16
+ import { descriptorToReact } from '../../descriptor-to-react';
17
+ export function createSwitch(platform) {
18
+ return rawProps => {
19
+ // Switch owns its host element rather than rendering through a symbiote View, so it folds
20
+ // aria/role into accessibility* here; the resolved accessibility* fields (plus testID etc.)
21
+ // ride down to the host node via `passthrough`.
22
+ const props = resolveAccessibilityProps(rawProps);
23
+ const { value, onValueChange, disabled, trackColor, thumbColor, ios_backgroundColor, style, ...passthrough } = props;
24
+ const ref = useRef(null);
25
+ const [state, dispatch] = useReducer(switchReducer, undefined, createInitialSwitchState);
26
+ const handleChange = useCallback((event) => {
27
+ const next = valueFromChange(event);
28
+ dlog(`Switch onChange value=${String(next)} eventCount=${String(event.nativeEvent.eventCount)}`);
29
+ if (next === undefined)
30
+ return;
31
+ onValueChange?.(next, event);
32
+ dispatch({ type: 'native-reported', value: next });
33
+ }, [onValueChange]);
34
+ // value is a real Fabric prop, folded to a strict boolean: RN sends `value === true`, so
35
+ // an undefined prop reads as "off" rather than absent.
36
+ const fabricValue = value === true;
37
+ useLayoutEffect(() => {
38
+ const node = ref.current;
39
+ if (node === null)
40
+ return;
41
+ if (!shouldSnapBack(state, fabricValue)) {
42
+ dlog(`Switch snap-back no-op reported=${String(state.lastNativeReport)} value=${fabricValue}`);
43
+ return;
44
+ }
45
+ dlog(`Switch ${platform.snapBackCommand} snap-back reported=${String(state.lastNativeReport)} value=${fabricValue}`);
46
+ dispatchViewCommand(node, platform.snapBackCommand, [fabricValue]);
47
+ }, [fabricValue, state]);
48
+ const descriptor = renderSwitch({
49
+ value: fabricValue,
50
+ disabled,
51
+ trackColor,
52
+ thumbColor,
53
+ ios_backgroundColor,
54
+ style,
55
+ passthrough: { ...passthrough, ref, onChange: handleChange },
56
+ }, platform);
57
+ return descriptorToReact(descriptor);
58
+ };
59
+ }