@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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/build/components/activity-indicator/index.android.d.ts +4 -0
- package/build/components/activity-indicator/index.android.js +10 -0
- package/build/components/activity-indicator/index.d.ts +1 -0
- package/build/components/activity-indicator/index.ios.d.ts +4 -0
- package/build/components/activity-indicator/index.ios.js +10 -0
- package/build/components/activity-indicator/index.js +4 -0
- package/build/components/activity-indicator/shared.d.ts +8 -0
- package/build/components/activity-indicator/shared.js +48 -0
- package/build/components/button.d.ts +14 -0
- package/build/components/button.js +59 -0
- package/build/components/drawer-layout-android/index.android.d.ts +8 -0
- package/build/components/drawer-layout-android/index.android.js +144 -0
- package/build/components/drawer-layout-android/index.d.ts +8 -0
- package/build/components/drawer-layout-android/index.js +36 -0
- package/build/components/drawer-layout-android/shared.d.ts +18 -0
- package/build/components/drawer-layout-android/shared.js +6 -0
- package/build/components/flat-list/index.d.ts +88 -0
- package/build/components/flat-list/index.js +189 -0
- package/build/components/image-background.d.ts +9 -0
- package/build/components/image-background.js +114 -0
- package/build/components/image.d.ts +9 -0
- package/build/components/image.js +99 -0
- package/build/components/input-accessory-view.d.ts +10 -0
- package/build/components/input-accessory-view.js +41 -0
- package/build/components/keyboard-avoiding-view.d.ts +18 -0
- package/build/components/keyboard-avoiding-view.js +121 -0
- package/build/components/modal.d.ts +29 -0
- package/build/components/modal.js +134 -0
- package/build/components/pressable.d.ts +58 -0
- package/build/components/pressable.js +221 -0
- package/build/components/refresh-control.d.ts +20 -0
- package/build/components/refresh-control.js +53 -0
- package/build/components/safe-area-view.d.ts +8 -0
- package/build/components/safe-area-view.js +33 -0
- package/build/components/scroll-view/index.android.d.ts +4 -0
- package/build/components/scroll-view/index.android.js +74 -0
- package/build/components/scroll-view/index.d.ts +1 -0
- package/build/components/scroll-view/index.ios.d.ts +4 -0
- package/build/components/scroll-view/index.ios.js +15 -0
- package/build/components/scroll-view/index.js +4 -0
- package/build/components/scroll-view/shared.d.ts +96 -0
- package/build/components/scroll-view/shared.js +297 -0
- package/build/components/scroll-view/sticky-header.d.ts +7 -0
- package/build/components/scroll-view/sticky-header.js +195 -0
- package/build/components/section-list/index.d.ts +39 -0
- package/build/components/section-list/index.js +77 -0
- package/build/components/switch/index.android.d.ts +12 -0
- package/build/components/switch/index.android.js +13 -0
- package/build/components/switch/index.d.ts +2 -0
- package/build/components/switch/index.ios.d.ts +12 -0
- package/build/components/switch/index.ios.js +11 -0
- package/build/components/switch/index.js +5 -0
- package/build/components/switch/shared.d.ts +23 -0
- package/build/components/switch/shared.js +129 -0
- package/build/components/text-input/index.d.ts +24 -0
- package/build/components/text-input/index.js +257 -0
- package/build/components/touchable-native-feedback.d.ts +24 -0
- package/build/components/touchable-native-feedback.js +75 -0
- package/build/components/touchable.d.ts +44 -0
- package/build/components/touchable.js +150 -0
- package/build/components/virtualized-list/index.d.ts +121 -0
- package/build/components/virtualized-list/index.js +687 -0
- package/build/components/virtualized-section-list/index.d.ts +96 -0
- package/build/components/virtualized-section-list/index.js +160 -0
- package/build/components.d.ts +51 -0
- package/build/components.js +26 -0
- package/build/composables/use-color-scheme.d.ts +3 -0
- package/build/composables/use-color-scheme.js +24 -0
- package/build/composables/use-window-dimensions.d.ts +3 -0
- package/build/composables/use-window-dimensions.js +34 -0
- package/build/create-tunnel.d.ts +9 -0
- package/build/create-tunnel.js +37 -0
- package/build/descriptor-to-vue.d.ts +3 -0
- package/build/descriptor-to-vue.js +14 -0
- package/build/host-instance/index.d.ts +2 -0
- package/build/host-instance/index.js +34 -0
- package/build/index.d.ts +67 -0
- package/build/index.js +90 -0
- package/build/modules/animated/create-animated-component.d.ts +4 -0
- package/build/modules/animated/create-animated-component.js +169 -0
- package/build/modules/animated/index.d.ts +55 -0
- package/build/modules/animated/index.js +66 -0
- package/build/modules/app-registry/index.d.ts +7 -0
- package/build/modules/app-registry/index.js +32 -0
- package/build/modules/status-bar.d.ts +17 -0
- package/build/modules/status-bar.js +65 -0
- package/build/render.d.ts +7 -0
- package/build/render.js +43 -0
- package/build/renderer.d.ts +4 -0
- package/build/renderer.js +129 -0
- package/build/runtime-helpers.d.ts +28 -0
- package/build/runtime-helpers.js +68 -0
- package/build/utils/component-helpers.d.ts +2 -0
- package/build/utils/component-helpers.js +13 -0
- package/build/utils/model-binding.d.ts +4 -0
- package/build/utils/model-binding.js +17 -0
- package/build/utils/normalize-attrs.d.ts +1 -0
- package/build/utils/normalize-attrs.js +30 -0
- package/build/utils/slots-to-render-props.d.ts +4 -0
- package/build/utils/slots-to-render-props.js +18 -0
- package/metro-css-parser.cjs +12 -0
- 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[];
|