@symbiote-native/vue 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/build/components/activity-indicator/index.android.d.ts +4 -0
  4. package/build/components/activity-indicator/index.android.js +10 -0
  5. package/build/components/activity-indicator/index.d.ts +1 -0
  6. package/build/components/activity-indicator/index.ios.d.ts +4 -0
  7. package/build/components/activity-indicator/index.ios.js +10 -0
  8. package/build/components/activity-indicator/index.js +4 -0
  9. package/build/components/activity-indicator/shared.d.ts +8 -0
  10. package/build/components/activity-indicator/shared.js +48 -0
  11. package/build/components/button.d.ts +14 -0
  12. package/build/components/button.js +59 -0
  13. package/build/components/drawer-layout-android/index.android.d.ts +8 -0
  14. package/build/components/drawer-layout-android/index.android.js +144 -0
  15. package/build/components/drawer-layout-android/index.d.ts +8 -0
  16. package/build/components/drawer-layout-android/index.js +36 -0
  17. package/build/components/drawer-layout-android/shared.d.ts +18 -0
  18. package/build/components/drawer-layout-android/shared.js +6 -0
  19. package/build/components/flat-list/index.d.ts +88 -0
  20. package/build/components/flat-list/index.js +189 -0
  21. package/build/components/image-background.d.ts +9 -0
  22. package/build/components/image-background.js +114 -0
  23. package/build/components/image.d.ts +9 -0
  24. package/build/components/image.js +99 -0
  25. package/build/components/input-accessory-view.d.ts +10 -0
  26. package/build/components/input-accessory-view.js +41 -0
  27. package/build/components/keyboard-avoiding-view.d.ts +18 -0
  28. package/build/components/keyboard-avoiding-view.js +121 -0
  29. package/build/components/modal.d.ts +29 -0
  30. package/build/components/modal.js +134 -0
  31. package/build/components/pressable.d.ts +58 -0
  32. package/build/components/pressable.js +221 -0
  33. package/build/components/refresh-control.d.ts +20 -0
  34. package/build/components/refresh-control.js +53 -0
  35. package/build/components/safe-area-view.d.ts +8 -0
  36. package/build/components/safe-area-view.js +33 -0
  37. package/build/components/scroll-view/index.android.d.ts +4 -0
  38. package/build/components/scroll-view/index.android.js +74 -0
  39. package/build/components/scroll-view/index.d.ts +1 -0
  40. package/build/components/scroll-view/index.ios.d.ts +4 -0
  41. package/build/components/scroll-view/index.ios.js +15 -0
  42. package/build/components/scroll-view/index.js +4 -0
  43. package/build/components/scroll-view/shared.d.ts +96 -0
  44. package/build/components/scroll-view/shared.js +297 -0
  45. package/build/components/scroll-view/sticky-header.d.ts +7 -0
  46. package/build/components/scroll-view/sticky-header.js +195 -0
  47. package/build/components/section-list/index.d.ts +39 -0
  48. package/build/components/section-list/index.js +77 -0
  49. package/build/components/switch/index.android.d.ts +12 -0
  50. package/build/components/switch/index.android.js +13 -0
  51. package/build/components/switch/index.d.ts +2 -0
  52. package/build/components/switch/index.ios.d.ts +12 -0
  53. package/build/components/switch/index.ios.js +11 -0
  54. package/build/components/switch/index.js +5 -0
  55. package/build/components/switch/shared.d.ts +23 -0
  56. package/build/components/switch/shared.js +129 -0
  57. package/build/components/text-input/index.d.ts +24 -0
  58. package/build/components/text-input/index.js +257 -0
  59. package/build/components/touchable-native-feedback.d.ts +24 -0
  60. package/build/components/touchable-native-feedback.js +75 -0
  61. package/build/components/touchable.d.ts +44 -0
  62. package/build/components/touchable.js +150 -0
  63. package/build/components/virtualized-list/index.d.ts +121 -0
  64. package/build/components/virtualized-list/index.js +687 -0
  65. package/build/components/virtualized-section-list/index.d.ts +96 -0
  66. package/build/components/virtualized-section-list/index.js +160 -0
  67. package/build/components.d.ts +51 -0
  68. package/build/components.js +26 -0
  69. package/build/composables/use-color-scheme.d.ts +3 -0
  70. package/build/composables/use-color-scheme.js +24 -0
  71. package/build/composables/use-window-dimensions.d.ts +3 -0
  72. package/build/composables/use-window-dimensions.js +34 -0
  73. package/build/create-tunnel.d.ts +9 -0
  74. package/build/create-tunnel.js +37 -0
  75. package/build/descriptor-to-vue.d.ts +3 -0
  76. package/build/descriptor-to-vue.js +14 -0
  77. package/build/host-instance/index.d.ts +2 -0
  78. package/build/host-instance/index.js +34 -0
  79. package/build/index.d.ts +67 -0
  80. package/build/index.js +90 -0
  81. package/build/modules/animated/create-animated-component.d.ts +4 -0
  82. package/build/modules/animated/create-animated-component.js +169 -0
  83. package/build/modules/animated/index.d.ts +55 -0
  84. package/build/modules/animated/index.js +66 -0
  85. package/build/modules/app-registry/index.d.ts +7 -0
  86. package/build/modules/app-registry/index.js +32 -0
  87. package/build/modules/status-bar.d.ts +17 -0
  88. package/build/modules/status-bar.js +65 -0
  89. package/build/render.d.ts +7 -0
  90. package/build/render.js +43 -0
  91. package/build/renderer.d.ts +4 -0
  92. package/build/renderer.js +129 -0
  93. package/build/runtime-helpers.d.ts +28 -0
  94. package/build/runtime-helpers.js +68 -0
  95. package/build/utils/component-helpers.d.ts +2 -0
  96. package/build/utils/component-helpers.js +13 -0
  97. package/build/utils/model-binding.d.ts +4 -0
  98. package/build/utils/model-binding.js +17 -0
  99. package/build/utils/normalize-attrs.d.ts +1 -0
  100. package/build/utils/normalize-attrs.js +30 -0
  101. package/build/utils/slots-to-render-props.d.ts +4 -0
  102. package/build/utils/slots-to-render-props.js +18 -0
  103. package/metro-css-parser.cjs +12 -0
  104. package/package.json +54 -0
