@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.
Files changed (67) hide show
  1. package/build/bootstrap.js +1 -1
  2. package/build/class-value.js +1 -1
  3. package/build/components/RefreshControl.svelte +4 -2
  4. package/build/components/Text.svelte +11 -2
  5. package/build/components/View.svelte +3 -1
  6. package/build/components/activity-indicator/index.svelte +8 -2
  7. package/build/components/button.svelte +8 -3
  8. package/build/components/flat-list/index.svelte +46 -11
  9. package/build/components/image/image-logic.js +13 -3
  10. package/build/components/image-background/index.svelte +8 -2
  11. package/build/components/index.d.ts +2 -2
  12. package/build/components/input-accessory-view/index.svelte +4 -1
  13. package/build/components/keyboard-avoiding-view/index.svelte +34 -11
  14. package/build/components/modal/index.svelte +4 -1
  15. package/build/components/pressable/index.svelte +10 -3
  16. package/build/components/pressable/pressable-props.d.ts +1 -1
  17. package/build/components/scroll-view/index.svelte +54 -21
  18. package/build/components/scroll-view/sticky-header.svelte +49 -16
  19. package/build/components/section-list/index.svelte +6 -1
  20. package/build/components/switch/index.svelte +14 -3
  21. package/build/components/text-input/index.svelte +22 -6
  22. package/build/components/touchable-highlight/index.svelte +114 -15
  23. package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
  24. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +7 -2
  25. package/build/components/touchable-opacity/index.svelte +112 -56
  26. package/build/components/touchable-without-feedback/index.svelte +59 -4
  27. package/build/components/virtualized-list/index.svelte +233 -70
  28. package/build/components/virtualized-list/virtualized-list-props.js +8 -4
  29. package/build/components/virtualized-section-list/index.svelte +56 -9
  30. package/build/components/virtualized-section-list/index.svelte.d.ts +1 -1
  31. package/build/components/virtualized-section-list/virtualized-section-list-props.d.ts +5 -0
  32. package/build/create-portal/index.d.ts +22 -0
  33. package/build/create-portal/index.js +160 -0
  34. package/build/descriptor-to-svelte.js +15 -21
  35. package/build/dom-shim/index.d.ts +1 -0
  36. package/build/dom-shim/index.js +3 -0
  37. package/build/dom-shim/text.js +38 -1
  38. package/build/host-instance.js +7 -2
  39. package/build/index.d.ts +5 -3
  40. package/build/index.js +16 -11
  41. package/build/modules/animated/create-animated-component.d.ts +5 -0
  42. package/build/modules/animated/create-animated-component.js +131 -0
  43. package/build/modules/animated/index.d.ts +24 -14
  44. package/build/modules/animated/index.js +30 -30
  45. package/build/modules/status-bar/index.js +1 -1
  46. package/build/modules/status-bar/index.svelte +4 -1
  47. package/build/native-view-bridge.d.ts +1 -1
  48. package/build/native-view-bridge.js +1 -1
  49. package/build/preprocessor/collapse-text-whitespace.d.ts +10 -0
  50. package/build/preprocessor/collapse-text-whitespace.js +138 -0
  51. package/build/preprocessor/forbid-web-only-constructs.js +8 -2
  52. package/build/preprocessor/scoped-styles.js +141 -104
  53. package/build/render.js +48 -3
  54. package/build/scope-token.d.ts +3 -2
  55. package/build/scope-token.js +6 -17
  56. package/build/style-scope.d.ts +2 -1
  57. package/build/style-scope.js +10 -8
  58. package/metro-svelte-transformer.cjs +17 -2
  59. package/package.json +11 -7
  60. package/build/modules/animated/AnimatedImage.svelte +0 -115
  61. package/build/modules/animated/AnimatedImage.svelte.d.ts +0 -5
  62. package/build/modules/animated/AnimatedScrollView.svelte +0 -92
  63. package/build/modules/animated/AnimatedScrollView.svelte.d.ts +0 -17
  64. package/build/modules/animated/AnimatedText.svelte +0 -58
  65. package/build/modules/animated/AnimatedText.svelte.d.ts +0 -5
  66. package/build/modules/animated/AnimatedView.svelte +0 -95
  67. 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
