@symbiote-native/svelte 1.0.0 → 2.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 (107) hide show
  1. package/README.md +8 -10
  2. package/build/components/{activity-indicator/activity-indicator-props.d.ts → activity-indicator-props.d.ts} +1 -1
  3. package/build/components/flat-list/index.svelte +4 -4
  4. package/build/components/{image-background/image-background-props.d.ts → image-background-props.d.ts} +1 -1
  5. package/build/components/index.d.ts +6 -24
  6. package/build/components/index.js +0 -18
  7. package/build/components/keyboard-avoiding-view/index.svelte +6 -6
  8. package/build/components/modal/index.svelte +7 -7
  9. package/build/components/pressable/pressable-props.d.ts +1 -0
  10. package/build/components/refresh-control-props.d.ts +1 -0
  11. package/build/components/text-props.d.ts +1 -0
  12. package/build/components/virtualized-list/index.svelte +71 -250
  13. package/build/dom-shim/canonical-prop-names.d.ts +2 -0
  14. package/build/dom-shim/canonical-prop-names.js +320 -0
  15. package/build/dom-shim/document.js +1 -1
  16. package/build/dom-shim/element.d.ts +9 -3
  17. package/build/dom-shim/element.js +195 -29
  18. package/build/dom-shim/patch-globals.js +31 -5
  19. package/build/dom-shim/style-cache.d.ts +1 -0
  20. package/build/dom-shim/style-cache.js +27 -0
  21. package/build/dom-shim/text.js +2 -2
  22. package/build/host-instance.d.ts +2 -3
  23. package/build/host-instance.js +7 -1
  24. package/build/index.d.ts +7 -4
  25. package/build/index.js +17 -5
  26. package/build/intrinsic-elements.d.ts +63 -0
  27. package/build/modules/animated/index.d.ts +7 -27
  28. package/build/modules/animated/index.js +24 -34
  29. package/build/modules/image/index.d.ts +3 -0
  30. package/build/modules/image/index.js +15 -0
  31. package/build/preprocessor/scoped-styles.js +1 -1
  32. package/build/register.d.ts +1 -1
  33. package/build/register.js +13 -31
  34. package/build/root-element.js +1 -1
  35. package/metro-css-parser.cjs +2 -1
  36. package/metro-svelte-transformer.cjs +1 -17
  37. package/package.json +7 -15
  38. package/build/components/RefreshControl.svelte +0 -40
  39. package/build/components/RefreshControl.svelte.d.ts +0 -5
  40. package/build/components/SafeAreaView.svelte +0 -34
  41. package/build/components/SafeAreaView.svelte.d.ts +0 -5
  42. package/build/components/Text.svelte +0 -34
  43. package/build/components/Text.svelte.d.ts +0 -5
  44. package/build/components/View.svelte +0 -43
  45. package/build/components/View.svelte.d.ts +0 -5
  46. package/build/components/activity-indicator/activity-indicator-platform.android.d.ts +0 -3
  47. package/build/components/activity-indicator/activity-indicator-platform.android.js +0 -5
  48. package/build/components/activity-indicator/activity-indicator-platform.d.ts +0 -1
  49. package/build/components/activity-indicator/activity-indicator-platform.ios.d.ts +0 -3
  50. package/build/components/activity-indicator/activity-indicator-platform.ios.js +0 -6
  51. package/build/components/activity-indicator/activity-indicator-platform.js +0 -5
  52. package/build/components/activity-indicator/index.svelte +0 -63
  53. package/build/components/activity-indicator/index.svelte.d.ts +0 -5
  54. package/build/components/button.svelte +0 -76
  55. package/build/components/button.svelte.d.ts +0 -5
  56. package/build/components/image/image-logic.d.ts +0 -2
  57. package/build/components/image/image-logic.js +0 -135
  58. package/build/components/image/index.d.ts +0 -3
  59. package/build/components/image/index.js +0 -10
  60. package/build/components/image/index.svelte +0 -66
  61. package/build/components/image/index.svelte.d.ts +0 -5
  62. package/build/components/image-background/index.svelte +0 -87
  63. package/build/components/image-background/index.svelte.d.ts +0 -5
  64. package/build/components/input-accessory-view/index.svelte +0 -51
  65. package/build/components/input-accessory-view/index.svelte.d.ts +0 -5
  66. package/build/components/pressable/index.svelte +0 -185
  67. package/build/components/pressable/index.svelte.d.ts +0 -9
  68. package/build/components/scroll-view/index.svelte +0 -331
  69. package/build/components/scroll-view/index.svelte.d.ts +0 -18
  70. package/build/components/scroll-view/scroll-view-platform-types.d.ts +0 -3
  71. package/build/components/scroll-view/scroll-view-platform.android.d.ts +0 -3
  72. package/build/components/scroll-view/scroll-view-platform.android.js +0 -3
  73. package/build/components/scroll-view/scroll-view-platform.d.ts +0 -1
  74. package/build/components/scroll-view/scroll-view-platform.ios.d.ts +0 -3
  75. package/build/components/scroll-view/scroll-view-platform.ios.js +0 -3
  76. package/build/components/scroll-view/scroll-view-platform.js +0 -4
  77. package/build/components/scroll-view/scroll-view-sticky-context.d.ts +0 -7
  78. package/build/components/scroll-view/scroll-view-sticky-context.js +0 -1
  79. package/build/components/scroll-view/sticky-header-props.d.ts +0 -10
  80. package/build/components/scroll-view/sticky-header-props.js +0 -1
  81. package/build/components/scroll-view/sticky-header.svelte +0 -287
  82. package/build/components/scroll-view/sticky-header.svelte.d.ts +0 -5
  83. package/build/components/switch/index.svelte +0 -134
  84. package/build/components/switch/index.svelte.d.ts +0 -5
  85. package/build/components/text-input/index.svelte +0 -372
  86. package/build/components/text-input/index.svelte.d.ts +0 -17
  87. package/build/components/touchable-highlight/index.svelte +0 -139
  88. package/build/components/touchable-highlight/index.svelte.d.ts +0 -5
  89. package/build/components/touchable-native-feedback/index.d.ts +0 -9
  90. package/build/components/touchable-native-feedback/index.js +0 -14
  91. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +0 -49
  92. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte.d.ts +0 -5
  93. package/build/components/touchable-opacity/index.svelte +0 -181
  94. package/build/components/touchable-opacity/index.svelte.d.ts +0 -5
  95. package/build/components/touchable-without-feedback/index.svelte +0 -75
  96. package/build/components/touchable-without-feedback/index.svelte.d.ts +0 -5
  97. package/build/modules/animated/animated-component-props.d.ts +0 -7
  98. package/build/modules/animated/animated-component-props.js +0 -1
  99. package/build/modules/animated/create-animated-component.d.ts +0 -5
  100. package/build/modules/animated/create-animated-component.js +0 -131
  101. package/build/preprocessor/lower-host-primitives.d.ts +0 -10
  102. package/build/preprocessor/lower-host-primitives.js +0 -455
  103. package/build/state-style.d.ts +0 -1
  104. package/build/state-style.js +0 -8
  105. /package/build/components/{activity-indicator/activity-indicator-props.js → activity-indicator-props.js} +0 -0
  106. /package/build/components/{image-background/image-background-props.js → image-background-props.js} +0 -0
  107. /package/build/{components/scroll-view/scroll-view-platform-types.js → intrinsic-elements.js} +0 -0