@@ -0,0 +1,33 @@
1
+ // SafeAreaView, the Vue lifecycle half (ADR 0024 Phase 2). A plain view whose native side
2
+ // insets its children to the safe area (notch, rounded corners, system bars); there is no
3
+ // JS-side translation, so this maps style + children straight onto the intrinsic. The Vue twin
4
+ // of the React adapter's SafeAreaView. Vue takes children via slots, folds aria/role through
5
+ // the shared resolveAccessibilityProps (so every adapter normalizes the web aliases identically),
6
+ // and forwards the rest onto the symbiote-safe-area-view host node.
7
+ //
8
+ // Inputs arrive as attrs (untyped), so the forwarded bag is BUILT at the a11y-intersection type
9
+ // (a genuine narrowing, not a cast) before resolveAccessibilityProps folds aria-* into accessibility*.
10
+ import { defineComponent, h } from '@vue/runtime-core';
11
+ import { dlog } from '@symbiote-native/engine';
12
+ import { resolveAccessibilityProps, } from '@symbiote-native/components';
13
+ import { normalizeVueAttrs } from '../utils/normalize-attrs';
14
+ // Copy every attr into a bag typed as the a11y intersection (the accumulator is BUILT at that
15
+ // type, a real narrowing, not a cast), then fold aria-*/role into the canonical accessibility*
16
+ // props before they reach the host. style / onLayout / testID forward unchanged.
17
+ function foldAttrs(attrs) {
18
+ const bag = {};
19
+ for (const key of Object.keys(attrs))
20
+ bag[key] = attrs[key];
21
+ return resolveAccessibilityProps(bag);
22
+ }
23
+ export const SafeAreaView = defineComponent({
24
+ name: 'SafeAreaView',
25
+ inheritAttrs: false,
26
+ setup(_props, { attrs: rawAttrs, slots }) {
27
+ return () => {
28
+ dlog('SafeAreaView -> SafeAreaView');
29
+ const nativeProps = foldAttrs(normalizeVueAttrs(rawAttrs));
30
+ return h('symbiote-safe-area-view', nativeProps, slots.default !== undefined ? slots.default() : undefined);
31
+ };
32
+ },
33
+ });
@@ -0,0 +1,4 @@
1
+ export type { IScrollViewProps, IScrollViewEmits, IScrollViewHandle } from './shared';
2
+ export declare const ScrollView: import("vue").DefineSetupFnComponent<import("./shared").IScrollViewProps, import("./shared").IScrollViewEmits, {}, import("./shared").IScrollViewProps & {
3
+ onContentSizeChange?: ((width: number, height: number) => any) | undefined;
4
+ }, import("vue").PublicProps>;
@@ -0,0 +1,74 @@
1
+ // ScrollView on Android (ADR 0024 Phase 2). An Android ScrollView accepts only ONE child, so a
2
+ // RefreshControl can't be a sibling of the content the way iOS allows ("addViewAt: failed to insert
3
+ // view ... at index 1"). Instead the RefreshControl (AndroidSwipeRefreshLayout) WRAPS the scroll
4
+ // view, with the scroll view nested inside and nestedScrollEnabled so the inner scroll handles the
5
+ // gesture before the refresh parent, mirroring RN's ScrollView.js android branch.
6
+ //
7
+ // React does this with cloneElement(refreshControl, {style}, scrollView). Vue has NO cloneElement,
8
+ // so the analog is to RE-INVOKE the user's RefreshControl component type via h(): same .type, its
9
+ // own .props plus the injected outer/layout style, and the inner scroll view as its DEFAULT SLOT.
10
+ // The slot is the seam: RefreshControl renders slots.default() as the host node's children. The
11
+ // node ref stays on the INNER scroll view (not the wrapper), so dispatchViewCommand targets it.
12
+ // Metro picks this on an Android host; no Platform.OS read.
13
+ // device-verify-pending: the wrap shape mirrors RN, proven on a real host by the absence of the
14
+ // "addViewAt: failed to insert" crash.
15
+ import { h, isVNode } from '@vue/runtime-core';
16
+ import { dlog } from '@symbiote-native/engine';
17
+ import { splitLayoutProps } from '@symbiote-native/components';
18
+ import { createScrollView } from './shared';
19
+ function isRecord(value) {
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ }
22
+ // A Vue VNode's `.type` is VNodeTypes (string | Component | nested VNode | the Fragment/Text/…
23
+ // symbol constants). Re-invoking via h() needs it narrowed to what h accepts (string | Component):
24
+ // the symbol constants fail the typeof checks and a nested VNode is ruled out by isVNode. A type
25
+ // predicate (not a plain return) sidesteps the assignability gap; refreshControl is always a real
26
+ // component element in practice.
27
+ function isHostType(type) {
28
+ if (typeof type === 'string')
29
+ return true;
30
+ return ((typeof type === 'object' || typeof type === 'function') && type !== null && !isVNode(type));
31
+ }
32
+ export const ScrollView = createScrollView({
33
+ assemble: input => {
34
+ if (input.refreshControl === undefined) {
35
+ // No refresh: the same single-child path as Phase 1; the node ref rides input.scrollProps.
36
+ dlog('Vue ScrollView.ANDROID refreshControl=NONE(1child)');
37
+ return h(input.scrollViewIntrinsic, input.scrollProps, [input.content]);
38
+ }
39
+ // RN splits the flattened style across the two boxes (splitLayoutProps): LAYOUT props
40
+ // (margin/flex/size/position/…) drive the outer AndroidSwipeRefreshLayout frame; VISUAL props
41
+ // (background/padding/border/…) paint the inner scroll view. So the wrapper carries `outer`, and
42
+ // the inner scroll view its base (flexDirection/overflow) plus the visual `inner` composed over
43
+ // it, NOT a hardcoded flex:1 that would override an explicit user height/width.
44
+ //
45
+ // layoutSplitStyle (not userStyle): a class-only layout prop (flex/height/gap/…) is invisible
46
+ // to userStyle (it never carries the resolved `class` value — see isClassNameProp in
47
+ // shared.ts), so splitting on userStyle alone starves the wrapper of its layout style and it
48
+ // collapses to nothing. layoutSplitStyle is userStyle merged with the resolved class style.
49
+ const { outer, inner } = splitLayoutProps(input.layoutSplitStyle);
50
+ // `class` is stripped from the spread here: layoutSplitStyle already folded its resolved
51
+ // value into `outer`/`inner` above, so forwarding the raw prop too would re-apply its LAYOUT
52
+ // half onto the inner scroll view a second time (on top of the outer wrapper), the same
53
+ // wrapper/inner leak `style` already avoids by living in HANDLED_ATTRS.
54
+ const { class: _classAppliedViaSplit, ...innerScrollOuterProps } = input.scrollOuterProps;
55
+ const innerScrollView = h(input.scrollViewIntrinsic, {
56
+ ...innerScrollOuterProps,
57
+ style: [input.scrollViewBaseStyle, inner],
58
+ nestedScrollEnabled: true,
59
+ ref: input.setNodeRef,
60
+ }, [input.content]);
61
+ const rc = input.refreshControl;
62
+ if (!isHostType(rc.type)) {
63
+ // refreshControl is always a component VNode; a symbol/nested-VNode type can't host children.
64
+ // Degrade to the unwrapped scroll view rather than crash (the node ref is already on it).
65
+ dlog('Vue ScrollView.ANDROID refreshControl has no hostable type, rendering scroll view unwrapped');
66
+ return innerScrollView;
67
+ }
68
+ const rcProps = isRecord(rc.props) ? rc.props : {};
69
+ dlog('Vue ScrollView.ANDROID refreshControl=WRAP');
70
+ // Re-invoke the user's RefreshControl: same type, its own props + the outer/layout style, and the
71
+ // inner scroll view as the default slot (the Vue analog of cloneElement(refreshControl, {style}, sv)).
72
+ return h(rc.type, { ...rcProps, style: outer }, { default: () => [innerScrollView] });
73
+ },
74
+ });
@@ -0,0 +1 @@
1
+ export * from './index.ios';
@@ -0,0 +1,4 @@
1
+ export type { IScrollViewProps, IScrollViewEmits, IScrollViewHandle } from './shared';
2
+ export declare const ScrollView: import("vue").DefineSetupFnComponent<import("./shared").IScrollViewProps, import("./shared").IScrollViewEmits, {}, import("./shared").IScrollViewProps & {
3
+ onContentSizeChange?: ((width: number, height: number) => any) | undefined;
4
+ }, import("vue").PublicProps>;
@@ -0,0 +1,15 @@
1
+ // ScrollView on iOS (ADR 0024 Phase 2). The RefreshControl is a CHILD of the scroll view, rendered
2
+ // as a SIBLING BEFORE the content container (RN ScrollView.js: {refreshControl}{contentContainer}).
3
+ // The user's RefreshControl VNode renders as-is; its default slot is empty on iOS, so it is a
4
+ // childless sibling. Also the base (scroll-view.ts re-exports it) for headless / web. Metro picks
5
+ // this on an iOS host; no Platform.OS read. Mirrors the React adapter's iOS binding.
6
+ import { h } from '@vue/runtime-core';
7
+ import { createScrollView } from './shared';
8
+ export const ScrollView = createScrollView({
9
+ assemble: input => {
10
+ // Sibling placement: RefreshControl before content (RN iOS). The node ref stays on the scroll
11
+ // view via input.scrollProps, so the imperative handle targets it whether or not refresh is on.
12
+ const children = input.refreshControl === undefined ? [input.content] : [input.refreshControl, input.content];
13
+ return h(input.scrollViewIntrinsic, input.scrollProps, children);
14
+ },
15
+ });
@@ -0,0 +1,4 @@
1
+ // Base / default ScrollView. Re-exports the iOS build. Metro overrides this with
2
+ // scroll-view.ios.ts / scroll-view.android.ts on a real host; under tsx / tsc / web the host
3
+ // resolves here. Filename is the selector, no Platform.OS read. See ADR 0020 / 0024.
4
+ export * from './index.ios';
@@ -0,0 +1,96 @@
1
+ import { type VNode } from '@vue/runtime-core';
2
+ import { type IAccessibilityProps, type IAriaProps, type ISymbioteIntrinsic } from '@symbiote-native/components';
3
+ import { type IClassNameValue, type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
4
+ import { type IStickyHeaderComponentType } from './sticky-header';
5
+ export type { IScrollViewHandle } from '@symbiote-native/components';
6
+ type IScrollHandler = (event: ISymbioteEvent) => void;
7
+ export interface IScrollViewProps extends IAccessibilityProps, IAriaProps {
8
+ style?: IStyleProp<IViewStyle>;
9
+ contentContainerStyle?: IStyleProp<IViewStyle> | string;
10
+ horizontal?: boolean;
11
+ scrollEnabled?: boolean;
12
+ showsVerticalScrollIndicator?: boolean;
13
+ showsHorizontalScrollIndicator?: boolean;
14
+ pagingEnabled?: boolean;
15
+ bounces?: boolean;
16
+ decelerationRate?: 'normal' | 'fast' | number;
17
+ scrollEventThrottle?: number;
18
+ contentInset?: {
19
+ top?: number;
20
+ left?: number;
21
+ bottom?: number;
22
+ right?: number;
23
+ };
24
+ contentOffset?: {
25
+ x: number;
26
+ y: number;
27
+ };
28
+ refreshControl?: VNode;
29
+ removeClippedSubviews?: boolean;
30
+ class?: IClassNameValue;
31
+ snapToInterval?: number;
32
+ snapToOffsets?: number[];
33
+ snapToAlignment?: 'start' | 'center' | 'end';
34
+ snapToStart?: boolean;
35
+ snapToEnd?: boolean;
36
+ disableIntervalMomentum?: boolean;
37
+ stickyHeaderIndices?: number[];
38
+ invertStickyHeaders?: boolean;
39
+ StickyHeaderComponent?: IStickyHeaderComponentType;
40
+ keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
41
+ keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled';
42
+ maintainVisibleContentPosition?: {
43
+ minIndexForVisible: number;
44
+ autoscrollToTopThreshold?: number;
45
+ };
46
+ alwaysBounceHorizontal?: boolean;
47
+ alwaysBounceVertical?: boolean;
48
+ centerContent?: boolean;
49
+ scrollIndicatorInsets?: {
50
+ top?: number;
51
+ left?: number;
52
+ bottom?: number;
53
+ right?: number;
54
+ };
55
+ indicatorStyle?: 'default' | 'black' | 'white';
56
+ directionalLockEnabled?: boolean;
57
+ automaticallyAdjustKeyboardInsets?: boolean;
58
+ contentInsetAdjustmentBehavior?: 'automatic' | 'scrollableAxes' | 'never' | 'always';
59
+ minimumZoomScale?: number;
60
+ maximumZoomScale?: number;
61
+ zoomScale?: number;
62
+ bouncesZoom?: boolean;
63
+ pinchGestureEnabled?: boolean;
64
+ nestedScrollEnabled?: boolean;
65
+ overScrollMode?: 'auto' | 'always' | 'never';
66
+ fadingEdgeLength?: number;
67
+ persistentScrollbar?: boolean;
68
+ endFillColor?: string;
69
+ onLayout?: IScrollHandler;
70
+ onScroll?: IScrollHandler;
71
+ onScrollBeginDrag?: IScrollHandler;
72
+ onScrollEndDrag?: IScrollHandler;
73
+ onMomentumScrollBegin?: IScrollHandler;
74
+ onMomentumScrollEnd?: IScrollHandler;
75
+ onScrollToTop?: IScrollHandler;
76
+ }
77
+ export type IScrollViewEmits = {
78
+ contentSizeChange: (width: number, height: number) => boolean;
79
+ };
80
+ export interface IScrollViewAssembleInput {
81
+ scrollViewIntrinsic: ISymbioteIntrinsic;
82
+ scrollProps: Record<string, unknown>;
83
+ content: VNode;
84
+ refreshControl: VNode | undefined;
85
+ scrollViewBaseStyle: IViewStyle;
86
+ userStyle: IStyleProp<IViewStyle> | undefined;
87
+ layoutSplitStyle: IStyleProp<IViewStyle>;
88
+ scrollOuterProps: Record<string, unknown>;
89
+ setNodeRef: (el: unknown) => void;
90
+ }
91
+ export interface IScrollViewPlatform {
92
+ assemble: (input: IScrollViewAssembleInput) => VNode;
93
+ }
94
+ export declare function createScrollView(platform: IScrollViewPlatform): import("vue").DefineSetupFnComponent<IScrollViewProps, IScrollViewEmits, {}, IScrollViewProps & {
95
+ onContentSizeChange?: ((width: number, height: number) => any) | undefined;
96
+ }, import("vue").PublicProps>;
@@ -0,0 +1,297 @@
1
+ // ScrollView, the Vue lifecycle half (Phase 1: base, ADR 0024). The Fabric tree is nested:
2
+ // a scroll view wraps a content view that holds the children (RN's ScrollView.js shape). The
3
+ // platform-invariant math (decelerationRate, the per-axis intrinsics/base style, the
4
+ // content-size dedupe, the imperative handle, the aria/role fold) lives in @symbiote-native/components,
5
+ // shared verbatim with React. Here Vue supplies only the reactivity: a shallowRef holds the host
6
+ // node, a setup-scope `lastContentSize` dedupes onContentSizeChange, and expose() wires the
7
+ // imperative handle. This is the Vue twin of the React adapter's useRef + buildScrollViewHandle.
8
+ //
9
+ // Inputs arrive as attrs (untyped), so each is narrowed with a runtime guard rather than a cast.
10
+ // contentSizeChange is synthesized as a typed Vue emit from the content onLayout. The legacy
11
+ // onContentSizeChange callback key MUST be consumed if it arrives (it is NOT a ViewConfig event;
12
+ // forwarding a function prop would reach Fabric and crash Android's folly::dynamic). Scroll events
13
+ // (onScroll/onLayout/…) ARE ViewConfig events, so they forward raw and routeProp turns them into
14
+ // listeners.
15
+ //
16
+ // Phase 2 (ADR 0024 §4): RefreshControl is wired through the platform assemble (iOS sibling /
17
+ // Android wrap). Phase 3 (§5): sticky headers are real. The scroll AnimatedValue (markRaw, held by
18
+ // identity), the headerLayoutYs cross-talk map + bump, the viewport-height capture, and the
19
+ // onScroll composition (native attach vs Animated.event) all live here; the per-header component and
20
+ // the children wrap live in scroll-view-sticky-header.ts (the Vue twin of the React file).
21
+ import { defineComponent, h, isVNode, markRaw, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
22
+ import { attachStickyScroll, buildScrollViewHandle, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveAccessibilityProps, resolveDecelerationRate, selectScrollIntrinsics, } from '@symbiote-native/components';
23
+ import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, isSymbioteNode, resolveClassName, } from '@symbiote-native/engine';
24
+ import { wrapStickyHeaders } from './sticky-header';
25
+ import { normalizeVueAttrs } from '../../utils/normalize-attrs';
26
+ function isHandler(value) {
27
+ return typeof value === 'function';
28
+ }
29
+ // Objects and arrays are valid StyleProp<ViewStyle> (ViewStyle | RecursiveArray | falsy; the
30
+ // engine omits RN's RegisteredStyle brand, so no numeric form). Primitives/null degrade to
31
+ // undefined; the engine flattens whatever object/array reaches it.
32
+ function isStyleProp(value) {
33
+ return typeof value === 'object' && value !== null;
34
+ }
35
+ // `class` is never in HANDLED_ATTRS (inheritAttrs:false means it also isn't auto-merged onto a
36
+ // root), so it forwards raw to the inner scroll-view node via forwardAttrs, resolved later by
37
+ // the renderer's own patchProp — fine for the single-node Phase 1 path. But the Android
38
+ // RefreshControl wrap (index.android.ts) reads userStyle alone to splitLayoutProps() the outer
39
+ // wrapper's layout style, BEFORE that later resolution ever runs, so a class-only layout prop
40
+ // (flex, height, gap, …) never reaches the wrapper and it collapses to nothing. See the
41
+ // symbiote-sfc-style-compiler skill for the failure this caused on a real Android device.
42
+ // isClassNameProp is @symbiote-native/engine's own isClassNameValue guard (shared, not redeclared —
43
+ // routeProp's centralized class+style merge needs the identical narrowing).
44
+ const isClassNameProp = isClassNameValue;
45
+ // decelerationRate is resolved per-platform (resolveDecelerationRate), so it must be narrowed to
46
+ // its declared shape before that call, unlike the raw pass-through props.
47
+ function asDecelerationRate(value) {
48
+ if (typeof value === 'number')
49
+ return value;
50
+ if (value === 'normal' || value === 'fast')
51
+ return value;
52
+ return undefined;
53
+ }
54
+ // stickyHeaderIndices arrives untyped (attrs); narrow to a number[] before the wrap reads it.
55
+ function isNumberArray(value) {
56
+ return Array.isArray(value) && value.every(entry => typeof entry === 'number');
57
+ }
58
+ // A StickyHeaderComponent override is a Vue component: a function (functional) or an object
59
+ // (defineComponent). Narrowed before it is handed to wrapStickyHeaders (no cast).
60
+ function isComponent(value) {
61
+ return typeof value === 'function' || (typeof value === 'object' && value !== null);
62
+ }
63
+ // The prop/handler keys the lifecycle consumes itself; everything else (the scroll events,
64
+ // snap/keyboard/zoom families, accessibility, testID, …) forwards onto the scroll-view node.
65
+ // onContentSizeChange is consumed (synthesized from the content onLayout, never forwarded);
66
+ // refreshControl is consumed by the platform assemble; the sticky-header props (indices / invert /
67
+ // StickyHeaderComponent) are lifecycle-consumed by the children wrap and must NEVER reach Fabric.
68
+ // style / contentContainerStyle / horizontal / decelerationRate are recomposed.
69
+ const HANDLED_ATTRS = [
70
+ 'style',
71
+ 'contentContainerStyle',
72
+ 'horizontal',
73
+ 'decelerationRate',
74
+ 'onContentSizeChange',
75
+ 'refreshControl',
76
+ 'stickyHeaderIndices',
77
+ 'invertStickyHeaders',
78
+ 'StickyHeaderComponent',
79
+ ];
80
+ function forwardAttrs(attrs) {
81
+ const result = {};
82
+ for (const key of Object.keys(attrs)) {
83
+ if (!HANDLED_ATTRS.includes(key))
84
+ result[key] = attrs[key];
85
+ }
86
+ return result;
87
+ }
88
+ export function createScrollView(platform) {
89
+ return defineComponent((_props, { slots, attrs: rawAttrs, expose, emit }) => {
90
+ // shallowRef, NOT ref: the engine node must be held by IDENTITY. A plain ref() runs the
91
+ // node through Vue's toReactive(), handing back a reactive Proxy, a different object than
92
+ // the raw node the engine's mirror (a WeakMap) is keyed on, so dispatchViewCommand would
93
+ // miss and every scrollTo/scrollToEnd/flashScrollIndicators silently no-op. See
94
+ // .claude/skills/vue-adapter-reactivity. This is the same rule as the Switch host node.
95
+ const nodeRef = shallowRef(null);
96
+ const setNodeRef = (el) => {
97
+ nodeRef.value = isSymbioteNode(el) ? el : null;
98
+ };
99
+ // The imperative handle reads the node through a LAZY getter (() => nodeRef.value), not the
100
+ // node captured once: it is null until the element commits, so an eager capture would freeze
101
+ // null and every command would no-op (ADR 0024 §3). expose() makes it the value a parent ref
102
+ // sees: the Vue twin of React's useImperativeHandle(forwardedRef, buildScrollViewHandle(…)).
103
+ expose(buildScrollViewHandle(() => nodeRef.value));
104
+ // The last-seen content size, kept here (setup scope, persists across renders) to dedupe
105
+ // contentSizeChange: RN fires the content onLayout on every layout pass; only real size
106
+ // changes emit to the user (didContentSizeChange).
107
+ let lastContentSize = null;
108
+ // A single AnimatedValue tracks the scroll offset and drives every sticky header's translateY
109
+ // (RN's _scrollAnimatedValue). A setup-scope const (allocated once, stable across renders) so
110
+ // the headers' bindings survive re-renders, the Vue twin of React's useRef-stable value.
111
+ // markRaw: it is an engine object, held by IDENTITY, never run through toReactive (the
112
+ // reactivity rule; a deep ref would hand back a Proxy the engine's mirror misses). Allocated
113
+ // unconditionally (like React's unconditional hook); unused when no sticky headers are flagged.
114
+ const scrollAnimatedValue = markRaw(new AnimatedValue(0));
115
+ // Inverted sticky headers stick to the BOTTOM, so they need the viewport height (RN reads it
116
+ // in _handleLayout). Tracked here and fed back into the wrapped headers on the next render.
117
+ const viewportHeight = ref(undefined);
118
+ // Sticky-header cross-talk (RN ScrollView.js _headerLayoutYs, line 754): a child-index→measured-y
119
+ // map the parent keeps so each header can learn where the NEXT sticky header starts (its push-off
120
+ // collision point). The map is a setup-scope const mutated imperatively from each header's onLayout
121
+ // (like RN's _onStickyHeaderLayout); a reactive bump ref forces the re-render that feeds the
122
+ // freshly-recorded y forward into the previous header's nextHeaderLayoutY (via nextStickyHeaderY).
123
+ const headerLayoutYs = new Map();
124
+ const bumpHeaderLayout = ref(0);
125
+ const onHeaderLayoutY = (index, y) => {
126
+ if (headerLayoutYs.get(index) === y)
127
+ return;
128
+ headerLayoutYs.set(index, y);
129
+ dlog(`Vue ScrollView sticky-header layoutY index=${index} y=${y}`);
130
+ bumpHeaderLayout.value += 1;
131
+ };
132
+ // Native sticky-scroll attach (RN attachNativeEvent / _updateAnimatedNodeAttachment): when the
133
+ // native module is available, the scroll value is driven on the UI thread so the interpolations
134
+ // ride scroll natively (no JS jitter). A plain flag set in render (like createAnimatedComponent's
135
+ // wantsNative: non-reactive so writing it in render triggers no effect); the post-commit watch
136
+ // reads it once the node commits. The JS path needs no attach: Animated.event drives the value
137
+ // each frame. flush:'post' so the engine has committed the node before attachStickyScroll reads
138
+ // its Fabric handle. Detached on unmount (and re-detached if the node identity changes).
139
+ let nativeStickyWanted = false;
140
+ let detachStickyScroll;
141
+ watch(() => nodeRef.value, node => {
142
+ if (detachStickyScroll !== undefined) {
143
+ detachStickyScroll();
144
+ detachStickyScroll = undefined;
145
+ }
146
+ if (!nativeStickyWanted || node === null)
147
+ return;
148
+ detachStickyScroll = attachStickyScroll(node, scrollAnimatedValue);
149
+ }, { flush: 'post' });
150
+ onBeforeUnmount(() => {
151
+ if (detachStickyScroll !== undefined)
152
+ detachStickyScroll();
153
+ });
154
+ return () => {
155
+ // Read the bump so a recorded header y re-runs render and feeds nextStickyHeaderY forward.
156
+ void bumpHeaderLayout.value;
157
+ // Fold kebab template props (:content-container-style) to the RN camelCase contract; idiomatic
158
+ // Vue templates use kebab, but the prop surface (and HANDLED_ATTRS below) is camelCase.
159
+ const attrs = normalizeVueAttrs(rawAttrs);
160
+ const isHorizontal = attrs.horizontal === true;
161
+ const userStyle = isStyleProp(attrs.style) ? attrs.style : undefined;
162
+ // resolveClassName(undefined) is a cheap {} no-op, so this is safe with no class prop too.
163
+ const classProp = isClassNameProp(attrs.class) ? attrs.class : undefined;
164
+ const layoutSplitStyle = [resolveClassName(classProp), userStyle];
165
+ // A class-name string resolves through the same style registry as `class`/`style` above;
166
+ // an object/array is already style-shaped and passes through as-is.
167
+ const contentContainerStyle = typeof attrs.contentContainerStyle === 'string'
168
+ ? resolveClassName(attrs.contentContainerStyle)
169
+ : isStyleProp(attrs.contentContainerStyle)
170
+ ? attrs.contentContainerStyle
171
+ : undefined;
172
+ // Sticky headers (Phase 3, ADR 0024): a pure-JS layer; the native scroll view ignores
173
+ // stickyHeaderIndices, so we wrap the flagged children below and drive their translateY off
174
+ // the scroll offset. invertStickyHeaders narrows to the inverted (stick-to-bottom) branch.
175
+ const stickyHeaderIndices = isNumberArray(attrs.stickyHeaderIndices)
176
+ ? attrs.stickyHeaderIndices
177
+ : undefined;
178
+ const hasStickyHeaders = stickyHeaderIndices !== undefined && stickyHeaderIndices.length > 0;
179
+ const invertStickyHeaders = attrs.invertStickyHeaders === true ? true : undefined;
180
+ const stickyHeaderComponent = isComponent(attrs.StickyHeaderComponent)
181
+ ? attrs.StickyHeaderComponent
182
+ : undefined;
183
+ const { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle } = selectScrollIntrinsics(isHorizontal, contentContainerStyle);
184
+ // Outer props: fold aria/role, then layer the lifecycle-managed values on top. RN defaults
185
+ // nested scrolling ON (ScrollView.js `nestedScrollEnabled ?? true`); horizontal forwards
186
+ // only when defined (load-bearing on iOS's RCTScrollView axis, ignored by Android's
187
+ // dedicated manager); decelerationRate is resolved per-platform.
188
+ const outerProps = {
189
+ ...resolveAccessibilityProps(forwardAttrs(attrs)),
190
+ };
191
+ outerProps.nestedScrollEnabled =
192
+ typeof attrs.nestedScrollEnabled === 'boolean' ? attrs.nestedScrollEnabled : true;
193
+ if (attrs.horizontal !== undefined)
194
+ outerProps.horizontal = attrs.horizontal;
195
+ const decel = asDecelerationRate(attrs.decelerationRate);
196
+ if (decel !== undefined)
197
+ outerProps.decelerationRate = resolveDecelerationRate(decel);
198
+ // onScroll: when sticky headers are active, the offset must reach the AnimatedValue. RN does
199
+ // the same with _scrollAnimatedValueAttachment. forwardAttrs already put the user's onScroll /
200
+ // onLayout / scrollEventThrottle on outerProps; here we override them for the sticky branch.
201
+ const nativeStickyAvailable = hasStickyHeaders && isNativeAnimatedAvailable();
202
+ nativeStickyWanted = nativeStickyAvailable;
203
+ if (hasStickyHeaders) {
204
+ const userOnScroll = isHandler(attrs.onScroll) ? attrs.onScroll : undefined;
205
+ const userThrottle = typeof attrs.scrollEventThrottle === 'number' ? attrs.scrollEventThrottle : undefined;
206
+ if (nativeStickyAvailable) {
207
+ // Native path (RN attachNativeEvent): the scroll value is driven on the UI thread by the
208
+ // post-commit watch above, so onScroll only forwards to the user: zero JS per frame. RN
209
+ // uses throttle 1 when sticky (ScrollView.js:1798); the native driver can afford it. The
210
+ // user onScroll is already on outerProps via forwardAttrs.
211
+ outerProps.scrollEventThrottle = userThrottle ?? 1;
212
+ }
213
+ else {
214
+ // JS fallback (no native module): Animated.event drives the value each frame and forwards
215
+ // the user's handler as the listener passthrough. Correct, but lags a frame under fast
216
+ // scroll (the jitter), which the native path above removes on a real host.
217
+ outerProps.onScroll = animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], userOnScroll === undefined
218
+ ? undefined
219
+ : { listener: (...args) => forwardScrollEvent(userOnScroll, args) });
220
+ outerProps.scrollEventThrottle = userThrottle ?? 16;
221
+ }
222
+ // onLayout on the scroll-view node: capture the viewport height for inverted sticky headers
223
+ // (RN _handleLayout), then call the user's handler. Non-inverted leaves onLayout as forwarded.
224
+ if (invertStickyHeaders === true) {
225
+ const userOnLayout = isHandler(attrs.onLayout) ? attrs.onLayout : undefined;
226
+ outerProps.onLayout = (event) => {
227
+ const height = readLayoutDimension(event, 'height');
228
+ if (height !== undefined)
229
+ viewportHeight.value = height;
230
+ if (userOnLayout !== undefined)
231
+ userOnLayout(event);
232
+ };
233
+ }
234
+ }
235
+ dlog(`Vue ScrollView -> ${scrollViewIntrinsic} (horizontal=${isHorizontal} sticky=${hasStickyHeaders})`);
236
+ // Content props: `collapsable: false` keeps the layout-only content view as a real native
237
+ // view: Android Fabric view-flattens it away otherwise, hoisting the cells as direct
238
+ // children of the scroll view (which hosts exactly one), an addViewAt crash. collapsableChildren
239
+ // false also preserves the cell views maintainVisibleContentPosition / snapToAlignment
240
+ // anchor against (RN preserveChildren). iOS never flattens; both are no-ops there.
241
+ const contentProps = { style: contentStyle, collapsable: false };
242
+ if (attrs.maintainVisibleContentPosition !== undefined ||
243
+ attrs.snapToAlignment !== undefined) {
244
+ contentProps.collapsableChildren = false;
245
+ }
246
+ // contentSizeChange is synthesized from the content view's own onLayout (RN
247
+ // _handleContentOnLayout): read width/height and emit only on a real size change (dedupe via
248
+ // the setup-scope lastContentSize).
249
+ contentProps.onLayout = (event) => {
250
+ const width = readLayoutDimension(event, 'width');
251
+ const height = readLayoutDimension(event, 'height');
252
+ if (width === undefined || height === undefined)
253
+ return;
254
+ if (!didContentSizeChange(lastContentSize, { width, height }))
255
+ return;
256
+ lastContentSize = { width, height };
257
+ dlog(`Vue ScrollView contentSizeChange ${width}x${height}`);
258
+ emit('contentSizeChange', width, height);
259
+ };
260
+ // Sticky headers are a pure-JS layer (the native scroll view ignores stickyHeaderIndices);
261
+ // wrap the flagged children so they pin to the scroll offset. No-op when none are flagged.
262
+ const slotChildren = slots.default !== undefined ? slots.default() : [];
263
+ const contentChildren = hasStickyHeaders
264
+ ? wrapStickyHeaders(slotChildren, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight.value, stickyHeaderComponent, headerLayoutYs, onHeaderLayoutY)
265
+ : slotChildren;
266
+ const content = h(contentIntrinsic, contentProps, contentChildren);
267
+ // Base style UNDER user style so an explicit user value (height, flexDirection) still wins;
268
+ // the scroll node carries overflow:'scroll' (frame clipping) + the per-axis flexDirection.
269
+ const scrollProps = {
270
+ ...outerProps,
271
+ style: [scrollViewBaseStyle, userStyle],
272
+ ref: setNodeRef,
273
+ };
274
+ // refreshControl arrives as a Vue VNode the app passes (h(RefreshControl, …)); narrow it
275
+ // with isVNode (no cast). Stripped from forwardAttrs (it is in HANDLED_ATTRS) so it never
276
+ // reaches the host as a prop: it is lifecycle-consumed by the platform assemble.
277
+ const refreshControl = isVNode(attrs.refreshControl) ? attrs.refreshControl : undefined;
278
+ return platform.assemble({
279
+ scrollViewIntrinsic,
280
+ scrollProps,
281
+ content,
282
+ refreshControl,
283
+ scrollViewBaseStyle,
284
+ userStyle,
285
+ layoutSplitStyle,
286
+ scrollOuterProps: outerProps,
287
+ setNodeRef,
288
+ });
289
+ };
290
+ }, {
291
+ name: 'ScrollView',
292
+ inheritAttrs: false,
293
+ emits: {
294
+ contentSizeChange: (_width, _height) => true,
295
+ },
296
+ });
297
+ }
@@ -0,0 +1,7 @@
1
+ import { type Component, type VNode } from '@vue/runtime-core';
2
+ import { AnimatedValue } from '@symbiote-native/engine';
3
+ export type IStickyHeaderComponentType = Component;
4
+ export declare const ScrollViewStickyHeader: import("vue").DefineComponent<{}, () => VNode<import("vue").RendererNode, import("vue").RendererElement, {
5
+ [key: string]: any;
6
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
7
+ export declare function wrapStickyHeaders(children: VNode[], stickyHeaderIndices: number[] | undefined, scrollAnimatedValue: AnimatedValue, invertStickyHeaders: boolean | undefined, scrollViewHeight: number | undefined, StickyHeaderComponent: IStickyHeaderComponentType | undefined, headerLayoutYs: ReadonlyMap<number, number>, onHeaderLayoutY: (index: number, y: number) => void): VNode[];