@symbiote-native/svelte 0.2.0 → 0.3.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/build/bootstrap.js +1 -1
- package/build/class-value.js +1 -1
- package/build/components/RefreshControl.svelte +4 -2
- package/build/components/Text.svelte +11 -2
- package/build/components/View.svelte +3 -1
- package/build/components/activity-indicator/index.svelte +8 -2
- package/build/components/button.svelte +8 -3
- package/build/components/flat-list/index.svelte +46 -11
- package/build/components/image/image-logic.js +13 -3
- package/build/components/image-background/index.svelte +8 -2
- package/build/components/index.d.ts +2 -2
- package/build/components/input-accessory-view/index.svelte +4 -1
- package/build/components/keyboard-avoiding-view/index.svelte +34 -11
- package/build/components/modal/index.svelte +4 -1
- package/build/components/pressable/index.svelte +10 -3
- package/build/components/pressable/pressable-props.d.ts +1 -1
- package/build/components/scroll-view/index.svelte +54 -21
- package/build/components/scroll-view/sticky-header.svelte +49 -16
- package/build/components/section-list/index.svelte +6 -1
- package/build/components/switch/index.svelte +14 -3
- package/build/components/text-input/index.svelte +22 -6
- package/build/components/touchable-highlight/index.svelte +114 -15
- package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
- package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +7 -2
- package/build/components/touchable-opacity/index.svelte +112 -56
- package/build/components/touchable-without-feedback/index.svelte +59 -4
- package/build/components/virtualized-list/index.svelte +233 -70
- package/build/components/virtualized-list/virtualized-list-props.js +8 -4
- package/build/components/virtualized-section-list/index.svelte +56 -9
- package/build/components/virtualized-section-list/index.svelte.d.ts +1 -1
- package/build/components/virtualized-section-list/virtualized-section-list-props.d.ts +5 -0
- package/build/create-portal/index.d.ts +22 -0
- package/build/create-portal/index.js +160 -0
- package/build/descriptor-to-svelte.js +15 -21
- package/build/dom-shim/index.d.ts +1 -0
- package/build/dom-shim/index.js +3 -0
- package/build/dom-shim/text.js +38 -1
- package/build/host-instance.js +7 -2
- package/build/index.d.ts +5 -3
- package/build/index.js +16 -11
- package/build/modules/animated/create-animated-component.d.ts +5 -0
- package/build/modules/animated/create-animated-component.js +131 -0
- package/build/modules/animated/index.d.ts +24 -14
- package/build/modules/animated/index.js +30 -30
- package/build/modules/status-bar/index.js +1 -1
- package/build/modules/status-bar/index.svelte +4 -1
- package/build/native-view-bridge.d.ts +1 -1
- package/build/native-view-bridge.js +1 -1
- package/build/preprocessor/collapse-text-whitespace.d.ts +10 -0
- package/build/preprocessor/collapse-text-whitespace.js +138 -0
- package/build/preprocessor/forbid-web-only-constructs.js +8 -2
- package/build/preprocessor/scoped-styles.js +141 -104
- package/build/render.js +48 -3
- package/build/scope-token.d.ts +3 -2
- package/build/scope-token.js +6 -17
- package/build/style-scope.d.ts +2 -1
- package/build/style-scope.js +10 -8
- package/metro-svelte-transformer.cjs +17 -2
- package/package.json +11 -7
- package/build/modules/animated/AnimatedImage.svelte +0 -115
- package/build/modules/animated/AnimatedImage.svelte.d.ts +0 -5
- package/build/modules/animated/AnimatedScrollView.svelte +0 -92
- package/build/modules/animated/AnimatedScrollView.svelte.d.ts +0 -17
- package/build/modules/animated/AnimatedText.svelte +0 -58
- package/build/modules/animated/AnimatedText.svelte.d.ts +0 -5
- package/build/modules/animated/AnimatedView.svelte +0 -95
- package/build/modules/animated/AnimatedView.svelte.d.ts +0 -5
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
//
|
|
30
30
|
// Native-Animated-driven, like React's ScrollViewStickyHeader.js: the pinned translateY rides
|
|
31
31
|
// as the raw AnimatedInterpolation node in style.transform on a real Animated.View
|
|
32
|
-
// (
|
|
32
|
+
// (createAnimatedComponent(View)), whose `__makeNative()` connects it straight to the native
|
|
33
|
+
// shadow tree,
|
|
33
34
|
// so once native the pin tracks scroll on the UI thread with zero JS per frame - the
|
|
34
35
|
// interpolation's own .addListener() below only drives the separate debounced
|
|
35
36
|
// `passthroughAnimatedPropExplicitValues` (RN's own hit-testing sync).
|
|
@@ -51,9 +52,16 @@
|
|
|
51
52
|
// why ScrollView/VirtualizedList force `nativeStickyAvailable = false`: the correct bootstrap
|
|
52
53
|
// path, not a workaround.
|
|
53
54
|
import type { IStickyHeaderComponentProps } from './sticky-header-props';
|
|
55
|
+
import View from '../View.svelte';
|
|
56
|
+
import { createAnimatedComponent } from '../../modules/animated/create-animated-component';
|
|
54
57
|
|
|
55
58
|
export type { IStickyHeaderComponentProps };
|
|
56
59
|
|
|
60
|
+
// Wrapped here, not imported from modules/animated: that barrel imports ScrollView, which
|
|
61
|
+
// imports this file, so naming it would close a module cycle. Module scope, so every header
|
|
62
|
+
// instance shares one component identity.
|
|
63
|
+
const AnimatedView = createAnimatedComponent(View);
|
|
64
|
+
|
|
57
65
|
// Diagnostic-only: a module-scope counter so every instance across every ScrollViewStickyHeader
|
|
58
66
|
// in a device log is individually identifiable (see the mount/unmount dlog calls below).
|
|
59
67
|
let stickyHeaderInstanceSeq = 0;
|
|
@@ -70,8 +78,13 @@
|
|
|
70
78
|
type IStickyEffect,
|
|
71
79
|
type IStickyReducerInputs,
|
|
72
80
|
} from '@symbiote-native/components';
|
|
73
|
-
import {
|
|
74
|
-
|
|
81
|
+
import {
|
|
82
|
+
AnimatedValue,
|
|
83
|
+
Platform,
|
|
84
|
+
dlog,
|
|
85
|
+
type AnimatedInterpolation,
|
|
86
|
+
type ISymbioteEvent,
|
|
87
|
+
} from '@symbiote-native/engine';
|
|
75
88
|
import {
|
|
76
89
|
SCROLL_VIEW_STICKY_CONTEXT_KEY,
|
|
77
90
|
type IScrollViewStickyContext,
|
|
@@ -91,18 +104,26 @@
|
|
|
91
104
|
// Resolve the scroll wiring from the parent ScrollView's context when not given explicitly.
|
|
92
105
|
// A fresh standalone AnimatedValue is the fallback for a header used outside a ScrollView
|
|
93
106
|
// (never sticks, but never crashes either).
|
|
94
|
-
const stickyContext = getContext<IScrollViewStickyContext | undefined>(
|
|
107
|
+
const stickyContext = getContext<IScrollViewStickyContext | undefined>(
|
|
108
|
+
SCROLL_VIEW_STICKY_CONTEXT_KEY,
|
|
109
|
+
);
|
|
95
110
|
$effect(() => {
|
|
96
111
|
if (stickyContext === undefined && scrollAnimatedValueProp === undefined) {
|
|
97
|
-
dlog(
|
|
112
|
+
dlog(
|
|
113
|
+
'ScrollViewStickyHeader used outside a ScrollView — sticky positioning is a no-op',
|
|
114
|
+
);
|
|
98
115
|
}
|
|
99
116
|
});
|
|
100
117
|
// svelte-ignore state_referenced_locally -- captured ONCE by identity; it does not change
|
|
101
118
|
// after mount, so re-deriving it would be wrong.
|
|
102
119
|
const scrollAnimatedValue =
|
|
103
|
-
scrollAnimatedValueProp ??
|
|
120
|
+
scrollAnimatedValueProp ??
|
|
121
|
+
stickyContext?.scrollAnimatedValue ??
|
|
122
|
+
new AnimatedValue(0);
|
|
104
123
|
const inverted = $derived(invertedProp ?? stickyContext?.getInverted());
|
|
105
|
-
const scrollViewHeight = $derived(
|
|
124
|
+
const scrollViewHeight = $derived(
|
|
125
|
+
scrollViewHeightProp ?? stickyContext?.getViewportHeight(),
|
|
126
|
+
);
|
|
106
127
|
|
|
107
128
|
// The one folded state cell (RN's scattered useState/useRef collapsed into IStickyHeaderState),
|
|
108
129
|
// mutated in place by reduceSticky. Named `stickyState`, not `state`: a local binding literally
|
|
@@ -115,7 +136,10 @@
|
|
|
115
136
|
// The interpolation node currently bound into style.transform — held by IDENTITY ($state.raw),
|
|
116
137
|
// rebuilt on every 'rebuild-interpolation' effect.
|
|
117
138
|
let animatedTranslateY = $state.raw<AnimatedInterpolation>(
|
|
118
|
-
scrollAnimatedValue.interpolate({
|
|
139
|
+
scrollAnimatedValue.interpolate({
|
|
140
|
+
inputRange: [-1, 0],
|
|
141
|
+
outputRange: [0, 0],
|
|
142
|
+
}),
|
|
119
143
|
);
|
|
120
144
|
let listenerId: string | undefined;
|
|
121
145
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -139,14 +163,18 @@
|
|
|
139
163
|
// Detach the old listener, build a fresh interpolation onto the shared scroll value, and
|
|
140
164
|
// wire the settled-value listener (drives ONLY the debounced hit-testing passthrough
|
|
141
165
|
// below — the visible pin rides the native AnimatedProps connection in the markup).
|
|
142
|
-
if (listenerId !== undefined)
|
|
166
|
+
if (listenerId !== undefined)
|
|
167
|
+
animatedTranslateY.removeListener(listenerId);
|
|
143
168
|
const next = scrollAnimatedValue.interpolate({
|
|
144
169
|
inputRange: effect.inputRange,
|
|
145
170
|
outputRange: effect.outputRange,
|
|
146
171
|
});
|
|
147
|
-
listenerId = next.addListener(
|
|
148
|
-
|
|
149
|
-
|
|
172
|
+
listenerId = next.addListener(
|
|
173
|
+
({ value }: { value: number | string }): void => {
|
|
174
|
+
if (typeof value === 'number')
|
|
175
|
+
dispatch({ kind: 'animated-tick', value });
|
|
176
|
+
},
|
|
177
|
+
);
|
|
150
178
|
animatedTranslateY = next;
|
|
151
179
|
break;
|
|
152
180
|
}
|
|
@@ -160,7 +188,9 @@
|
|
|
160
188
|
case 'apply-passthrough':
|
|
161
189
|
// The actual committed translateY — a freeze at a huge/wrong number here is the
|
|
162
190
|
// visible-disappearance symptom to watch for.
|
|
163
|
-
dlog(
|
|
191
|
+
dlog(
|
|
192
|
+
`ScrollViewStickyHeader[y=${stickyState.layoutY}] apply-passthrough translateY=${effect.translateY}`,
|
|
193
|
+
);
|
|
164
194
|
version += 1;
|
|
165
195
|
break;
|
|
166
196
|
case 'record-header-y':
|
|
@@ -192,8 +222,11 @@
|
|
|
192
222
|
// Detach the listener + clear the debounce on unmount.
|
|
193
223
|
$effect(() => {
|
|
194
224
|
return (): void => {
|
|
195
|
-
dlog(
|
|
196
|
-
|
|
225
|
+
dlog(
|
|
226
|
+
`ScrollViewStickyHeader#${instanceId}[y=${stickyState.layoutY}] unmount`,
|
|
227
|
+
);
|
|
228
|
+
if (listenerId !== undefined)
|
|
229
|
+
animatedTranslateY.removeListener(listenerId);
|
|
197
230
|
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
|
|
198
231
|
};
|
|
199
232
|
});
|
|
@@ -213,7 +246,7 @@
|
|
|
213
246
|
|
|
214
247
|
// The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
|
|
215
248
|
// `animatedTranslateY` (in the markup below) does the smooth native-driven pin — per RN
|
|
216
|
-
// ScrollViewStickyHeader.js, and
|
|
249
|
+
// ScrollViewStickyHeader.js, and createAnimatedComponent's own passthroughAnimatedPropExplicitValues
|
|
217
250
|
// contract (readPassthroughStyle).
|
|
218
251
|
const passthroughAnimatedPropExplicitValues = $derived.by(() => {
|
|
219
252
|
void version;
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
// (a Svelte `bind:this` on THIS component resolves to whatever THIS component exports, so the
|
|
7
7
|
// wrapper must re-declare + delegate rather than re-exporting the child's exports directly) —
|
|
8
8
|
// the Svelte twin of the React/Vue SectionList.
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ISectionListProps,
|
|
11
|
+
ISectionListHandle,
|
|
12
|
+
ISection,
|
|
13
|
+
} from './section-list-props';
|
|
10
14
|
|
|
11
15
|
export type { ISectionListProps, ISectionListHandle, ISection };
|
|
12
16
|
</script>
|
|
@@ -77,6 +81,7 @@
|
|
|
77
81
|
footer={props.footer}
|
|
78
82
|
empty={props.empty}
|
|
79
83
|
keyExtractor={props.keyExtractor}
|
|
84
|
+
getItemLayout={props.getItemLayout}
|
|
80
85
|
stickySectionHeadersEnabled={props.stickySectionHeadersEnabled}
|
|
81
86
|
inverted={props.inverted}
|
|
82
87
|
extraData={props.extraData}
|
|
@@ -27,7 +27,11 @@
|
|
|
27
27
|
valueFromChange,
|
|
28
28
|
renderSwitch,
|
|
29
29
|
} from '@symbiote-native/components';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
dispatchViewCommand,
|
|
32
|
+
dlog,
|
|
33
|
+
type ISymbioteEvent,
|
|
34
|
+
} from '@symbiote-native/engine';
|
|
31
35
|
import { PLATFORM } from './switch-platform';
|
|
32
36
|
import { createDescriptorChildrenSync } from '../../descriptor-to-svelte';
|
|
33
37
|
import { createAttachmentsSync } from '../../runes/attachments';
|
|
@@ -67,7 +71,10 @@
|
|
|
67
71
|
// accept/reject control via the snap-back effect below — see TextInput's identical
|
|
68
72
|
// mechanism (text-input/index.svelte) for why echoing unconditionally would break that.
|
|
69
73
|
if (onValueChange === undefined) value = next;
|
|
70
|
-
switchState = switchReducer(switchState, {
|
|
74
|
+
switchState = switchReducer(switchState, {
|
|
75
|
+
type: 'native-reported',
|
|
76
|
+
value: next,
|
|
77
|
+
});
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
// Snap-back: when native reported a value the parent rejected (the value prop did not
|
|
@@ -102,7 +109,11 @@
|
|
|
102
109
|
thumbColor,
|
|
103
110
|
ios_backgroundColor,
|
|
104
111
|
style,
|
|
105
|
-
passthrough: {
|
|
112
|
+
passthrough: {
|
|
113
|
+
...passthrough,
|
|
114
|
+
class: className,
|
|
115
|
+
onChange: handleChange,
|
|
116
|
+
},
|
|
106
117
|
},
|
|
107
118
|
PLATFORM,
|
|
108
119
|
),
|
|
@@ -89,7 +89,9 @@
|
|
|
89
89
|
// plain, direct property read on the raw `$props()` proxy. `source` is a closed interface (no
|
|
90
90
|
// index signature), so `Object.keys` + bracket-indexing would need an `as` cast; `Object.entries`
|
|
91
91
|
// falls onto TS's `entries(o: {}): [string, any][]` overload instead, which needs no cast.
|
|
92
|
-
function forwardProps(
|
|
92
|
+
function forwardProps(
|
|
93
|
+
source: Omit<ITextInputProps, 'value'>,
|
|
94
|
+
): Record<string, unknown> {
|
|
93
95
|
const resolved = resolveAccessibilityProps(source);
|
|
94
96
|
const result: Record<string, unknown> = {};
|
|
95
97
|
for (const [key, propValue] of Object.entries(resolved)) {
|
|
@@ -205,9 +207,17 @@
|
|
|
205
207
|
if (engineNode === undefined) return;
|
|
206
208
|
if (!shouldCommandText(lastNativeText, currentValue)) return;
|
|
207
209
|
const selStart = rest.selection?.start ?? SELECTION_NONE;
|
|
208
|
-
const selEnd =
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
const selEnd =
|
|
211
|
+
rest.selection?.end ?? rest.selection?.start ?? SELECTION_NONE;
|
|
212
|
+
dlog(
|
|
213
|
+
`TextInput setTextAndSelection count=${count} text=${JSON.stringify(currentValue)}`,
|
|
214
|
+
);
|
|
215
|
+
dispatchViewCommand(engineNode, 'setTextAndSelection', [
|
|
216
|
+
count,
|
|
217
|
+
currentValue,
|
|
218
|
+
selStart,
|
|
219
|
+
selEnd,
|
|
220
|
+
]);
|
|
211
221
|
lastNativeText = currentValue;
|
|
212
222
|
});
|
|
213
223
|
|
|
@@ -217,7 +227,8 @@
|
|
|
217
227
|
// controlled-write effect above relies on.
|
|
218
228
|
$effect(() => {
|
|
219
229
|
const engineNode = hostShim?.engineNode;
|
|
220
|
-
if (autoFocused || engineNode === undefined || rest.autoFocus !== true)
|
|
230
|
+
if (autoFocused || engineNode === undefined || rest.autoFocus !== true)
|
|
231
|
+
return;
|
|
221
232
|
autoFocused = true;
|
|
222
233
|
dlog('TextInput autoFocus -> focus command');
|
|
223
234
|
dispatchViewCommand(engineNode, 'focus', []);
|
|
@@ -241,7 +252,12 @@
|
|
|
241
252
|
export function clear(): void {
|
|
242
253
|
const engineNode = hostShim?.engineNode;
|
|
243
254
|
if (engineNode === undefined) return;
|
|
244
|
-
dispatchViewCommand(engineNode, 'setTextAndSelection', [
|
|
255
|
+
dispatchViewCommand(engineNode, 'setTextAndSelection', [
|
|
256
|
+
mostRecentEventCount,
|
|
257
|
+
'',
|
|
258
|
+
0,
|
|
259
|
+
0,
|
|
260
|
+
]);
|
|
245
261
|
lastNativeText = '';
|
|
246
262
|
}
|
|
247
263
|
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
-
// TouchableHighlight: built on Pressable.
|
|
3
|
-
// TouchableHighlight.js)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// TouchableHighlight: built on Pressable. Ported against RN's own source
|
|
3
|
+
// (.vendors/react-native/.../Components/Touchable/TouchableHighlight.js) after the 2026-08-19
|
|
4
|
+
// audit; Solid migrated first, then React, Vue and Angular, and this is Svelte.
|
|
5
|
+
//
|
|
6
|
+
// RN drives the underlay from THREE Pressability callbacks, not from a `pressed` flag, and the
|
|
7
|
+
// difference is visible: onPress re-shows the underlay and holds it for delayPressOut, so a tap
|
|
8
|
+
// too fast to see still flashes. The previous port derived the highlight from Pressable's
|
|
9
|
+
// `pressed` through `highlightPressedStyle`, which cannot express that hold — the flag is
|
|
10
|
+
// already false by then. The machine itself
|
|
11
|
+
// (createHighlightUnderlayHandlers) is shared; Svelte owns the reactive `shown` cell and the
|
|
12
|
+
// real timers.
|
|
7
13
|
import type { ITouchableHighlightProps } from './touchable-highlight-props';
|
|
8
14
|
|
|
9
15
|
export type { ITouchableHighlightProps };
|
|
@@ -11,28 +17,121 @@
|
|
|
11
17
|
|
|
12
18
|
<script lang="ts">
|
|
13
19
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
createHighlightUnderlayHandlers,
|
|
21
|
+
createHighlightUnderlayRuntime,
|
|
22
|
+
hasTouchablePressHandler,
|
|
23
|
+
resolveHighlightExtraStyles,
|
|
18
24
|
} from '@symbiote-native/components';
|
|
19
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
ISymbioteEvent,
|
|
27
|
+
IStyleProp,
|
|
28
|
+
IViewStyle,
|
|
29
|
+
} from '@symbiote-native/engine';
|
|
20
30
|
import Pressable from '../pressable/index.svelte';
|
|
21
31
|
|
|
32
|
+
function scheduleTimeout(callback: () => void, ms: number): () => void {
|
|
33
|
+
const id = setTimeout(callback, ms);
|
|
34
|
+
return () => clearTimeout(id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// onPress/onPressIn/onPressOut are INTERCEPTED, not forwarded: RN drives the underlay from
|
|
38
|
+
// those three and then calls the caller's. delayPressOut is CONSUMED (it is the underlay hold),
|
|
39
|
+
// so it must not also reach the host as an unknown Fabric prop. onLongPress deliberately stays
|
|
40
|
+
// in `rest` — Pressable fires it directly — and is only READ here, for the has-handler gate.
|
|
22
41
|
let {
|
|
23
|
-
activeOpacity
|
|
24
|
-
underlayColor
|
|
42
|
+
activeOpacity,
|
|
43
|
+
underlayColor,
|
|
25
44
|
style,
|
|
45
|
+
delayPressOut = 0,
|
|
46
|
+
onPress,
|
|
47
|
+
onPressIn,
|
|
48
|
+
onPressOut,
|
|
49
|
+
onShowUnderlay,
|
|
50
|
+
onHideUnderlay,
|
|
26
51
|
children: content,
|
|
27
52
|
...rest
|
|
28
53
|
}: ITouchableHighlightProps = $props();
|
|
29
54
|
|
|
30
|
-
|
|
31
|
-
|
|
55
|
+
// `shown` is NOT `pressed`: RN holds the underlay past the tap for delayPressOut.
|
|
56
|
+
let shown = $state(false);
|
|
57
|
+
// Setup-scope only, mutated by the machine on every event and never read reactively — same as
|
|
58
|
+
// Pressable's own runtime, never `$state`.
|
|
59
|
+
const runtime = createHighlightUnderlayRuntime();
|
|
60
|
+
|
|
61
|
+
// RN's _hasPressHandler gate: a TouchableHighlight with no press callback is decorative, and
|
|
62
|
+
// flashing an underlay under a touch passing through it is wrong.
|
|
63
|
+
const hasPressHandler = $derived(
|
|
64
|
+
hasTouchablePressHandler({
|
|
65
|
+
onPress,
|
|
66
|
+
onPressIn,
|
|
67
|
+
onPressOut,
|
|
68
|
+
onLongPress: rest.onLongPress,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Rebuilt whenever the config changes; the runtime persists across rebuilds so an in-flight
|
|
73
|
+
// hide timer survives one.
|
|
74
|
+
const underlay = $derived(
|
|
75
|
+
createHighlightUnderlayHandlers(
|
|
76
|
+
{ delayPressOut, hasPressHandler, schedule: scheduleTimeout },
|
|
77
|
+
runtime,
|
|
78
|
+
{
|
|
79
|
+
setShown(next: boolean): void {
|
|
80
|
+
shown = next;
|
|
81
|
+
},
|
|
82
|
+
onShowUnderlay: () => onShowUnderlay?.(),
|
|
83
|
+
onHideUnderlay: () => onHideUnderlay?.(),
|
|
84
|
+
},
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const extra = $derived(
|
|
89
|
+
resolveHighlightExtraStyles({
|
|
90
|
+
shown,
|
|
91
|
+
hasPressHandler,
|
|
92
|
+
underlayColor,
|
|
93
|
+
activeOpacity,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// ITEM 7 IS DELIBERATELY NOT FIXED HERE, and this is the whole of the divergence from RN. RN
|
|
98
|
+
// clones its single child with `extra.child`, so the lowered opacity lands on the CHILD while
|
|
99
|
+
// the container keeps an opaque underlay; React does that with cloneElement and Vue with
|
|
100
|
+
// cloneVNode. Svelte hands a component an opaque `Snippet` — a function that renders into an
|
|
101
|
+
// anchor. It cannot be introspected, cloned, or given a prop, and it may have several roots, so
|
|
102
|
+
// there is NO way to reach the child short of a permanent wrapper View. That wrapper would sit
|
|
103
|
+
// in the flex chain between this container and the children and silently re-parent any `flex`
|
|
104
|
+
// they declare; the fake Fabric runs no Yoga, so no headless test can measure the damage.
|
|
105
|
+
// Shipping an unmeasurable layout change is worse than the visual approximation, so both styles
|
|
106
|
+
// stay on the container (the child style folded in LAST), exactly as Solid and Angular decided.
|
|
107
|
+
// resolveHighlightExtraStyles keeps them separate regardless, so a future fix — which needs a
|
|
108
|
+
// real device, not a test — is unblocked.
|
|
109
|
+
const containerStyle = $derived<IStyleProp<IViewStyle>>(
|
|
110
|
+
extra === undefined ? style : [style, extra.underlay, extra.child],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Visual first, then the caller's callback — RN's order in _createPressabilityConfig.
|
|
114
|
+
function handlePressIn(event: ISymbioteEvent): void {
|
|
115
|
+
underlay.handlePressIn(event);
|
|
116
|
+
onPressIn?.(event);
|
|
117
|
+
}
|
|
118
|
+
function handlePress(event: ISymbioteEvent): void {
|
|
119
|
+
underlay.handlePress(event);
|
|
120
|
+
onPress?.(event);
|
|
121
|
+
}
|
|
122
|
+
function handlePressOut(event: ISymbioteEvent): void {
|
|
123
|
+
underlay.handlePressOut(event);
|
|
124
|
+
onPressOut?.(event);
|
|
32
125
|
}
|
|
33
126
|
</script>
|
|
34
127
|
|
|
35
|
-
<Pressable
|
|
128
|
+
<Pressable
|
|
129
|
+
{...rest}
|
|
130
|
+
style={containerStyle}
|
|
131
|
+
onPress={handlePress}
|
|
132
|
+
onPressIn={handlePressIn}
|
|
133
|
+
onPressOut={handlePressOut}
|
|
134
|
+
>
|
|
36
135
|
{#snippet children()}
|
|
37
136
|
{@render content?.()}
|
|
38
137
|
{/snippet}
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
</script>
|
|
14
14
|
|
|
15
15
|
<script lang="ts">
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
backgroundProps,
|
|
18
|
+
selectableBackground,
|
|
19
|
+
} from '@symbiote-native/components';
|
|
17
20
|
import { dlog } from '@symbiote-native/engine';
|
|
18
21
|
import Pressable from '../pressable/index.svelte';
|
|
19
22
|
|
|
@@ -29,7 +32,9 @@
|
|
|
29
32
|
const resolved = $derived(background ?? selectableBackground());
|
|
30
33
|
|
|
31
34
|
$effect(() => {
|
|
32
|
-
dlog(
|
|
35
|
+
dlog(
|
|
36
|
+
`TouchableNativeFeedback render ${resolved.type} useForeground ${useForeground}`,
|
|
37
|
+
);
|
|
33
38
|
});
|
|
34
39
|
|
|
35
40
|
const nativeProps = $derived(backgroundProps(resolved, useForeground));
|
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
-
// TouchableOpacity: built on Pressable, like the rest of the family.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// (
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// wired into the native Animated node graph — `useNativeDriver`-style native-thread driving is
|
|
16
|
-
// NOT available here until a real Animated adapter binding lands.
|
|
2
|
+
// TouchableOpacity: built on Pressable, like the rest of the family. Ported against RN's own
|
|
3
|
+
// source (.vendors/react-native/.../Components/Touchable/TouchableOpacity.js) after the
|
|
4
|
+
// 2026-08-19 audit found ten divergences shared by every adapter; Solid migrated first, then
|
|
5
|
+
// React, Vue and Angular, and this is Svelte.
|
|
6
|
+
//
|
|
7
|
+
// Shared (framework-agnostic) half: the press-scheduling machine and the resting-opacity math
|
|
8
|
+
// (@symbiote-native/components). Svelte owns only the lifecycle — runes, the real timers, and
|
|
9
|
+
// the Animated wiring.
|
|
10
|
+
//
|
|
11
|
+
// The interim `tweenOpacity` stand-in this file used to carry is GONE. Its header claimed no
|
|
12
|
+
// Animated binding existed for this adapter; that stopped being true when
|
|
13
|
+
// modules/animated landed, and a setTimeout tween can never be native-driven — which is
|
|
14
|
+
// exactly what RN asks for here (useNativeDriver: true, TouchableOpacity.js:242).
|
|
17
15
|
import type { ITouchableOpacityProps } from './touchable-opacity-props';
|
|
16
|
+
import View from '../View.svelte';
|
|
17
|
+
import { createAnimatedComponent } from '../../modules/animated/create-animated-component';
|
|
18
18
|
|
|
19
19
|
export type { ITouchableOpacityProps };
|
|
20
|
+
|
|
21
|
+
// Wrapped here, not imported from modules/animated: that barrel pulls in six `.svelte`
|
|
22
|
+
// components (View/Text/Image/ScrollView/FlatList/SectionList), and this package's vitest has
|
|
23
|
+
// no `.svelte` loader — a smoke test would have to pre-compile the whole tree to reach one
|
|
24
|
+
// Animated.View. Same reasoning, different cause, as sticky-header.svelte's own local wrap.
|
|
25
|
+
// Module scope, so every TouchableOpacity instance shares one component identity.
|
|
26
|
+
const AnimatedView = createAnimatedComponent(View);
|
|
20
27
|
</script>
|
|
21
28
|
|
|
22
29
|
<script lang="ts">
|
|
@@ -24,37 +31,28 @@
|
|
|
24
31
|
createTouchableFeedbackHandlers,
|
|
25
32
|
createTouchableFeedbackRuntime,
|
|
26
33
|
DEFAULT_ACTIVE_OPACITY,
|
|
27
|
-
|
|
28
|
-
OPACITY_ACTIVE_DURATION_MS,
|
|
34
|
+
OPACITY_ACTIVE_GRANT_DURATION_MS,
|
|
29
35
|
OPACITY_INACTIVE_DURATION_MS,
|
|
30
|
-
|
|
36
|
+
TOUCHABLE_MIN_PRESS_DURATION_MS,
|
|
37
|
+
restingOpacityFromStyle,
|
|
31
38
|
} from '@symbiote-native/components';
|
|
32
|
-
import
|
|
39
|
+
import {
|
|
40
|
+
AnimatedMock,
|
|
41
|
+
AnimatedValue,
|
|
42
|
+
Easing,
|
|
43
|
+
Platform,
|
|
44
|
+
dlog,
|
|
45
|
+
timing,
|
|
46
|
+
type ISymbioteEvent,
|
|
47
|
+
} from '@symbiote-native/engine';
|
|
33
48
|
import Pressable from '../pressable/index.svelte';
|
|
34
49
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
durationMs: number,
|
|
42
|
-
onFrame: (value: number) => void,
|
|
43
|
-
): () => void {
|
|
44
|
-
const start = Date.now();
|
|
45
|
-
let cancelled = false;
|
|
46
|
-
function step(): void {
|
|
47
|
-
if (cancelled) return;
|
|
48
|
-
const t = Math.min(1, (Date.now() - start) / durationMs);
|
|
49
|
-
const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
|
50
|
-
onFrame(from + (to - from) * eased);
|
|
51
|
-
if (t < 1) setTimeout(step, TWEEN_FRAME_MS);
|
|
52
|
-
}
|
|
53
|
-
step();
|
|
54
|
-
return () => {
|
|
55
|
-
cancelled = true;
|
|
56
|
-
};
|
|
57
|
-
}
|
|
50
|
+
// modules/animated's barrel swaps the WHOLE driver namespace for the mock when the host
|
|
51
|
+
// reports reduced motion; reaching it here would close the import the module block above
|
|
52
|
+
// avoids, so the one driver this component uses is swapped locally on the same flag.
|
|
53
|
+
const startTiming = Platform.isDisableAnimations
|
|
54
|
+
? AnimatedMock.timing
|
|
55
|
+
: timing;
|
|
58
56
|
|
|
59
57
|
function scheduleTimeout(callback: () => void, ms: number): () => void {
|
|
60
58
|
const id = setTimeout(callback, ms);
|
|
@@ -70,21 +68,36 @@
|
|
|
70
68
|
onPressOut,
|
|
71
69
|
delayPressIn = 0,
|
|
72
70
|
delayPressOut = 0,
|
|
73
|
-
|
|
71
|
+
// RN's Touchables override Pressability's own 130ms floor with 0 (TouchableOpacity.js:195);
|
|
72
|
+
// what holds the active visual is the fade's duration, not a press-duration floor.
|
|
73
|
+
minPressDuration = TOUCHABLE_MIN_PRESS_DURATION_MS,
|
|
74
74
|
...rest
|
|
75
75
|
}: ITouchableOpacityProps = $props();
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// RN's _getChildStyleOpacityWithDefault: the fade settles at the opacity the CALLER's style
|
|
78
|
+
// asks for, not at a hard 1.
|
|
79
|
+
const restingOpacity = $derived(restingOpacityFromStyle(style));
|
|
80
|
+
|
|
81
|
+
// One Animated.Value per mount, held by IDENTITY in a plain `const` — never `$state`, which
|
|
82
|
+
// would deep-proxy an engine object whose graph bookkeeping is keyed on the raw instance
|
|
83
|
+
// (same concern as Pressable's `$state.raw` hostShim).
|
|
84
|
+
// svelte-ignore state_referenced_locally -- the SEED is a one-shot read of the initial style;
|
|
85
|
+
// later changes are re-settled by the update effect below, not by re-seeding.
|
|
86
|
+
const opacity = new AnimatedValue(restingOpacity);
|
|
87
|
+
|
|
79
88
|
// Setup-scope only, mutated by the machine — same as Pressable's own runtime, never `$state`.
|
|
80
89
|
const runtime = createTouchableFeedbackRuntime();
|
|
81
90
|
|
|
82
91
|
function setOpacityTo(toValue: number, duration: number): void {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
dlog(`TouchableOpacity opacity -> ${toValue} over ${duration}ms`);
|
|
93
|
+
// useNativeDriver: true is RN's own (TouchableOpacity.js:242). opacity is natively drivable,
|
|
94
|
+
// so the fade survives a busy JS thread — the whole point of press feedback.
|
|
95
|
+
startTiming(opacity, {
|
|
96
|
+
toValue,
|
|
97
|
+
duration,
|
|
98
|
+
easing: Easing.inOut(Easing.quad),
|
|
99
|
+
useNativeDriver: true,
|
|
100
|
+
}).start();
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
// Rebuilt whenever the timing config changes (mirrors Vue's per-render rebuild); the runtime
|
|
@@ -101,24 +114,67 @@
|
|
|
101
114
|
runtime,
|
|
102
115
|
{
|
|
103
116
|
activate(event: ISymbioteEvent): void {
|
|
104
|
-
|
|
117
|
+
// 0, not 150 (TouchableOpacity.js:215-220): the duration is chosen by where the
|
|
118
|
+
// press-in came from, and Pressability re-dispatches the GRANT event as the delay
|
|
119
|
+
// signal when delayPressIn is 0 — so an ordinary tap darkens instantly in RN. The 150ms
|
|
120
|
+
// branch is a re-activation (finger returning inside after leaving the bounds), which
|
|
121
|
+
// our engine never emits — it sends pressIn from one place, on topTouchStart.
|
|
122
|
+
setOpacityTo(activeOpacity, OPACITY_ACTIVE_GRANT_DURATION_MS);
|
|
105
123
|
onPressIn?.(event);
|
|
106
124
|
},
|
|
107
125
|
deactivate(event: ISymbioteEvent): void {
|
|
108
|
-
setOpacityTo(
|
|
126
|
+
setOpacityTo(restingOpacity, OPACITY_INACTIVE_DURATION_MS);
|
|
109
127
|
onPressOut?.(event);
|
|
110
128
|
},
|
|
111
129
|
},
|
|
112
130
|
),
|
|
113
131
|
);
|
|
114
132
|
|
|
115
|
-
|
|
133
|
+
// Two SEPARATE deriveds, not one object: each memoizes by value, so an unrelated prop change
|
|
134
|
+
// cannot re-settle the opacity — the Svelte form of Vue's deliberate two-getter watch source.
|
|
135
|
+
const disabledInput = $derived(rest.disabled);
|
|
136
|
+
|
|
137
|
+
// RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
|
|
138
|
+
// so a Touchable disabled mid-press does not stay stuck at its active opacity. The first run is
|
|
139
|
+
// skipped — RN does this on UPDATE only, and firing at mount would animate over the value the
|
|
140
|
+
// Animated.Value was just seeded with.
|
|
141
|
+
let settled: { disabled: unknown; resting: number } | undefined;
|
|
142
|
+
$effect(() => {
|
|
143
|
+
const current = { disabled: disabledInput, resting: restingOpacity };
|
|
144
|
+
const previous = settled;
|
|
145
|
+
settled = current;
|
|
146
|
+
if (previous === undefined) return;
|
|
147
|
+
if (
|
|
148
|
+
previous.disabled === current.disabled &&
|
|
149
|
+
previous.resting === current.resting
|
|
150
|
+
) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setOpacityTo(current.resting, OPACITY_INACTIVE_DURATION_MS);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// RN's componentWillUnmount: stop the animation and drop the value back, so a teardown
|
|
157
|
+
// mid-fade leaves no driver running against a node that is gone.
|
|
158
|
+
$effect(() => {
|
|
159
|
+
return () => {
|
|
160
|
+
opacity.resetAnimation();
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Depends on `style` alone. `opacity` is a stable graph node, so a press changes what the node
|
|
165
|
+
// HOLDS, never this array — the per-frame path is setValue -> AnimatedProps.update ->
|
|
166
|
+
// setNativeProps, which never re-renders this component.
|
|
167
|
+
const feedbackStyle = $derived([style, { opacity }]);
|
|
116
168
|
</script>
|
|
117
169
|
|
|
118
|
-
<Pressable
|
|
170
|
+
<Pressable
|
|
171
|
+
{...rest}
|
|
172
|
+
onPressIn={handlers.handlePressIn}
|
|
173
|
+
onPressOut={handlers.handlePressOut}
|
|
174
|
+
>
|
|
119
175
|
{#snippet children()}
|
|
120
|
-
<
|
|
176
|
+
<AnimatedView style={feedbackStyle} class={className}>
|
|
121
177
|
{@render content?.()}
|
|
122
|
-
</
|
|
178
|
+
</AnimatedView>
|
|
123
179
|
{/snippet}
|
|
124
180
|
</Pressable>
|