@@ -1,87 +0,0 @@
1
- <script lang="ts" module>
2
- // ImageBackground: render-only composition (absolute-fill Image behind, children on top).
3
- // Calls renderImageBackground() straight from core/components for the absolute-fill
4
- // positioning + wrapper-dimension proxy onto the inner Image — both tags stay literal (the
5
- // wrapper hosts `{@render children?.()}` as a live sibling after the image, which the
6
- // generic descriptorToSvelte bridge can't safely own: appending the image inside an $effect
7
- // would race Svelte's own placement of the live children and could reorder them — see
8
- // svelte-adapter-dom-shim skill §19), so `.props`/`.children[0].props` are destructured onto
9
- // the two known host tags, same pattern as Modal/index.svelte.
10
- import type { IImageBackgroundProps } from './image-background-props';
11
-
12
- export type { IImageBackgroundProps };
13
- </script>
14
-
15
- <script lang="ts">
16
- import {
17
- renderImageBackground,
18
- resolveAccessibilityProps,
19
- } from '@symbiote-native/components';
20
- import { resolveClassName } from '@symbiote-native/engine';
21
- import { createAttachmentsSync } from '../../runes/attachments';
22
- import type { ShimElement } from '../../dom-shim';
23
-
24
- let {
25
- children,
26
- style,
27
- imageStyle,
28
- class: className,
29
- source,
30
- defaultSource,
31
- loadingIndicatorSource,
32
- resizeMode,
33
- tintColor,
34
- src,
35
- srcSet,
36
- alt,
37
- width,
38
- height,
39
- crossOrigin,
40
- referrerPolicy,
41
- ...passthrough
42
- }: IImageBackgroundProps = $props();
43
-
44
- const resolvedImageStyle = $derived(
45
- typeof imageStyle === 'string' ? resolveClassName(imageStyle) : imageStyle,
46
- );
47
-
48
- const descriptor = $derived(
49
- renderImageBackground({
50
- style,
51
- imageStyle: resolvedImageStyle,
52
- image: {
53
- source,
54
- defaultSource,
55
- loadingIndicatorSource,
56
- resizeMode,
57
- tintColor,
58
- src,
59
- srcSet,
60
- alt,
61
- width,
62
- height,
63
- crossOrigin,
64
- referrerPolicy,
65
- passthrough: resolveAccessibilityProps(passthrough),
66
- },
67
- }),
68
- );
69
- const imageBag = $derived.by(() => {
70
- const child = descriptor.children[0];
71
- return typeof child === 'string' ? {} : child.props;
72
- });
73
- const wrapperBag = $derived({ ...descriptor.props, class: className });
74
-
75
- // See View.svelte's note on `{@attach}` — the attachment binds to the OUTER wrapper, the node
76
- // this component's own `class`/`style` land on and the one a caller means by "this component".
77
- let hostShim = $state.raw<ShimElement | null>(null);
78
- const syncAttachments = createAttachmentsSync();
79
- $effect(() => {
80
- syncAttachments(hostShim, passthrough);
81
- });
82
- </script>
83
-
84
- <symbiote-view p={wrapperBag} bind:this={hostShim}>
85
- <symbiote-image p={imageBag} />
86
- {@render children?.()}
87
- </symbiote-view>
@@ -1,5 +0,0 @@
1
- import type { IImageBackgroundProps } from './image-background-props';
2
- export type { IImageBackgroundProps };
3
- declare const Index: import("svelte").Component<IImageBackgroundProps, {}, "">;
4
- type Index = ReturnType<typeof Index>;
5
- export default Index;
@@ -1,51 +0,0 @@
1
- <script lang="ts" module>
2
- // InputAccessoryView: render-only host assembly (iOS). Calls the shared
3
- // core/components/src/view/render-input-accessory-view.ts's renderInputAccessoryView()
4
- // directly (nativeID/backgroundColor/style -> the host node, everything else passthrough) —
5
- // the same calling convention as keyboard-avoiding-view/index.svelte's shared-render-fn call,
6
- // rather than hand-duplicating its prop-assembly logic (per the svelte-adapter-dom-shim
7
- // skill's §15/§19 correction: a fixed-shape Descriptor still has to be CALLED, not
8
- // re-derived). The root tag stays literal below since renderInputAccessoryView always paints
9
- // the same 'symbiote-input-accessory-view' host — no dynamic <svelte:element> needed. The
10
- // host has no structural children of its own; the user's children nest directly under it.
11
- import type { IInputAccessoryViewProps } from './input-accessory-view-props';
12
-
13
- export type { IInputAccessoryViewProps };
14
- </script>
15
-
16
- <script lang="ts">
17
- import {
18
- renderInputAccessoryView,
19
- resolveAccessibilityProps,
20
- } from '@symbiote-native/components';
21
- import { createAttachmentsSync } from '../../runes/attachments';
22
- import type { ShimElement } from '../../dom-shim';
23
-
24
- let {
25
- nativeID,
26
- backgroundColor,
27
- style,
28
- children,
29
- ...passthrough
30
- }: IInputAccessoryViewProps = $props();
31
-
32
- const descriptor = $derived(
33
- renderInputAccessoryView({
34
- nativeID,
35
- backgroundColor,
36
- style,
37
- passthrough: resolveAccessibilityProps(passthrough),
38
- }),
39
- );
40
-
41
- // See View.svelte's note on `{@attach}`.
42
- let hostShim = $state.raw<ShimElement | null>(null);
43
- const syncAttachments = createAttachmentsSync();
44
- $effect(() => {
45
- syncAttachments(hostShim, passthrough);
46
- });
47
- </script>
48
-
49
- <symbiote-input-accessory-view p={descriptor.props} bind:this={hostShim}>
50
- {@render children?.()}
51
- </symbiote-input-accessory-view>
@@ -1,5 +0,0 @@
1
- import type { IInputAccessoryViewProps } from './input-accessory-view-props';
2
- export type { IInputAccessoryViewProps };
3
- declare const Index: import("svelte").Component<IInputAccessoryViewProps, {}, "">;
4
- type Index = ReturnType<typeof Index>;
5
- export default Index;
@@ -1,185 +0,0 @@
1
- <script lang="ts" module>
2
- // Pressable: the Svelte lifecycle half. The press lifecycle (the long-press timer,
3
- // unstable_pressDelay deferral, the pressRetentionOffset drift test, the suppression flags)
4
- // lives in @symbiote-native/components/state as a pure machine over a runtime + host; the
5
- // render decisions (the responder listeners, the disabled->accessibilityState fold, the
6
- // ripple prop) in @symbiote-native/components/view, shared verbatim with React and Vue. Here
7
- // runes supply the reactivity: `$state` holds `pressed`, a plain setup-scope object (created
8
- // once, like Vue's) holds the press runtime, and `$state.raw` holds the responder's ShimElement
9
- // by IDENTITY so the machine can measure through it (same shallowRef-not-ref concern as Vue's
10
- // pressable.ts / Switch's own hostShim — a deep-proxied `$state` object would miss the engine's
11
- // WeakMap mirror lookup).
12
- //
13
- // Pressable owns no `renderPressable()` Descriptor factory (unlike Switch's fixed-shape
14
- // `renderSwitch()`) — core/components/src/view/render-pressable only resolves the responder-
15
- // listener bag and the accessibilityState fold, so this hand-authors the host node directly,
16
- // close to View.svelte's own shape, wiring the press responder listeners onto it instead of
17
- // importing View.svelte (which has no `bind:this` escape hatch for the raw ShimElement the
18
- // measure handle needs).
19
- import type { IPressableProps } from './pressable-props';
20
-
21
- export type { IPressableProps };
22
- </script>
23
-
24
- <script lang="ts">
25
- import { onDestroy } from 'svelte';
26
- import {
27
- createPressHandlers,
28
- createPressRuntime,
29
- disposePressRuntime,
30
- rippleProps,
31
- buildPressableListeners,
32
- resolveDisabledAccessibilityState,
33
- resolveAccessibilityProps,
34
- noteHoverNoop,
35
- DEFAULT_DELAY_LONG_PRESS_MS,
36
- DEFAULT_MIN_PRESS_DURATION_MS,
37
- type IPressHost,
38
- type IPressState,
39
- } from '@symbiote-native/components';
40
- import { measure, type ISymbioteNode } from '@symbiote-native/engine';
41
- import type { ShimElement } from '../../dom-shim';
42
- import { createAttachmentsSync } from '../../runes/attachments';
43
-
44
- let {
45
- onPress,
46
- onPressIn,
47
- onPressOut,
48
- onPressMove,
49
- onLongPress,
50
- delayLongPress = DEFAULT_DELAY_LONG_PRESS_MS,
51
- disabled,
52
- cancelable,
53
- hitSlop,
54
- pressRetentionOffset,
55
- unstable_pressDelay = 0,
56
- __minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS,
57
- android_ripple,
58
- android_disableSound,
59
- onHoverIn,
60
- onHoverOut,
61
- delayHoverIn,
62
- delayHoverOut,
63
- accessibilityState,
64
- style,
65
- class: className,
66
- children,
67
- ...rest
68
- }: IPressableProps & {
69
- /** @internal Touchable* mirrors RN's Pressability minPressDuration: 0 override. */
70
- __minPressDuration?: number;
71
- } = $props();
72
-
73
- let pressed = $state(false);
74
- // Plain setup-scope object, never `$state`: mutated by the machine on every event, never
75
- // reactively read — same as Vue's setup-scope runtime.
76
- const runtime = createPressRuntime();
77
- // `$state.raw`, NOT `$state`: holds the responder ShimElement by IDENTITY. `$state()` would
78
- // deep-proxy it, and `measure()` looks the RAW node up in the engine's WeakMap mirror — a Proxy
79
- // wrapper misses that lookup and the retention-region measure silently no-ops.
80
- let hostShim = $state.raw<ShimElement | null>(null);
81
-
82
- const host: IPressHost = {
83
- setPressed(next: boolean): void {
84
- pressed = next;
85
- },
86
- getMeasureFn: () => {
87
- const node: ISymbioteNode | undefined = hostShim?.engineNode;
88
- if (node === undefined) return undefined;
89
- return callback => measure(node, callback);
90
- },
91
- schedule: (callback, ms) => {
92
- const id = setTimeout(callback, ms);
93
- return () => clearTimeout(id);
94
- },
95
- now: Date.now,
96
- };
97
-
98
- onDestroy(() => {
99
- disposePressRuntime(runtime);
100
- });
101
-
102
- $effect(() => {
103
- noteHoverNoop(onHoverIn, onHoverOut);
104
- });
105
- void delayHoverIn;
106
- void delayHoverOut;
107
-
108
- const state: IPressState = $derived({ pressed });
109
-
110
- // Rebuilt whenever the config it closes over changes (mirrors Vue's per-render rebuild); the
111
- // runtime persists across rebuilds so in-flight timers/drift state survive.
112
- const handlers = $derived(
113
- createPressHandlers(
114
- {
115
- onPress,
116
- onPressIn,
117
- onPressOut,
118
- onPressMove,
119
- onLongPress,
120
- delayLongPress,
121
- unstable_pressDelay,
122
- minPressDuration: __minPressDuration,
123
- hitSlop,
124
- pressRetentionOffset,
125
- },
126
- runtime,
127
- host,
128
- ),
129
- );
130
-
131
- // android_ripple rides a dedicated inner View; on iOS rippleProps() returns undefined, so the
132
- // child renders unwrapped, no extra node — mirrors React's Pressable + touchable-native-feedback.
133
- const ripple = $derived(
134
- android_ripple !== undefined ? rippleProps(android_ripple) : undefined,
135
- );
136
-
137
- // ONE derived, not four. The accessibility fold and the style resolution used to be their own
138
- // `$derived`s, each read by exactly this bag and by nothing else — a memo nobody can reuse, in
139
- // exchange for a reaction-graph node per Pressable instance. A 1 000-row list mounts 2 000 of
140
- // them, which made this component the largest single allocation site in a create profile.
141
- // `handlers` and `ripple` stay separate BECAUSE they are read elsewhere: `handlers` must keep
142
- // its identity across an unrelated bag change or every recompute re-registers native listeners,
143
- // and `ripple` is read twice (the branch test and the inner view's own bag).
144
- const bag = $derived.by(() => {
145
- // Folds aria-*/role into accessibility* — Pressable owns its host node directly (it does not
146
- // compose View.svelte), so it must fold this itself, exactly like Vue's Pressable and every
147
- // other Svelte component that hand-authors a raw host tag (RefreshControl.svelte, modal/
148
- // index.svelte).
149
- const resolved = resolveAccessibilityProps({
150
- ...rest,
151
- accessibilityState: resolveDisabledAccessibilityState(
152
- accessibilityState,
153
- disabled,
154
- ),
155
- });
156
- const next: Record<string, unknown> = {
157
- ...resolved,
158
- style: typeof style === 'function' ? style(state) : style,
159
- class: className,
160
- hitSlop,
161
- ...buildPressableListeners(handlers, { disabled, cancelable }),
162
- };
163
- // Assigned rather than conditionally spread: `...(cond ? { x } : {})` allocated an empty
164
- // object literal on every evaluation for the overwhelmingly common `undefined` case.
165
- if (android_disableSound !== undefined)
166
- next.android_disableSound = android_disableSound;
167
- return next;
168
- });
169
-
170
- // See View.svelte's note on `{@attach}`.
171
- const syncAttachments = createAttachmentsSync();
172
- $effect(() => {
173
- syncAttachments(hostShim, rest);
174
- });
175
- </script>
176
-
177
- <symbiote-view p={bag} bind:this={hostShim}>
178
- {#if ripple !== undefined}
179
- <symbiote-view p={ripple}>
180
- {@render children?.(state)}
181
- </symbiote-view>
182
- {:else}
183
- {@render children?.(state)}
184
- {/if}
185
- </symbiote-view>
@@ -1,9 +0,0 @@
1
- import type { IPressableProps } from './pressable-props';
2
- export type { IPressableProps };
3
- type $$ComponentProps = IPressableProps & {
4
- /** @internal Touchable* mirrors RN's Pressability minPressDuration: 0 override. */
5
- __minPressDuration?: number;
6
- };
7
- declare const Index: import("svelte").Component<$$ComponentProps, {}, "">;
8
- type Index = ReturnType<typeof Index>;
9
- export default Index;
@@ -1,331 +0,0 @@
1
- <script lang="ts" module>
2
- // ScrollView: sticky headers, RefreshControl, the imperative scroll handle,
3
- // maintainVisibleContentPosition, native scroll-attach. There is NO renderScrollView Descriptor
4
- // factory (no 3-layer split, no descriptorToSvelte bridge exists anywhere in this adapter — see
5
- // svelte-adapter-dom-shim skill §15's fixed-shape-render note). This component hand-assembles
6
- // markup and wires refs/effects directly, calling the SAME framework-agnostic helpers React's
7
- // usePreparedScrollView / Vue's createScrollView call: resolveDecelerationRate,
8
- // selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, resolveScrollForwarding,
9
- // buildScrollViewHandle, attachStickyScroll, forwardScrollEvent, resolveAccessibilityProps.
10
- //
11
- // Fabric tree shape: a scroll view wraps a content view holding the children (RN's own
12
- // ScrollView.js shape). Svelte cannot pick a host tag name dynamically without
13
- // `<svelte:element>` (unverified under the DOM shim, not exercised elsewhere in this adapter —
14
- // see svelte-adapter-dom-shim skill §4), so the horizontal/vertical tag choice is a static
15
- // `{#if isHorizontal}` branch instead of a data-driven `createElement(scrollViewIntrinsic, …)`
16
- // call the way React/Vue do it.
17
- //
18
- // RefreshControl: iOS renders the real `RefreshControl.svelte` as a childless SIBLING before
19
- // the content container. Android WRAPS the scroll view with it (`refreshControl` becomes the
20
- // parent, scroll view nested inside) — structurally possible in Svelte (unlike React's
21
- // cloneElement / Vue's VNode re-invocation) because `refreshControl` is typed as
22
- // RefreshControl's OWN PROPS BAG here (scroll-view-props.ts), not a pre-rendered
23
- // element/snippet: ScrollView itself instantiates `<RefreshControl>` in the right position and
24
- // puts the scroll view INSIDE it on Android via plain markup nesting.
25
- //
26
- // KNOWN GAPS:
27
- // 1. `stickyHeaderIndices` / `invertStickyHeaders` are NOT auto-honored (see scroll-view-props.ts
28
- // and sticky-header.svelte's header comments for the full reasoning). Compose the exported
29
- // `ScrollViewStickyHeader` manually instead; it auto-wires to THIS ScrollView's scroll offset
30
- // via Svelte context (scroll-view-sticky-context.ts), so no extra props are usually needed.
31
- // 2. `maintainVisibleContentPosition` is forwarded to the native node and `collapsableChildren`
32
- // is set correctly (via resolveScrollForwarding), but not otherwise exercised or tested here.
33
- import type { IScrollViewProps } from './scroll-view-props';
34
- import type { IScrollViewHandle } from '@symbiote-native/components';
35
-
36
- export type { IScrollViewProps, IScrollViewHandle };
37
- </script>
38
-
39
- <script lang="ts">
40
- import { setContext } from 'svelte';
41
- import {
42
- attachStickyScroll,
43
- buildScrollViewHandle,
44
- didContentSizeChange,
45
- forwardScrollEvent,
46
- readLayoutDimension,
47
- resolveAccessibilityProps,
48
- resolveDecelerationRate,
49
- resolveScrollForwarding,
50
- selectScrollIntrinsics,
51
- splitLayoutProps,
52
- type IContentSize,
53
- } from '@symbiote-native/components';
54
- import {
55
- AnimatedValue,
56
- dlog,
57
- event as animatedEvent,
58
- isNativeAnimatedAvailable,
59
- resolveClassName,
60
- type ISymbioteEvent,
61
- type ISymbioteNode,
62
- } from '@symbiote-native/engine';
63
- import { resolveSvelteClass } from '../../class-value';
64
- import { PLATFORM } from './scroll-view-platform';
65
- import { SCROLL_VIEW_STICKY_CONTEXT_KEY } from './scroll-view-sticky-context';
66
- import RefreshControl from '../RefreshControl.svelte';
67
- import type { ShimElement } from '../../dom-shim';
68
- import { createAttachmentsSync } from '../../runes/attachments';
69
-
70
- let {
71
- style,
72
- class: className,
73
- contentContainerStyle,
74
- horizontal,
75
- decelerationRate,
76
- refreshControl,
77
- onContentSizeChange,
78
- stickyHeaderIndices,
79
- invertStickyHeaders,
80
- onLayout,
81
- onScroll,
82
- scrollEventThrottle,
83
- children,
84
- ...passthrough
85
- }: IScrollViewProps = $props();
86
-
87
- $effect(() => {
88
- if (stickyHeaderIndices === undefined || stickyHeaderIndices.length === 0)
89
- return;
90
- dlog(
91
- 'ScrollView.stickyHeaderIndices is not auto-honored on Svelte (no index-wrap mechanism — ' +
92
- 'see scroll-view-props.ts KNOWN GAP); compose ScrollViewStickyHeader manually instead',
93
- );
94
- });
95
-
96
- // $state.raw, NOT $state: holds the shim element by IDENTITY — a deep $state proxy would make
97
- // dispatchViewCommand miss the engine's WeakMap mirror and every
98
- // scrollTo/scrollToEnd/flashScrollIndicators would silently no-op.
99
- let hostShim = $state.raw<ShimElement | null>(null);
100
- const handle: IScrollViewHandle = buildScrollViewHandle(
101
- (): ISymbioteNode | null => hostShim?.engineNode ?? null,
102
- );
103
-
104
- // Plain exported functions on the instance script are what a parent's `bind:this={ref}` sees —
105
- // the Svelte mechanism for exposing an imperative handle, the twin of React's
106
- // useImperativeHandle / Vue's expose().
107
- export function scrollTo(options?: {
108
- x?: number;
109
- y?: number;
110
- animated?: boolean;
111
- }): void {
112
- handle.scrollTo(options);
113
- }
114
- export function scrollToEnd(options?: { animated?: boolean }): void {
115
- handle.scrollToEnd(options);
116
- }
117
- export function flashScrollIndicators(): void {
118
- handle.flashScrollIndicators();
119
- }
120
- export function getScrollNode(): ISymbioteNode | null {
121
- return handle.getScrollNode();
122
- }
123
-
124
- const isHorizontal = $derived(horizontal === true);
125
- const hasStickyHeaders = $derived(
126
- stickyHeaderIndices !== undefined && stickyHeaderIndices.length > 0,
127
- );
128
- const shouldWrapRefreshControl = $derived(
129
- PLATFORM.refreshControlMode === 'wrap' && refreshControl !== undefined,
130
- );
131
-
132
- // Android wrap mode only: RN's ScrollView.js splits the flattened style across the two boxes —
133
- // LAYOUT props (margin/flex/size/position/...) drive the outer AndroidSwipeRefreshLayout frame,
134
- // VISUAL props (background/padding/border/...) paint the inner scroll view (mirrors React's/
135
- // Vue's index.android.ts splitLayoutProps). Splitting on the resolved [class, style] pair, not
136
- // `style` alone: a class-only layout prop (flex/height/gap/...) is invisible to `style` until
137
- // resolveClassName runs, so splitting on `style` alone would starve the wrapper of its layout
138
- // style and collapse it to nothing — e.g. App.svelte's `class="screen"` (flex:1) on the
139
- // top-level ScrollView, which left the wrapper with no height for its content to grow into.
140
- const layoutSplit = $derived(
141
- shouldWrapRefreshControl
142
- ? splitLayoutProps([resolveSvelteClass(className), style])
143
- : undefined,
144
- );
145
-
146
- // A single AnimatedValue tracks the scroll offset (RN's _scrollAnimatedValue), allocated once
147
- // per instance — held by IDENTITY (never wrapped in $state, the same reactivity rule the shim
148
- // node follows), shared with any manually-composed ScrollViewStickyHeader via context below.
149
- const scrollAnimatedValue = new AnimatedValue(0);
150
-
151
- // Inverted sticky headers stick to the BOTTOM, needing the viewport height (RN _handleLayout).
152
- let viewportHeight = $state<number | undefined>(undefined);
153
-
154
- // Context handoff for a manually-composed ScrollViewStickyHeader (see scroll-view-props.ts's
155
- // KNOWN GAP note) — getter functions so a header reads the LIVE value, not a snapshot from
156
- // whenever it first called getContext().
157
- setContext(SCROLL_VIEW_STICKY_CONTEXT_KEY, {
158
- scrollAnimatedValue,
159
- getInverted: (): boolean | undefined => invertStickyHeaders,
160
- getViewportHeight: (): number | undefined => viewportHeight,
161
- });
162
-
163
- // Resolved dynamically, exactly like React (adapters/react/.../scroll-view/shared.ts:267). Do
164
- // not hardcode this false to keep the JS listener alive: `attachStickyScroll` makes
165
- // `scrollAnimatedValue` native up front, and once native AnimatedWithChildren stops cascading
166
- // listeners into its subtree — but RN carries the SAME gate (AnimatedWithChildren.js:74) and
167
- // streams values back only for AnimatedValue, never an interpolation, so the listener is silent
168
- // under RN too and sticky headers still work: the pin IS the native transform, the listener
169
- // only feeds the debounced committed transform for hit-testing (ScrollViewStickyHeader.js adds
170
- // it solely `if (isFabric)`). Forcing the JS path to keep that listener alive only gives up the
171
- // native driver, putting the pin on the JS thread — drift on iOS, outright failure on Android
172
- // (commit debounce 15ms vs iOS's 64ms — render-scroll-sticky.ts).
173
- const nativeStickyAvailable = $derived(
174
- hasStickyHeaders && isNativeAnimatedAvailable(),
175
- );
176
-
177
- // Native sticky-scroll attach (RN attachNativeEvent / _updateAnimatedNodeAttachment) — NOT used
178
- // for sticky headers (see the comment above); kept for other native-event-attach consumers.
179
- $effect(() => {
180
- if (!nativeStickyAvailable) return;
181
- const node = hostShim?.engineNode;
182
- if (node === undefined) return;
183
- return attachStickyScroll(node, scrollAnimatedValue);
184
- });
185
-
186
- const resolvedContentContainerStyle = $derived(
187
- typeof contentContainerStyle === 'string'
188
- ? resolveClassName(contentContainerStyle)
189
- : contentContainerStyle,
190
- );
191
-
192
- const intrinsics = $derived(
193
- selectScrollIntrinsics(isHorizontal, resolvedContentContainerStyle),
194
- );
195
-
196
- const forwarding = $derived(
197
- resolveScrollForwarding({
198
- hasStickyHeaders,
199
- nativeStickyAvailable,
200
- invertStickyHeaders,
201
- scrollEventThrottle,
202
- maintainVisibleContentPosition:
203
- passthrough.maintainVisibleContentPosition,
204
- snapToAlignment: passthrough.snapToAlignment,
205
- }),
206
- );
207
-
208
- let lastContentSize = $state.raw<IContentSize | null>(null);
209
-
210
- function handleContentLayout(event: ISymbioteEvent): void {
211
- const width = readLayoutDimension(event, 'width');
212
- const height = readLayoutDimension(event, 'height');
213
- if (width === undefined || height === undefined) return;
214
- if (!didContentSizeChange(lastContentSize, { width, height })) return;
215
- lastContentSize = { width, height };
216
- dlog(`ScrollView onContentSizeChange ${width}x${height}`);
217
- onContentSizeChange?.(width, height);
218
- }
219
-
220
- function handleScrollLayout(event: ISymbioteEvent): void {
221
- if (forwarding.capturesViewportHeight) {
222
- const height = readLayoutDimension(event, 'height');
223
- if (height !== undefined) viewportHeight = height;
224
- }
225
- onLayout?.(event);
226
- }
227
-
228
- // onScroll: the JS-fallback path wraps the user's handler in Animated.event so the offset drives
229
- // the AnimatedValue each frame (RN _scrollAnimatedValueAttachment); the native + plain paths
230
- // forward the user's handler as-is (the native driver attaches the value on the UI thread).
231
- const resolvedScrollHandler = $derived.by(() => {
232
- if (forwarding.mode !== 'sticky-js') return onScroll;
233
- return animatedEvent(
234
- [{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }],
235
- onScroll === undefined
236
- ? undefined
237
- : {
238
- listener: (...args: unknown[]) =>
239
- forwardScrollEvent(onScroll, args),
240
- },
241
- );
242
- });
243
-
244
- // Not wrapping: the full [base, style] pair stays on the scroll view, unchanged. Wrapping: only
245
- // the VISUAL half (layoutSplit.inner) paints it — the LAYOUT half moved to the wrapper below.
246
- const scrollStyle = $derived([
247
- intrinsics.scrollViewBaseStyle,
248
- layoutSplit !== undefined ? layoutSplit.inner : style,
249
- ]);
250
-
251
- const outerBag = $derived.by(() => {
252
- const forwarded = resolveAccessibilityProps(passthrough);
253
- return {
254
- ...forwarded,
255
- nestedScrollEnabled: passthrough.nestedScrollEnabled ?? true,
256
- ...(horizontal !== undefined ? { horizontal } : {}),
257
- ...(decelerationRate !== undefined
258
- ? { decelerationRate: resolveDecelerationRate(decelerationRate) }
259
- : {}),
260
- // layoutSplit already folded className's resolved value into inner/outer above — forwarding
261
- // the raw class here too would re-apply its LAYOUT half onto the inner scroll view a second
262
- // time (on top of the wrapper).
263
- ...(className !== undefined && layoutSplit === undefined
264
- ? { class: className }
265
- : {}),
266
- style: scrollStyle,
267
- onScroll: resolvedScrollHandler,
268
- onLayout: handleScrollLayout,
269
- ...(forwarding.scrollEventThrottle !== undefined
270
- ? { scrollEventThrottle: forwarding.scrollEventThrottle }
271
- : {}),
272
- };
273
- });
274
-
275
- // `collapsable: false` is load-bearing on Android: the content container is a layout-only view
276
- // Android Fabric would otherwise view-flatten away, hoisting the cells up as direct children of
277
- // the scroll view (which strictly hosts one child — an addViewAt crash). No-op on iOS.
278
- const contentBag = $derived.by(() => ({
279
- style: intrinsics.contentStyle,
280
- collapsable: false,
281
- ...(forwarding.collapsableChildren ? { collapsableChildren: false } : {}),
282
- ...(onContentSizeChange !== undefined
283
- ? { onLayout: handleContentLayout }
284
- : {}),
285
- }));
286
- // See View.svelte's note on `{@attach}`.
287
- const syncAttachments = createAttachmentsSync();
288
- $effect(() => {
289
- syncAttachments(hostShim, passthrough);
290
- });
291
- </script>
292
-
293
- {#snippet scrollBody()}
294
- <!-- The gap between these two sibling blocks survives clean_nodes as a ' ' text node, but
295
- the shim drops a whitespace-only node whose parent takes no raw text, so it never
296
- reaches Fabric (dom-shim/text.ts, svelte-adapter-dom-shim §16b). -->
297
- {#if !shouldWrapRefreshControl && refreshControl !== undefined}
298
- <RefreshControl {...refreshControl} />
299
- {/if}
300
- {#if isHorizontal}
301
- <symbiote-horizontal-scroll-content p={contentBag}>
302
- {@render children?.()}
303
- </symbiote-horizontal-scroll-content>
304
- {:else}
305
- <symbiote-scroll-content p={contentBag}>
306
- {@render children?.()}
307
- </symbiote-scroll-content>
308
- {/if}
309
- {/snippet}
310
-
311
- {#if shouldWrapRefreshControl && refreshControl !== undefined}
312
- <RefreshControl {...refreshControl} style={layoutSplit?.outer}>
313
- {#if isHorizontal}
314
- <symbiote-horizontal-scroll-view p={outerBag} bind:this={hostShim}>
315
- {@render scrollBody()}
316
- </symbiote-horizontal-scroll-view>
317
- {:else}
318
- <symbiote-scroll-view p={outerBag} bind:this={hostShim}>
319
- {@render scrollBody()}
320
- </symbiote-scroll-view>
321
- {/if}
322
- </RefreshControl>
323
- {:else if isHorizontal}
324
- <symbiote-horizontal-scroll-view p={outerBag} bind:this={hostShim}>
325
- {@render scrollBody()}
326
- </symbiote-horizontal-scroll-view>
327
- {:else}
328
- <symbiote-scroll-view p={outerBag} bind:this={hostShim}>
329
- {@render scrollBody()}
330
- </symbiote-scroll-view>
331
- {/if}