@symbiote-native/svelte 0.2.1 → 1.0.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 (81) 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 +48 -23
  16. package/build/components/pressable/index.svelte.d.ts +5 -1
  17. package/build/components/pressable/pressable-props.d.ts +1 -1
  18. package/build/components/safe-area-view-props.d.ts +1 -0
  19. package/build/components/scroll-view/index.svelte +54 -21
  20. package/build/components/scroll-view/sticky-header.svelte +49 -16
  21. package/build/components/section-list/index.svelte +6 -1
  22. package/build/components/switch/index.svelte +16 -5
  23. package/build/components/text-input/index.svelte +80 -8
  24. package/build/components/text-input/index.svelte.d.ts +5 -0
  25. package/build/components/touchable-highlight/index.svelte +115 -15
  26. package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
  27. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +8 -3
  28. package/build/components/touchable-opacity/index.svelte +113 -56
  29. package/build/components/touchable-without-feedback/index.svelte +60 -4
  30. package/build/components/virtualized-list/index.svelte +233 -70
  31. package/build/components/virtualized-list/virtualized-list-props.js +8 -4
  32. package/build/components/virtualized-section-list/index.svelte +56 -9
  33. package/build/components/virtualized-section-list/index.svelte.d.ts +1 -1
  34. package/build/components/virtualized-section-list/virtualized-section-list-props.d.ts +5 -0
  35. package/build/create-portal/index.d.ts +22 -0
  36. package/build/create-portal/index.js +160 -0
  37. package/build/descriptor-to-svelte.js +15 -21
  38. package/build/dom-shim/element.d.ts +9 -3
  39. package/build/dom-shim/element.js +77 -25
  40. package/build/dom-shim/fold-host-bag.d.ts +1 -0
  41. package/build/dom-shim/fold-host-bag.js +7 -0
  42. package/build/dom-shim/index.d.ts +1 -0
  43. package/build/dom-shim/index.js +3 -0
  44. package/build/dom-shim/patch-globals.js +4 -2
  45. package/build/dom-shim/shim-node.js +11 -0
  46. package/build/dom-shim/text.js +38 -1
  47. package/build/host-instance.js +9 -5
  48. package/build/index.d.ts +6 -3
  49. package/build/index.js +20 -11
  50. package/build/modules/animated/create-animated-component.d.ts +5 -0
  51. package/build/modules/animated/create-animated-component.js +131 -0
  52. package/build/modules/animated/index.d.ts +24 -14
  53. package/build/modules/animated/index.js +30 -30
  54. package/build/modules/status-bar/index.js +1 -1
  55. package/build/modules/status-bar/index.svelte +4 -1
  56. package/build/native-view-bridge.d.ts +1 -1
  57. package/build/native-view-bridge.js +1 -1
  58. package/build/preprocessor/collapse-text-whitespace.js +20 -7
  59. package/build/preprocessor/forbid-web-only-constructs.js +8 -2
  60. package/build/preprocessor/lower-host-primitives.d.ts +10 -0
  61. package/build/preprocessor/lower-host-primitives.js +455 -0
  62. package/build/preprocessor/scoped-styles.js +141 -104
  63. package/build/register.d.ts +1 -0
  64. package/build/register.js +33 -0
  65. package/build/render.js +48 -3
  66. package/build/scope-token.d.ts +3 -2
  67. package/build/scope-token.js +6 -17
  68. package/build/state-style.d.ts +1 -0
  69. package/build/state-style.js +8 -0
  70. package/build/style-scope.d.ts +2 -1
  71. package/build/style-scope.js +10 -8
  72. package/metro-svelte-transformer.cjs +48 -14
  73. package/package.json +28 -10
  74. package/build/modules/animated/AnimatedImage.svelte +0 -115
  75. package/build/modules/animated/AnimatedImage.svelte.d.ts +0 -5
  76. package/build/modules/animated/AnimatedScrollView.svelte +0 -92
  77. package/build/modules/animated/AnimatedScrollView.svelte.d.ts +0 -17
  78. package/build/modules/animated/AnimatedText.svelte +0 -58
  79. package/build/modules/animated/AnimatedText.svelte.d.ts +0 -5
  80. package/build/modules/animated/AnimatedView.svelte +0 -95
  81. package/build/modules/animated/AnimatedView.svelte.d.ts +0 -5