- // (AnimatedView.svelte), whose `__makeNative()` connects it straight to the native shadow tree,
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 { AnimatedValue, Platform, dlog, type AnimatedInterpolation, type ISymbioteEvent } from '@symbiote-native/engine';
74
- import AnimatedView from '../../modules/animated/AnimatedView.svelte';
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>(SCROLL_VIEW_STICKY_CONTEXT_KEY);
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('ScrollViewStickyHeader used outside a ScrollView — sticky positioning is a no-op');
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 ?? stickyContext?.scrollAnimatedValue ?? new AnimatedValue(0);
120
+ scrollAnimatedValueProp ??
121
+ stickyContext?.scrollAnimatedValue ??
122
+ new AnimatedValue(0);
104
123
  const inverted = $derived(invertedProp ?? stickyContext?.getInverted());
105
- const scrollViewHeight = $derived(scrollViewHeightProp ?? stickyContext?.getViewportHeight());
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({ inputRange: [-1, 0], outputRange: [0, 0] }),
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) animatedTranslateY.removeListener(listenerId);
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(({ value }: { value: number | string }): void => {
148
- if (typeof value === 'number') dispatch({ kind: 'animated-tick', value });
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(`ScrollViewStickyHeader[y=${stickyState.layoutY}] apply-passthrough translateY=${effect.translateY}`);
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(`ScrollViewStickyHeader#${instanceId}[y=${stickyState.layoutY}] unmount`);
196
- if (listenerId !== undefined) animatedTranslateY.removeListener(listenerId);
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 AnimatedView.svelte's own passthroughAnimatedPropExplicitValues
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 { ISectionListProps, ISectionListHandle, ISection } from './section-list-props';
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 { dispatchViewCommand, dlog, type ISymbioteEvent } from '@symbiote-native/engine';
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, { type: 'native-reported', value: next });
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: { ...passthrough, class: className, onChange: handleChange },
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(source: Omit<ITextInputProps, 'value'>): Record<string, unknown> {
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 = rest.selection?.end ?? rest.selection?.start ?? SELECTION_NONE;
209
- dlog(`TextInput setTextAndSelection count=${count} text=${JSON.stringify(currentValue)}`);
210
- dispatchViewCommand(engineNode, 'setTextAndSelection', [count, currentValue, selStart, selEnd]);
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) return;
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', [mostRecentEventCount, '', 0, 0]);
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. RN drives the underlay with setState (not Animated —
3
- // TouchableHighlight.js), so unlike TouchableOpacity this needs no tween: `highlightPressedStyle`
4
- // (@symbiote-native/components/view/render-touchable-highlight) is a pure function of the live
5
- // `pressed` flag, fed through Pressable's own style-as-function-of-state prop — the same
6
- // mechanism React's `pressedStyle` callback and Vue's style-as-function attr use.
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
- highlightPressedStyle,
15
- DEFAULT_HIGHLIGHT_CHILD_OPACITY,
16
- DEFAULT_UNDERLAY_COLOR,
17
- type IPressState,
20
+ createHighlightUnderlayHandlers,
21
+ createHighlightUnderlayRuntime,
22
+ hasTouchablePressHandler,
23
+ resolveHighlightExtraStyles,
18
24
  } from '@symbiote-native/components';
19
- import type { IStyleProp, IViewStyle } from '@symbiote-native/engine';
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 = DEFAULT_HIGHLIGHT_CHILD_OPACITY,
24
- underlayColor = DEFAULT_UNDERLAY_COLOR,
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
- function pressedStyle({ pressed }: IPressState): IStyleProp<IViewStyle> {
31
- return highlightPressedStyle(pressed, style, underlayColor, activeOpacity);
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 {...rest} style={pressedStyle}>
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}
@@ -2,4 +2,6 @@ import type { ITouchableBaseProps } from '../touchable-base-props';
2
2
  export interface ITouchableHighlightProps extends ITouchableBaseProps {
3
3
  activeOpacity?: number;
4
4
  underlayColor?: string;
5
+ onShowUnderlay?: () => void;
6
+ onHideUnderlay?: () => void;
5
7
  }
@@ -13,7 +13,10 @@
13
13
  </script>
14
14
 
15
15
  <script lang="ts">
16
- import { backgroundProps, selectableBackground } from '@symbiote-native/components';
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(`TouchableNativeFeedback render ${resolved.type} useForeground ${useForeground}`);
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. RN realizes its feedback
3
- // with Animated (an opacity fade to activeOpacity on press-in, back to 1 on press-out); the
4
- // press-timing constants and the deactivation-floor math are shared with every adapter
5
- // (@symbiote-native/components/state/touchable). No `Animated.View` binding exists yet for
6
- // this adapter — core/engine/src/animated is the framework-agnostic value-graph plumbing, but
7
- // React and Vue each wrote their OWN reactive binding on top of it
8
- // (adapters/react/src/modules/animated, adapters/vue/src/modules/animated); porting that
9
- // binding to Svelte runes is separate, standalone work, out of scope for this Pressable/
10
- // Touchable/Button pass. `tweenOpacity` below is a deliberate, documented stand-in: it
11
- // reproduces Animated.timing's quad-inOut curve over the SAME shared duration constants
12
- // directly onto the style bag via `$state`, using `setTimeout` (not `requestAnimationFrame`,
13
- // which this adapter must never patch globally per the dom-shim skill §6c, and which a bare
14
- // headless test sandbox does not provide) so TouchableOpacity still visibly fades. It is not
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
- DEFAULT_MIN_PRESS_DURATION_MS,
28
- OPACITY_ACTIVE_DURATION_MS,
34
+ OPACITY_ACTIVE_GRANT_DURATION_MS,
29
35
  OPACITY_INACTIVE_DURATION_MS,
30
- RESTING_OPACITY,
36
+ TOUCHABLE_MIN_PRESS_DURATION_MS,
37
+ restingOpacityFromStyle,
31
38
  } from '@symbiote-native/components';
