@symbiote-native/vue 0.3.8 → 0.5.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/README.md +6 -6
- package/build/bootstrap.js +1 -1
- package/build/components/flat-list/index.js +32 -39
- package/build/components/image-background.js +8 -19
- package/build/components/image.js +12 -21
- package/build/components/keyboard-avoiding-view.js +47 -31
- package/build/components/modal/index.d.ts +4 -4
- package/build/components/modal/index.js +5 -2
- package/build/components/pressable.d.ts +1 -1
- package/build/components/pressable.js +25 -32
- package/build/components/refresh-control.js +12 -18
- package/build/components/scroll-view/index.android.d.ts +1 -1
- package/build/components/scroll-view/index.android.js +24 -34
- package/build/components/scroll-view/index.ios.d.ts +1 -1
- package/build/components/scroll-view/index.ios.js +3 -1
- package/build/components/scroll-view/shared.js +93 -123
- package/build/components/scroll-view/sticky-header.js +59 -64
- package/build/components/section-list/index.js +16 -21
- package/build/components/switch/shared.js +29 -32
- package/build/components/text-input/index.js +36 -51
- package/build/components/touchable-native-feedback.js +3 -1
- package/build/components/touchable.d.ts +7 -1
- package/build/components/touchable.js +181 -33
- package/build/components/virtualized-list/index.js +57 -18
- package/build/components/virtualized-section-list/index.d.ts +5 -0
- package/build/components/virtualized-section-list/index.js +50 -29
- package/build/components.js +9 -6
- package/build/create-portal/index.d.ts +28 -0
- package/build/create-portal/index.js +42 -0
- package/build/host-instance/index.js +5 -5
- package/build/index.d.ts +9 -6
- package/build/index.js +18 -46
- package/build/modules/animated/create-animated-component.js +46 -101
- package/build/modules/animated/index.d.ts +2 -0
- package/build/modules/animated/index.js +26 -17
- package/build/modules/app-registry/index.js +11 -13
- package/build/modules/status-bar.js +12 -17
- package/build/render.d.ts +3 -1
- package/build/render.js +62 -1
- package/build/renderer/index.js +11 -6
- package/build/runtime-helpers/index.d.ts +44 -24
- package/build/runtime-helpers/index.js +116 -47
- package/build/utils/normalize-attrs.js +3 -1
- package/metro-css-parser.cjs +9 -14
- package/metro-vue-transformer.cjs +196 -178
- package/package.json +5 -5
|
@@ -12,6 +12,10 @@ export interface ITouchableHighlightProps extends ITouchableBaseProps {
|
|
|
12
12
|
underlayColor?: string;
|
|
13
13
|
}
|
|
14
14
|
export type ITouchableWithoutFeedbackProps = ITouchableBaseProps;
|
|
15
|
+
export type ITouchableHighlightEmits = IPressableEmits & {
|
|
16
|
+
showUnderlay: () => boolean;
|
|
17
|
+
hideUnderlay: () => boolean;
|
|
18
|
+
};
|
|
15
19
|
export declare const TouchableOpacity: import("vue").DefineSetupFnComponent<ITouchableOpacityProps, IPressableEmits, {}, ITouchableOpacityProps & {
|
|
16
20
|
onPress?: ((event: ISymbioteEvent) => any) | undefined;
|
|
17
21
|
onPressIn?: ((event: ISymbioteEvent) => any) | undefined;
|
|
@@ -21,7 +25,7 @@ export declare const TouchableOpacity: import("vue").DefineSetupFnComponent<ITou
|
|
|
21
25
|
onHoverIn?: ((event: ISymbioteEvent) => any) | undefined;
|
|
22
26
|
onHoverOut?: ((event: ISymbioteEvent) => any) | undefined;
|
|
23
27
|
}, import("vue").PublicProps>;
|
|
24
|
-
export declare const TouchableHighlight: import("vue").DefineSetupFnComponent<ITouchableHighlightProps,
|
|
28
|
+
export declare const TouchableHighlight: import("vue").DefineSetupFnComponent<ITouchableHighlightProps, ITouchableHighlightEmits, {}, ITouchableHighlightProps & {
|
|
25
29
|
onPress?: ((event: ISymbioteEvent) => any) | undefined;
|
|
26
30
|
onPressIn?: ((event: ISymbioteEvent) => any) | undefined;
|
|
27
31
|
onPressOut?: ((event: ISymbioteEvent) => any) | undefined;
|
|
@@ -29,6 +33,8 @@ export declare const TouchableHighlight: import("vue").DefineSetupFnComponent<IT
|
|
|
29
33
|
onLongPress?: ((event: ISymbioteEvent) => any) | undefined;
|
|
30
34
|
onHoverIn?: ((event: ISymbioteEvent) => any) | undefined;
|
|
31
35
|
onHoverOut?: ((event: ISymbioteEvent) => any) | undefined;
|
|
36
|
+
onShowUnderlay?: (() => any) | undefined;
|
|
37
|
+
onHideUnderlay?: (() => any) | undefined;
|
|
32
38
|
}, import("vue").PublicProps>;
|
|
33
39
|
export declare const TouchableWithoutFeedback: import("vue").DefineSetupFnComponent<ITouchableBaseProps, IPressableEmits, {}, Omit<IPressableProps, "style"> & IPressTimingProps & {
|
|
34
40
|
style?: IStyleProp<IViewStyle>;
|
|
@@ -1,26 +1,35 @@
|
|
|
1
|
-
// The Touchable* family for Vue, all built on Pressable
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
1
|
+
// The Touchable* family for Vue, all built on Pressable. Ported against RN's own sources
|
|
2
|
+
// (.vendors/react-native/.../Components/Touchable/*.js) after the 2026-08-19 audit found ten
|
|
3
|
+
// divergences shared by the React/Vue/Svelte/Angular ports; Solid migrated first, this is Vue.
|
|
4
|
+
//
|
|
5
|
+
// Shared (framework-agnostic) half: the press-scheduling machine, the underlay machine, the
|
|
6
|
+
// resting-opacity math, the press-handler gate, the extra-style split. Vue owns only the
|
|
7
|
+
// lifecycle — refs, the real timers, the Animated wiring, and the emit bridge:
|
|
8
|
+
// TouchableOpacity: an Animated.Value fading toward activeOpacity on press-in and back to the
|
|
9
|
+
// style's own opacity on press-out.
|
|
10
|
+
// TouchableHighlight: the underlay machine flipping a `shown` ref; underlayColor lands on the
|
|
11
|
+
// container, the lowered opacity on the cloned child.
|
|
12
|
+
// TouchableWithoutFeedback: the same press-scheduling machine with the visual half empty.
|
|
10
13
|
//
|
|
11
14
|
// Inputs arrive as attrs (untyped), narrowed with runtime guards. The handlers read attrs LIVE
|
|
12
15
|
// (they fire on events, not render) so a re-supplied callback / timing is always honored.
|
|
13
|
-
import { defineComponent, h } from '@vue/runtime-core';
|
|
14
|
-
import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime,
|
|
15
|
-
import {} from '@symbiote-native/engine';
|
|
16
|
+
import { cloneVNode, defineComponent, getCurrentInstance, h, onUnmounted, ref, watch, } from '@vue/runtime-core';
|
|
17
|
+
import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, hasTouchablePressHandler, resolveHighlightExtraStyles, restingOpacityFromStyle, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
|
|
18
|
+
import { dlog, } from '@symbiote-native/engine';
|
|
16
19
|
import { Pressable, emitPressableEvents, PRESSABLE_EMITS, } from './pressable.js';
|
|
17
20
|
import { Animated } from '../modules/animated/index.js';
|
|
18
21
|
import { normalizeVueAttrs } from '../utils/normalize-attrs.js';
|
|
22
|
+
const TOUCHABLE_HIGHLIGHT_EMITS = {
|
|
23
|
+
...PRESSABLE_EMITS,
|
|
24
|
+
showUnderlay: () => true,
|
|
25
|
+
hideUnderlay: () => true,
|
|
26
|
+
};
|
|
19
27
|
function numberOr(value, fallback) {
|
|
20
28
|
return typeof value === 'number' ? value : fallback;
|
|
21
29
|
}
|
|
22
|
-
// The real setTimeout the shared
|
|
23
|
-
// (core/components has no timer globals). Returns a canceller so an early release flushes
|
|
30
|
+
// The real setTimeout the shared machines schedule their deferred activation/deactivation/underlay
|
|
31
|
+
// hide on (core/components has no timer globals). Returns a canceller so an early release flushes
|
|
32
|
+
// the timer.
|
|
24
33
|
function scheduleTimeout(callback, ms) {
|
|
25
34
|
const id = setTimeout(callback, ms);
|
|
26
35
|
return () => clearTimeout(id);
|
|
@@ -44,20 +53,41 @@ const TOUCHABLE_OPACITY_HANDLED = [
|
|
|
44
53
|
'minPressDuration',
|
|
45
54
|
];
|
|
46
55
|
export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttrs, emit }) => {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
56
|
+
// RN's _getChildStyleOpacityWithDefault: the fade settles at the opacity the CALLER's style
|
|
57
|
+
// asks for, not at a hard 1 — and the Animated.Value is SEEDED there, or the first paint jumps
|
|
58
|
+
// to fully opaque on a Touchable styled `opacity: 0.6`.
|
|
59
|
+
const restingOpacity = () => restingOpacityFromStyle(normalizeVueAttrs(rawAttrs).style);
|
|
60
|
+
// One Animated.Value per mount. Held by identity in setup scope (an engine object, never a
|
|
61
|
+
// reactive ref — a Proxy would miss the graph's bookkeeping). The Animated.View leaf commits
|
|
62
|
+
// it every frame.
|
|
63
|
+
const opacity = new Animated.Value(restingOpacity());
|
|
50
64
|
// The shared press-scheduling cell (delayPressIn timer + activation clock), persisted across
|
|
51
65
|
// renders in setup scope; the handlers are rebuilt each render over live attrs.
|
|
52
66
|
const runtime = createTouchableFeedbackRuntime();
|
|
53
67
|
function setOpacityTo(toValue, duration) {
|
|
68
|
+
dlog(`TouchableOpacity opacity -> ${toValue} over ${duration}ms`);
|
|
69
|
+
// useNativeDriver: true is RN's own (TouchableOpacity.js:242). opacity is natively drivable,
|
|
70
|
+
// so the fade survives a busy JS thread — the whole point of press feedback.
|
|
54
71
|
Animated.timing(opacity, {
|
|
55
72
|
toValue,
|
|
56
73
|
duration,
|
|
57
74
|
easing: Animated.Easing.inOut(Animated.Easing.quad),
|
|
58
|
-
useNativeDriver:
|
|
75
|
+
useNativeDriver: true,
|
|
59
76
|
}).start();
|
|
60
77
|
}
|
|
78
|
+
// RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
|
|
79
|
+
// so a Touchable disabled mid-press does not stay stuck at its active opacity. NO `immediate` —
|
|
80
|
+
// RN does this on UPDATE only, and firing at mount would animate over the value the
|
|
81
|
+
// Animated.Value was just seeded with. Two getter sources, not one object: Vue compares an
|
|
82
|
+
// array source element-wise, so an unrelated attr change does not re-settle the opacity.
|
|
83
|
+
watch([() => normalizeVueAttrs(rawAttrs).disabled, restingOpacity], ([, resting]) => {
|
|
84
|
+
setOpacityTo(resting, OPACITY_INACTIVE_DURATION_MS);
|
|
85
|
+
});
|
|
86
|
+
// RN's componentWillUnmount: stop the animation and drop the value back, so a teardown
|
|
87
|
+
// mid-fade leaves no driver running against a node that is gone.
|
|
88
|
+
onUnmounted(() => {
|
|
89
|
+
opacity.resetAnimation();
|
|
90
|
+
});
|
|
61
91
|
return () => {
|
|
62
92
|
const attrs = normalizeVueAttrs(rawAttrs);
|
|
63
93
|
const style = attrs.style;
|
|
@@ -66,16 +96,18 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
|
|
|
66
96
|
const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
|
|
67
97
|
delayPressIn: numberOr(attrs.delayPressIn, 0),
|
|
68
98
|
delayPressOut: numberOr(attrs.delayPressOut, 0),
|
|
69
|
-
|
|
99
|
+
// RN's Touchables override Pressability's 130ms floor with 0; what holds the active
|
|
100
|
+
// visual is the fade's own duration, not a press-duration floor.
|
|
101
|
+
minPressDuration: numberOr(attrs.minPressDuration, TOUCHABLE_MIN_PRESS_DURATION_MS),
|
|
70
102
|
schedule: scheduleTimeout,
|
|
71
103
|
now: Date.now,
|
|
72
104
|
}, runtime, {
|
|
73
105
|
activate(event) {
|
|
74
|
-
setOpacityTo(numberOr(attrs.activeOpacity, DEFAULT_ACTIVE_OPACITY),
|
|
106
|
+
setOpacityTo(numberOr(attrs.activeOpacity, DEFAULT_ACTIVE_OPACITY), OPACITY_ACTIVE_GRANT_DURATION_MS);
|
|
75
107
|
emit('pressIn', event);
|
|
76
108
|
},
|
|
77
109
|
deactivate(event) {
|
|
78
|
-
setOpacityTo(
|
|
110
|
+
setOpacityTo(restingOpacity(), OPACITY_INACTIVE_DURATION_MS);
|
|
79
111
|
emit('pressOut', event);
|
|
80
112
|
},
|
|
81
113
|
});
|
|
@@ -94,34 +126,150 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
|
|
|
94
126
|
inheritAttrs: false,
|
|
95
127
|
emits: PRESSABLE_EMITS,
|
|
96
128
|
});
|
|
97
|
-
|
|
129
|
+
// delayPressOut is CONSUMED here (the underlay hold), so it must not also forward to the host.
|
|
130
|
+
// onPress/onPressIn/onPressOut/onShowUnderlay/onHideUnderlay never appear in attrs at all — they
|
|
131
|
+
// are declared emits, which Vue strips from $attrs.
|
|
132
|
+
const TOUCHABLE_HIGHLIGHT_HANDLED = [
|
|
133
|
+
'activeOpacity',
|
|
134
|
+
'underlayColor',
|
|
135
|
+
'style',
|
|
136
|
+
'delayPressOut',
|
|
137
|
+
];
|
|
138
|
+
// A vnode that can carry a style prop: a host tag (string) or a component (object/function).
|
|
139
|
+
// Fragment / Text / Comment / Static are Symbols and fail all three — cloning one with a style
|
|
140
|
+
// would silently drop it.
|
|
141
|
+
function isStylableVNode(vnode) {
|
|
142
|
+
const type = vnode.type;
|
|
143
|
+
return (typeof type === 'string' ||
|
|
144
|
+
typeof type === 'function' ||
|
|
145
|
+
(typeof type === 'object' && type !== null));
|
|
146
|
+
}
|
|
98
147
|
export const TouchableHighlight = defineComponent((_props, { slots, attrs: rawAttrs, emit }) => {
|
|
148
|
+
// `shown` is NOT `pressed`: RN holds the underlay for delayPressOut past the tap so a fast tap
|
|
149
|
+
// still flashes, and the pressed flag is already false by then.
|
|
150
|
+
const shown = ref(false);
|
|
151
|
+
// The mutable underlay cell (the in-flight hide canceller). A plain setup-scope object, never
|
|
152
|
+
// a ref: the machine mutates it on every event and nothing reads it reactively.
|
|
153
|
+
const runtime = createHighlightUnderlayRuntime();
|
|
154
|
+
// The press callbacks are declared emits, so Vue has stripped their onX listeners from $attrs.
|
|
155
|
+
// RN's _hasPressHandler gate therefore reads what the PARENT actually passed, off the
|
|
156
|
+
// instance's own vnode props — the same shape FlatList/VirtualizedList use for this.
|
|
157
|
+
const instance = getCurrentInstance();
|
|
158
|
+
const listener = (onName) => {
|
|
159
|
+
const vnodeProps = instance?.vnode.props;
|
|
160
|
+
return vnodeProps == null ? undefined : vnodeProps[onName];
|
|
161
|
+
};
|
|
99
162
|
return () => {
|
|
100
163
|
const attrs = normalizeVueAttrs(rawAttrs);
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
164
|
+
const hasPressHandler = hasTouchablePressHandler({
|
|
165
|
+
onPress: listener('onPress'),
|
|
166
|
+
onPressIn: listener('onPressIn'),
|
|
167
|
+
onPressOut: listener('onPressOut'),
|
|
168
|
+
onLongPress: listener('onLongPress'),
|
|
169
|
+
});
|
|
170
|
+
// Rebuilt each render over live attrs; the runtime persists, so an in-flight hide timer
|
|
171
|
+
// survives a rebuild.
|
|
172
|
+
const underlay = createHighlightUnderlayHandlers({
|
|
173
|
+
delayPressOut: numberOr(attrs.delayPressOut, 0),
|
|
174
|
+
hasPressHandler,
|
|
175
|
+
schedule: scheduleTimeout,
|
|
176
|
+
}, runtime, {
|
|
177
|
+
setShown: next => {
|
|
178
|
+
shown.value = next;
|
|
179
|
+
},
|
|
180
|
+
onShowUnderlay: () => emit('showUnderlay'),
|
|
181
|
+
onHideUnderlay: () => emit('hideUnderlay'),
|
|
182
|
+
});
|
|
183
|
+
const extra = resolveHighlightExtraStyles({
|
|
184
|
+
shown: shown.value,
|
|
185
|
+
hasPressHandler,
|
|
186
|
+
underlayColor: typeof attrs.underlayColor === 'string'
|
|
187
|
+
? attrs.underlayColor
|
|
188
|
+
: undefined,
|
|
189
|
+
activeOpacity: typeof attrs.activeOpacity === 'number'
|
|
190
|
+
? attrs.activeOpacity
|
|
191
|
+
: undefined,
|
|
192
|
+
});
|
|
193
|
+
const children = slots.default !== undefined ? slots.default() : [];
|
|
194
|
+
// RN's _createExtraStyles split: the backgroundColor on the CONTAINER, the lowered opacity
|
|
195
|
+
// on the CHILD (TouchableHighlight.js clones its single child). Vue can reach the child
|
|
196
|
+
// without a wrapper node — cloneVNode merges the style onto exactly one existing vnode, so
|
|
197
|
+
// nothing is inserted into the flex chain. It needs RN's own single-element-child shape
|
|
198
|
+
// though (React.Children.only): a fragment, a raw text node or several roots have no one
|
|
199
|
+
// child to clone, and there both styles fold onto the container — the pre-audit
|
|
200
|
+
// approximation, where the opacity fades the very underlay it should reveal.
|
|
201
|
+
const onlyChild = children.length === 1 && children[0] !== undefined
|
|
202
|
+
? children[0]
|
|
203
|
+
: undefined;
|
|
204
|
+
const canStyleChild = onlyChild !== undefined && isStylableVNode(onlyChild);
|
|
205
|
+
const styledChildren = extra !== undefined && canStyleChild && onlyChild !== undefined
|
|
206
|
+
? [cloneVNode(onlyChild, { style: extra.child })]
|
|
207
|
+
: children;
|
|
208
|
+
const containerStyle = extra === undefined
|
|
209
|
+
? attrs.style
|
|
210
|
+
: canStyleChild
|
|
211
|
+
? [attrs.style, extra.underlay]
|
|
212
|
+
: [attrs.style, extra.underlay, extra.child];
|
|
107
213
|
const pressableProps = {
|
|
108
214
|
...forwardExcept(attrs, TOUCHABLE_HIGHLIGHT_HANDLED),
|
|
109
215
|
...emitPressableEvents(emit),
|
|
110
|
-
style:
|
|
216
|
+
style: containerStyle,
|
|
217
|
+
// Visual first, then the caller's callback — RN's order in _createPressabilityConfig.
|
|
218
|
+
onPress: (event) => {
|
|
219
|
+
underlay.handlePress(event);
|
|
220
|
+
emit('press', event);
|
|
221
|
+
},
|
|
222
|
+
onPressIn: (event) => {
|
|
223
|
+
underlay.handlePressIn(event);
|
|
224
|
+
emit('pressIn', event);
|
|
225
|
+
},
|
|
226
|
+
onPressOut: (event) => {
|
|
227
|
+
underlay.handlePressOut(event);
|
|
228
|
+
emit('pressOut', event);
|
|
229
|
+
},
|
|
111
230
|
};
|
|
112
|
-
|
|
113
|
-
return h(Pressable, pressableProps, { default: () => children });
|
|
231
|
+
return h(Pressable, pressableProps, { default: () => styledChildren });
|
|
114
232
|
};
|
|
115
233
|
}, {
|
|
116
234
|
name: 'TouchableHighlight',
|
|
117
235
|
inheritAttrs: false,
|
|
118
|
-
emits:
|
|
236
|
+
emits: TOUCHABLE_HIGHLIGHT_EMITS,
|
|
119
237
|
});
|
|
238
|
+
// RN's TouchableWithoutFeedback builds a FULL Pressability config — "without feedback" means no
|
|
239
|
+
// VISUAL, not no timing — so the delay props are consumed by the shared machine here instead of
|
|
240
|
+
// forwarding to the host as unknown Fabric props.
|
|
241
|
+
const TOUCHABLE_WITHOUT_FEEDBACK_HANDLED = [
|
|
242
|
+
'delayPressIn',
|
|
243
|
+
'delayPressOut',
|
|
244
|
+
'minPressDuration',
|
|
245
|
+
];
|
|
120
246
|
export const TouchableWithoutFeedback = defineComponent((_props, { slots, attrs: rawAttrs, emit }) => {
|
|
247
|
+
const runtime = createTouchableFeedbackRuntime();
|
|
121
248
|
return () => {
|
|
122
249
|
const attrs = normalizeVueAttrs(rawAttrs);
|
|
250
|
+
// The same machine TouchableOpacity runs, with the visual half left empty.
|
|
251
|
+
const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
|
|
252
|
+
delayPressIn: numberOr(attrs.delayPressIn, 0),
|
|
253
|
+
delayPressOut: numberOr(attrs.delayPressOut, 0),
|
|
254
|
+
minPressDuration: numberOr(attrs.minPressDuration, TOUCHABLE_MIN_PRESS_DURATION_MS),
|
|
255
|
+
schedule: scheduleTimeout,
|
|
256
|
+
now: Date.now,
|
|
257
|
+
}, runtime, {
|
|
258
|
+
activate(event) {
|
|
259
|
+
emit('pressIn', event);
|
|
260
|
+
},
|
|
261
|
+
deactivate(event) {
|
|
262
|
+
emit('pressOut', event);
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const pressableProps = {
|
|
266
|
+
...forwardExcept(attrs, TOUCHABLE_WITHOUT_FEEDBACK_HANDLED),
|
|
267
|
+
...emitPressableEvents(emit),
|
|
268
|
+
onPressIn: handlePressIn,
|
|
269
|
+
onPressOut: handlePressOut,
|
|
270
|
+
};
|
|
123
271
|
const children = slots.default !== undefined ? slots.default() : [];
|
|
124
|
-
return h(Pressable,
|
|
272
|
+
return h(Pressable, pressableProps, { default: () => children });
|
|
125
273
|
};
|
|
126
274
|
}, {
|
|
127
275
|
name: 'TouchableWithoutFeedback',
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// scroll commands would silently no-op. The folded listState is a PLAIN object (not reactive) —
|
|
28
28
|
// mutating it never triggers Vue; the `version` ref is bumped when a transition changes render state.
|
|
29
29
|
import { computed, defineComponent, getCurrentInstance, h, isVNode, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
|
|
30
|
-
import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
|
|
30
|
+
import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readLayoutOffset, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
|
|
31
31
|
import { dlog, } from '@symbiote-native/engine';
|
|
32
32
|
import { ScrollView } from '../scroll-view';
|
|
33
33
|
import { RefreshControl } from '../refresh-control.js';
|
|
@@ -78,7 +78,7 @@ function isRecord(value) {
|
|
|
78
78
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
79
79
|
}
|
|
80
80
|
function isComponent(value) {
|
|
81
|
-
return typeof value === 'function' || (typeof value === 'object' && value !== null);
|
|
81
|
+
return (typeof value === 'function' || (typeof value === 'object' && value !== null));
|
|
82
82
|
}
|
|
83
83
|
function asNumber(value, fallback) {
|
|
84
84
|
return typeof value === 'number' ? value : fallback;
|
|
@@ -181,7 +181,9 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
181
181
|
? (info) => emit('startReached', info)
|
|
182
182
|
: undefined,
|
|
183
183
|
onStartReachedThreshold: asNumber(props.onStartReachedThreshold, DEFAULT_START_REACHED_THRESHOLD),
|
|
184
|
-
onRefresh: listens('onRefresh')
|
|
184
|
+
onRefresh: listens('onRefresh')
|
|
185
|
+
? () => emit('refresh')
|
|
186
|
+
: undefined,
|
|
185
187
|
refreshing: props.refreshing === true,
|
|
186
188
|
progressViewOffset: props.progressViewOffset,
|
|
187
189
|
onViewableItemsChanged: listens('onViewableItemsChanged')
|
|
@@ -351,13 +353,17 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
351
353
|
const length = readLayoutLength(event, narrowed.value.horizontal);
|
|
352
354
|
if (length === undefined)
|
|
353
355
|
return;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
+
const offset = readLayoutOffset(event, narrowed.value.horizontal);
|
|
357
|
+
dlog(`Vue VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`);
|
|
358
|
+
dispatch({ kind: 'measure', index, length, offset });
|
|
356
359
|
};
|
|
357
360
|
const mergeSeparator = (gapIndex, patch) => {
|
|
358
361
|
if (!isSeparatorGapInRange(gapIndex, listState.metrics.count))
|
|
359
362
|
return;
|
|
360
|
-
separatorOverrides.set(gapIndex, {
|
|
363
|
+
separatorOverrides.set(gapIndex, {
|
|
364
|
+
...separatorOverrides.get(gapIndex),
|
|
365
|
+
...patch,
|
|
366
|
+
});
|
|
361
367
|
separatorVersion.value += 1;
|
|
362
368
|
};
|
|
363
369
|
const makeSeparators = (index) => ({
|
|
@@ -440,7 +446,9 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
440
446
|
dlog(`Vue VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
|
|
441
447
|
`(offset=${listState.scrollOffset}, viewport=${listState.viewportLength}, rendered=${Math.max(0, m.last - m.first + 1)})`);
|
|
442
448
|
const children = [];
|
|
443
|
-
const stickySet = p.stickyHeaderIndices !== undefined
|
|
449
|
+
const stickySet = p.stickyHeaderIndices !== undefined
|
|
450
|
+
? new Set(p.stickyHeaderIndices)
|
|
451
|
+
: undefined;
|
|
444
452
|
const header = resolveElement(p.listHeaderComponent);
|
|
445
453
|
if (header !== undefined) {
|
|
446
454
|
children.push(h('symbiote-view', { key: 'list-header' }, [header]));
|
|
@@ -463,13 +471,14 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
463
471
|
keyFor,
|
|
464
472
|
stickyIndices: stickySet,
|
|
465
473
|
hasHeader: header !== undefined,
|
|
466
|
-
hasSeparators: p.itemSeparatorComponent !== undefined,
|
|
467
474
|
});
|
|
468
475
|
renderedStickyIndices = plan.stickyChildPositions;
|
|
469
476
|
if (plan.leadingExtent > EMPTY_OFFSET) {
|
|
470
477
|
children.push(h('symbiote-view', {
|
|
471
478
|
key: 'spacer-leading',
|
|
472
|
-
style: p.horizontal
|
|
479
|
+
style: p.horizontal
|
|
480
|
+
? { width: plan.leadingExtent }
|
|
481
|
+
: { height: plan.leadingExtent },
|
|
473
482
|
}));
|
|
474
483
|
}
|
|
475
484
|
const cellInverted = p.inverted
|
|
@@ -477,26 +486,55 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
477
486
|
? INVERTED_X_STYLE
|
|
478
487
|
: INVERTED_Y_STYLE
|
|
479
488
|
: undefined;
|
|
480
|
-
|
|
481
|
-
|
|
489
|
+
// Wraps one cell identically whether it's the force-mounted sticky cell ahead of the
|
|
490
|
+
// window or an in-window cell from the loop below — same measuring View, same key shape.
|
|
491
|
+
// RN renders the separator INSIDE the cell's own measuring wrapper
|
|
492
|
+
// (VirtualizedListCellRenderer.js:218-221), and that placement is load-bearing rather than
|
|
493
|
+
// cosmetic. As a SIBLING it is an extra flex child, so the chrome between two cells becomes
|
|
494
|
+
// gap + separator + gap while a spacer collapsing that region replaces it with one gap —
|
|
495
|
+
// the leading spacer then lands every cell below it short by (separator + gap), and the
|
|
496
|
+
// content visibly jumps by that amount each time the window's first index moves. Measured
|
|
497
|
+
// at exactly 17px on device 2026-08-19 (a 1px divider under a 16px container gap); see
|
|
498
|
+
// .claude/rules/list-geometry-feedback-loop.md.
|
|
499
|
+
const pushCell = (cell) => {
|
|
482
500
|
const item = p.getItem(p.data, cell.index);
|
|
483
501
|
const content = p.renderItem?.({
|
|
484
502
|
item,
|
|
485
503
|
index: cell.index,
|
|
486
504
|
separators: makeSeparators(cell.index),
|
|
487
505
|
});
|
|
506
|
+
const separator =
|
|
507
|
+
// RN gates the separator on the last index of the DATA, not of the WINDOW
|
|
508
|
+
// (VirtualizedList.js:793 `const end = getItemCount(data) - 1`), and now that the separator
|
|
509
|
+
// lives INSIDE the measuring wrapper that distinction is load-bearing: gating on the window
|
|
510
|
+
// would make a cell's own measured height change as the window slides past it. Device-measured
|
|
511
|
+
// 2026-08-19 as a run of cells all shifting by exactly the divider's 1px.
|
|
512
|
+
cell.index < m.count - 1
|
|
513
|
+
? renderSeparatorElement(p.itemSeparatorComponent, item, p.getItem(p.data, cell.index + 1), separatorOverrides.get(cell.index))
|
|
514
|
+
: undefined;
|
|
488
515
|
children.push(h('symbiote-view', {
|
|
489
516
|
key: `cell-${cell.key}`,
|
|
490
517
|
onLayout: makeCellMeasure(cell.index),
|
|
491
518
|
style: cellInverted,
|
|
492
|
-
}, [content]));
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
519
|
+
}, separator === undefined ? [content] : [content, separator]));
|
|
520
|
+
};
|
|
521
|
+
// The nearest sticky section header below the window (RN _ensureClosestStickyHeader):
|
|
522
|
+
// force-mounted so it never gets destroyed/recreated while pinned off-window. Rendered
|
|
523
|
+
// ahead of the main window loop, with its own gap spacer to the window's first cell.
|
|
524
|
+
if (plan.forcedStickyCell !== undefined) {
|
|
525
|
+
pushCell(plan.forcedStickyCell);
|
|
526
|
+
if (plan.gapExtent > EMPTY_OFFSET) {
|
|
527
|
+
children.push(h('symbiote-view', {
|
|
528
|
+
key: 'spacer-gap',
|
|
529
|
+
style: p.horizontal
|
|
530
|
+
? { width: plan.gapExtent }
|
|
531
|
+
: { height: plan.gapExtent },
|
|
532
|
+
}));
|
|
498
533
|
}
|
|
499
534
|
}
|
|
535
|
+
for (let cellPos = 0; cellPos < plan.cells.length; cellPos += 1) {
|
|
536
|
+
pushCell(plan.cells[cellPos]);
|
|
537
|
+
}
|
|
500
538
|
if (plan.trailingExtent > EMPTY_OFFSET) {
|
|
501
539
|
children.push(h('symbiote-view', {
|
|
502
540
|
key: 'spacer-trailing',
|
|
@@ -546,7 +584,8 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
546
584
|
if (p.maintainVisibleContentPosition !== undefined) {
|
|
547
585
|
scrollProps.maintainVisibleContentPosition = {
|
|
548
586
|
...p.maintainVisibleContentPosition,
|
|
549
|
-
minIndexForVisible: p.maintainVisibleContentPosition.minIndexForVisible +
|
|
587
|
+
minIndexForVisible: p.maintainVisibleContentPosition.minIndexForVisible +
|
|
588
|
+
(header !== undefined ? 1 : 0),
|
|
550
589
|
};
|
|
551
590
|
}
|
|
552
591
|
// Pull-to-refresh: when a @refresh listener is present, build a RefreshControl for the
|
|
@@ -6,6 +6,11 @@ export type { IVirtualizedSectionListHandle };
|
|
|
6
6
|
export interface IVirtualizedSectionListProps<ItemT> {
|
|
7
7
|
sections: ReadonlyArray<ISection<ItemT>>;
|
|
8
8
|
keyExtractor?: (item: ItemT, index: number) => string;
|
|
9
|
+
getItemLayout?: (data: ReadonlyArray<ISection<ItemT>> | null, index: number) => {
|
|
10
|
+
length: number;
|
|
11
|
+
offset: number;
|
|
12
|
+
index: number;
|
|
13
|
+
};
|
|
9
14
|
stickySectionHeadersEnabled?: boolean;
|
|
10
15
|
extraData?: unknown;
|
|
11
16
|
onEndReachedThreshold?: number;
|
|
@@ -1,33 +1,34 @@
|
|
|
1
1
|
// VirtualizedSectionList, the Vue wrapper that flattens sections into one virtualized stream
|
|
2
|
-
// over VirtualizedList
|
|
3
|
-
// (RN counts 2 per section)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// The Vue twin of the React adapter's VirtualizedSectionList.
|
|
2
|
+
// over VirtualizedList: each section contributes a header row, its item rows, then a footer row
|
|
3
|
+
// (RN counts 2 per section), and the flattened tagged sequence is windowed by the same
|
|
4
|
+
// machinery as one list. Flattening, entry keying, separator-item unwrap, and scrollToLocation
|
|
5
|
+
// mapping are shared from @symbiote-native/components; this file wires Vue lifecycle
|
|
6
|
+
// (typed-prop inputs + handle re-expose + per-entry render dispatch).
|
|
8
7
|
//
|
|
9
|
-
// Typed-emits generic component (mirrors FlatList
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// React's section list exposes) stay GATED on listener presence: each emit bridge is wired ONLY
|
|
16
|
-
// when the consumer listens (read off the instance vnode props), so the inner VirtualizedList keeps
|
|
17
|
-
// building RefreshControl / computing edge-reached strictly on demand.
|
|
8
|
+
// Typed-emits generic component (mirrors FlatList/VirtualizedList): a GENERIC setup function so
|
|
9
|
+
// section inputs infer ItemT at the call site. Those inputs are read from typed `props`
|
|
10
|
+
// (declared in the runtime `props` array); the VirtualizedList passthrough tail rides through
|
|
11
|
+
// $attrs onto the inner list. The three adapter-synthesized events (endReached/startReached/
|
|
12
|
+
// refresh) stay GATED on listener presence so the inner VirtualizedList keeps building
|
|
13
|
+
// RefreshControl / computing edge-reached strictly on demand.
|
|
18
14
|
import { defineComponent, getCurrentInstance, h, shallowRef, } from '@vue/runtime-core';
|
|
19
15
|
import { flattenSections, resolveStickySectionHeaders, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
|
|
20
16
|
import { Platform, dlog, } from '@symbiote-native/engine';
|
|
21
17
|
import { VirtualizedList } from '../virtualized-list/index.js';
|
|
22
18
|
import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
|
|
23
|
-
// VirtualizedList
|
|
24
|
-
//
|
|
19
|
+
// VirtualizedList's generic construct signature can't be resolved by h()'s overloads, so drive
|
|
20
|
+
// it through a loose functional-component handle instead.
|
|
25
21
|
const VirtualizedListHost = VirtualizedList;
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
const PROP_KEYS = [
|
|
22
|
+
// Listed for the runtime `props` declaration (keyof can't derive it: the index signature widens
|
|
23
|
+
// keyof to `string`). The three emit events are deliberately absent (declared as emits below).
|
|
24
|
+
// getItemLayout is declared here, not left to $attrs: falling through would hand the user's
|
|
25
|
+
// callback the flattened ENTRIES as its `data` argument instead of the sections (see below).
|
|
26
|
+
const PROP_KEYS = [
|
|
27
|
+
'sections',
|
|
28
|
+
'keyExtractor',
|
|
29
|
+
'getItemLayout',
|
|
30
|
+
'stickySectionHeadersEnabled',
|
|
31
|
+
];
|
|
31
32
|
const EMIT_KEYS = ['endReached', 'startReached', 'refresh'];
|
|
32
33
|
function isRecord(value) {
|
|
33
34
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -68,10 +69,8 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
|
|
|
68
69
|
// headerIndices change each render (sections may change); the delegate reads them lazily.
|
|
69
70
|
let headerIndices = [];
|
|
70
71
|
expose(buildSectionDelegate(() => inner.value, () => headerIndices));
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// FlatList/VirtualizedList, so an emit bridge is wired ONLY when the consumer actually listens
|
|
74
|
-
// (preserving the inner list's RefreshControl / edge-reached gating).
|
|
72
|
+
// Emits are stripped from $attrs by Vue; detect listener presence off the instance's own
|
|
73
|
+
// vnode props instead, so an emit bridge is wired ONLY when the consumer actually listens.
|
|
75
74
|
const instance = getCurrentInstance();
|
|
76
75
|
const listens = (onName) => {
|
|
77
76
|
const vnodeProps = instance?.vnode.props;
|
|
@@ -95,10 +94,14 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
|
|
|
95
94
|
const renderEntry = (info) => {
|
|
96
95
|
const entry = info.item;
|
|
97
96
|
if (entry.kind === 'header') {
|
|
98
|
-
return slots.sectionHeader
|
|
97
|
+
return slots.sectionHeader
|
|
98
|
+
? slots.sectionHeader({ section: entry.section })
|
|
99
|
+
: [];
|
|
99
100
|
}
|
|
100
101
|
if (entry.kind === 'footer') {
|
|
101
|
-
return slots.sectionFooter
|
|
102
|
+
return slots.sectionFooter
|
|
103
|
+
? slots.sectionFooter({ section: entry.section })
|
|
104
|
+
: [];
|
|
102
105
|
}
|
|
103
106
|
if (entry.kind === 'section-separator') {
|
|
104
107
|
return slots.sectionSeparator ? slots.sectionSeparator() : [];
|
|
@@ -122,6 +125,21 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
|
|
|
122
125
|
trailingItem: unwrapEntryItem(entryProps.trailingItem),
|
|
123
126
|
});
|
|
124
127
|
const entryKeyExtractor = (entry, index) => sectionEntryKey(entry, index, keyExtractor);
|
|
128
|
+
// Hand the callback `sections`, not the entries: RN's inner VirtualizedList gets
|
|
129
|
+
// `data={this.props.sections}` (VirtualizedSectionList.js:216) while ours streams the
|
|
130
|
+
// FLATTENED entries, so the same user code would otherwise see a different argument here
|
|
131
|
+
// than on RN.
|
|
132
|
+
//
|
|
133
|
+
// UPSTREAM-DIVERGENCE(react-native): the flat INDEX matches RN's (two rows per section,
|
|
134
|
+
// header and footer) only while the #sectionSeparator slot is unset. With it,
|
|
135
|
+
// flattenSections emits an extra 'section-separator' row per boundary that RN renders inside
|
|
136
|
+
// the neighbouring cell, so indices shift by one per boundary from the second section on.
|
|
137
|
+
// Deliberate - that row is how this adapter paints the separator; a caller combining the two
|
|
138
|
+
// must account for it.
|
|
139
|
+
const getItemLayout = props.getItemLayout;
|
|
140
|
+
const entryItemLayout = getItemLayout === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: (_entries, index) => getItemLayout(sections, index);
|
|
125
143
|
// The three synthesized events become inner-VL handlers ONLY when listened, so the inner list
|
|
126
144
|
// keeps gating (no parasitic RefreshControl / edge-reached work for an unlistened event).
|
|
127
145
|
const endReached = listens('onEndReached')
|
|
@@ -130,7 +148,9 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
|
|
|
130
148
|
const startReached = listens('onStartReached')
|
|
131
149
|
? (eventInfo) => emit('startReached', eventInfo)
|
|
132
150
|
: undefined;
|
|
133
|
-
const refresh = listens('onRefresh')
|
|
151
|
+
const refresh = listens('onRefresh')
|
|
152
|
+
? () => emit('refresh')
|
|
153
|
+
: undefined;
|
|
134
154
|
return h(VirtualizedListHost, {
|
|
135
155
|
...forwarded,
|
|
136
156
|
ref: setInner,
|
|
@@ -138,6 +158,7 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
|
|
|
138
158
|
getItem: (_source, index) => entries[index],
|
|
139
159
|
getItemCount: () => entries.length,
|
|
140
160
|
keyExtractor: entryKeyExtractor,
|
|
161
|
+
getItemLayout: entryItemLayout,
|
|
141
162
|
stickyHeaderIndices,
|
|
142
163
|
onEndReached: endReached,
|
|
143
164
|
onStartReached: startReached,
|