@@ -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,122 @@
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
+ __minPressDuration={0}
130
+ {...rest}
131
+ style={containerStyle}
132
+ onPress={handlePress}
133
+ onPressIn={handlePressIn}
134
+ onPressOut={handlePressOut}
135
+ >
36
136
  {#snippet children()}
37
137
  {@render content?.()}
38
138
  {/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,13 +32,15 @@
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));
36
41
  </script>
37
42
 
38
- <Pressable {...rest}>
43
+ <Pressable __minPressDuration={0} {...rest}>
39
44
  {#snippet children()}
40
45
  <symbiote-view p={nativeProps}>
41
46
  {@render content?.()}
@@ -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,68 @@
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
+ __minPressDuration={0}
172
+ {...rest}
173
+ onPressIn={handlers.handlePressIn}
174
+ onPressOut={handlers.handlePressOut}
175
+ >
119
176
  {#snippet children()}
120
- <symbiote-view p={{ style: foldedStyle, class: className }}>
177
+ <AnimatedView style={feedbackStyle} class={className}>
121
178
  {@render content?.()}
122
- </symbiote-view>
179
+ </AnimatedView>
123
180
  {/snippet}
124
181
  </Pressable>
@@ -1,18 +1,74 @@
1
1
  <script lang="ts" module>
2
- // TouchableWithoutFeedback: built on Pressable, no visual feedback of its own — just the press
3
- // wiring, exactly like React's and Vue's twins.
2
+ // TouchableWithoutFeedback: built on Pressable, no visual feedback of its own. Ported against
3
+ // RN's own source (.vendors/react-native/.../Components/Touchable/TouchableWithoutFeedback.js)
4
+ // after the 2026-08-19 audit.
5
+ //
6
+ // RN builds a FULL Pressability config here — delayPressIn / delayPressOut /
7
+ // minPressDuration: 0 — so "without feedback" means no VISUAL, not no TIMING. The previous port
8
+ // spread the delay props straight into Pressable, which both ignored them (Pressable owns no
9
+ // press-delay scheduling) and forwarded them to the host as unknown Fabric props. The same
10
+ // shared machine TouchableOpacity runs now runs here with the visual half left empty.
4
11
  import type { ITouchableWithoutFeedbackProps } from './touchable-without-feedback-props';
5
12
 
6
13
  export type { ITouchableWithoutFeedbackProps };
7
14
  </script>
8
15
 
9
16
  <script lang="ts">
17
+ import {
18
+ createTouchableFeedbackHandlers,
19
+ createTouchableFeedbackRuntime,
20
+ TOUCHABLE_MIN_PRESS_DURATION_MS,
21
+ } from '@symbiote-native/components';
22
+ import type { ISymbioteEvent } from '@symbiote-native/engine';
10
23
  import Pressable from '../pressable/index.svelte';
11
24
 
12
- let { children: content, ...rest }: ITouchableWithoutFeedbackProps = $props();
25
+ function scheduleTimeout(callback: () => void, ms: number): () => void {
26
+ const id = setTimeout(callback, ms);
27
+ return () => clearTimeout(id);
28
+ }
29
+
30
+ let {
31
+ children: content,
32
+ onPressIn,
33
+ onPressOut,
34
+ delayPressIn = 0,
35
+ delayPressOut = 0,
36
+ // RN's Touchables override Pressability's own 130ms floor with 0.
37
+ minPressDuration = TOUCHABLE_MIN_PRESS_DURATION_MS,
38
+ ...rest
39
+ }: ITouchableWithoutFeedbackProps = $props();
40
+
41
+ // Setup-scope only, mutated by the machine — never `$state`.
42
+ const runtime = createTouchableFeedbackRuntime();
43
+
44
+ const handlers = $derived(
45
+ createTouchableFeedbackHandlers(
46
+ {
47
+ delayPressIn,
48
+ delayPressOut,
49
+ minPressDuration,
50
+ schedule: scheduleTimeout,
51
+ now: Date.now,
52
+ },
53
+ runtime,
54
+ {
55
+ activate(event: ISymbioteEvent): void {
56
+ onPressIn?.(event);
57
+ },
58
+ deactivate(event: ISymbioteEvent): void {
59
+ onPressOut?.(event);
60
+ },
61
+ },
62
+ ),
63
+ );
13
64
  </script>
14
65
 
15
- <Pressable {...rest}>
66
+ <Pressable
67
+ __minPressDuration={0}
68
+ {...rest}
69
+ onPressIn={handlers.handlePressIn}
70
+ onPressOut={handlers.handlePressOut}
71
+ >
16
72
  {#snippet children()}
17
73
  {@render content?.()}
18
74
  {/snippet}