32
- import type { ISymbioteEvent } from '@symbiote-native/engine';
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
- const TWEEN_FRAME_MS = 16;
36
-
37
- // See the module header above: the interim substitute for a real Animated.View driver.
38
- function tweenOpacity(
39
- from: number,
40
- to: number,
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
- minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS,
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
- let opacity = $state(RESTING_OPACITY);
78
- let cancelTween: (() => void) | undefined;
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
- cancelTween?.();
84
- const from = opacity;
85
- cancelTween = tweenOpacity(from, toValue, duration, value => {
86
- opacity = value;
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
- setOpacityTo(activeOpacity, OPACITY_ACTIVE_DURATION_MS);
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(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
126
+ setOpacityTo(restingOpacity, OPACITY_INACTIVE_DURATION_MS);
109
127
  onPressOut?.(event);
110
128
  },
111
129
  },
112
130
  ),
113
131
  );
114
132
 
115
- const foldedStyle = $derived([style, { opacity }]);
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 {...rest} onPressIn={handlers.handlePressIn} onPressOut={handlers.handlePressOut}>
170
+ <Pressable
171
+ {...rest}
172
+ onPressIn={handlers.handlePressIn}
173
+ onPressOut={handlers.handlePressOut}
174
+ >
119
175
  {#snippet children()}
120
- <symbiote-view p={{ style: foldedStyle, class: className }}>
176
+ <AnimatedView style={feedbackStyle} class={className}>
121
177
  {@render content?.()}
122
- </symbiote-view>
178
+ </AnimatedView>
123
179
  {/snippet}
124
180
  </Pressable>