panelui-native 0.87.0 → 0.88.1

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 (52) hide show
  1. package/lib/module/components/animated-badge/index.js +451 -0
  2. package/lib/module/components/animated-badge/index.js.map +1 -0
  3. package/lib/module/components/bottom-sheet/index.js +17 -6
  4. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  5. package/lib/module/components/bubble-chart/index.js +557 -41
  6. package/lib/module/components/bubble-chart/index.js.map +1 -1
  7. package/lib/module/components/feedback/index.js +474 -0
  8. package/lib/module/components/feedback/index.js.map +1 -0
  9. package/lib/module/components/scroll-blur/index.js +363 -0
  10. package/lib/module/components/scroll-blur/index.js.map +1 -0
  11. package/lib/module/components/scroll-canvas/index.js +2 -0
  12. package/lib/module/components/scroll-canvas/index.js.map +1 -1
  13. package/lib/module/components/scroll-text/index.js +3 -0
  14. package/lib/module/components/scroll-text/index.js.map +1 -1
  15. package/lib/module/components/search-bar/index.js +390 -45
  16. package/lib/module/components/search-bar/index.js.map +1 -1
  17. package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
  18. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  19. package/lib/module/hooks/use-reveal-progress.js +21 -1
  20. package/lib/module/hooks/use-reveal-progress.js.map +1 -1
  21. package/lib/module/index.js +3 -0
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
  24. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
  26. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/bubble-chart/index.d.ts +150 -7
  28. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/feedback/index.d.ts +156 -0
  30. package/lib/typescript/src/components/feedback/index.d.ts.map +1 -0
  31. package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
  32. package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
  33. package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/search-bar/index.d.ts +142 -7
  35. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  37. package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
  38. package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
  39. package/lib/typescript/src/index.d.ts +5 -2
  40. package/lib/typescript/src/index.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/animated-badge/index.tsx +513 -0
  43. package/src/components/bottom-sheet/index.tsx +23 -8
  44. package/src/components/bubble-chart/index.tsx +664 -46
  45. package/src/components/feedback/index.tsx +557 -0
  46. package/src/components/scroll-blur/index.tsx +466 -0
  47. package/src/components/scroll-canvas/index.tsx +2 -1
  48. package/src/components/scroll-text/index.tsx +3 -3
  49. package/src/components/search-bar/index.tsx +471 -41
  50. package/src/hooks/use-keyboard-avoidance.ts +24 -1
  51. package/src/hooks/use-reveal-progress.ts +30 -1
  52. package/src/index.ts +27 -0
@@ -0,0 +1,513 @@
1
+ /**
2
+ * AnimatedBadge — a status pill whose icon and label roll over when the status
3
+ * changes.
4
+ *
5
+ * A badge that swaps its word between one frame and the next is a badge people
6
+ * miss. The status is the smallest thing on the screen and usually not the
7
+ * thing being looked at, so a change with no movement in it registers as
8
+ * having always said that. Rolling the old glyph out and the new one in is
9
+ * what makes the change itself visible.
10
+ *
11
+ * ```tsx
12
+ * <AnimatedBadge status="loading">Deploying</AnimatedBadge>
13
+ * <AnimatedBadge status="success">Live</AnimatedBadge>
14
+ * ```
15
+ *
16
+ * ## The roll, and the width
17
+ *
18
+ * The outgoing glyph rises out of the pill and fades; the incoming one comes
19
+ * up from below and settles on a spring, with a slight rotation and scale so
20
+ * it reads as turning over rather than sliding. Both are clipped to the pill,
21
+ * which is what keeps the movement inside the badge instead of over whatever
22
+ * it sits beside.
23
+ *
24
+ * The pill's width springs to the new word rather than jumping, because a
25
+ * badge in a row of them shoves its neighbours as it changes and a jump does
26
+ * that in one frame. Everything runs on the UI thread.
27
+ *
28
+ * ## Which change counts as a change
29
+ *
30
+ * The label is keyed on what it says, so `"Queued"` to `"Building"` rolls and
31
+ * a re-render with the same word does not. Where the label is an element
32
+ * rather than a string, or where two different states share a word, pass
33
+ * `contentKey` — the badge cannot tell those apart on its own, and without a
34
+ * key it either animates on every render or never.
35
+ *
36
+ * ## Reduced motion
37
+ *
38
+ * With the preference on, every part of this is skipped: the glyph and the
39
+ * label cut over, the pill resizes immediately, and the pulse does not run.
40
+ * The badge still says what it says, which is the part that mattered.
41
+ */
42
+ import {
43
+ forwardRef,
44
+ useCallback,
45
+ useEffect,
46
+ useRef,
47
+ useState,
48
+ type ReactNode,
49
+ } from 'react';
50
+ import { View, type ViewProps } from 'react-native';
51
+ import Animated, {
52
+ Easing,
53
+ LinearTransition,
54
+ runOnJS,
55
+ useAnimatedStyle,
56
+ useReducedMotion,
57
+ useSharedValue,
58
+ withRepeat,
59
+ withSequence,
60
+ withSpring,
61
+ withTiming,
62
+ } from 'react-native-reanimated';
63
+ import { tv, type VariantProps } from 'tailwind-variants';
64
+ import { useCSSVariable } from 'uniwind';
65
+ import {
66
+ AlertTriangleIcon,
67
+ CheckCircleIcon,
68
+ CircleIcon,
69
+ InfoIcon,
70
+ XIcon,
71
+ } from '../../icons';
72
+ import { IconColorProvider } from '../../icons';
73
+ import { Text, textChildren } from '../../primitives/text';
74
+
75
+ /** How long the pulse takes to swell and settle again, in milliseconds. */
76
+ const PULSE_DURATION = 800;
77
+
78
+ /** The roll's exit: short, because it is over before anyone looks at it. */
79
+ const ROLL_OUT = 180;
80
+
81
+ /** The roll's entrance. Springy, so the glyph lands rather than arriving. */
82
+ const ROLL_IN = { damping: 18, stiffness: 210, mass: 0.85 } as const;
83
+
84
+ export type AnimatedBadgeStatus =
85
+ | 'neutral'
86
+ | 'info'
87
+ | 'success'
88
+ | 'warning'
89
+ | 'danger'
90
+ | 'loading';
91
+
92
+ export type AnimatedBadgeSize = 'sm' | 'md';
93
+
94
+ const animatedBadgeVariants = tv({
95
+ slots: {
96
+ /*
97
+ * Clipped, which is the whole mechanism: the glyphs travel a full line box
98
+ * in and out, and without this they would be drawn over whatever the badge
99
+ * is sitting next to.
100
+ */
101
+ /*
102
+ * No border. A ring would have to be a colour per status, and there is no
103
+ * token for one — `border` on its own resolves to `currentColor`, which
104
+ * comes out black on every pill in every theme. The tinted fill is what
105
+ * separates the badge from the surface, which is how `Badge` does it too.
106
+ */
107
+ root: 'flex-row items-center self-start overflow-hidden rounded-full',
108
+ label: 'font-medium',
109
+ /** The pulse's fill, behind the content and inside the same clip. */
110
+ pulse: 'absolute inset-0',
111
+ /** One clipped column per rolling element, so the two move independently. */
112
+ slot: 'items-center justify-center overflow-hidden',
113
+ },
114
+ variants: {
115
+ status: {
116
+ neutral: {
117
+ root: 'bg-muted',
118
+ label: 'text-muted-foreground',
119
+ pulse: 'bg-muted-foreground',
120
+ },
121
+ info: {
122
+ root: 'bg-info-subtle',
123
+ label: 'text-info-foreground',
124
+ pulse: 'bg-info',
125
+ },
126
+ success: {
127
+ root: 'bg-success-subtle',
128
+ label: 'text-success-foreground',
129
+ pulse: 'bg-success',
130
+ },
131
+ warning: {
132
+ root: 'bg-warning-subtle',
133
+ label: 'text-warning-foreground',
134
+ pulse: 'bg-warning',
135
+ },
136
+ danger: {
137
+ root: 'bg-destructive-subtle',
138
+ label: 'text-destructive-foreground',
139
+ pulse: 'bg-destructive',
140
+ },
141
+ loading: {
142
+ root: 'bg-info-subtle',
143
+ label: 'text-info-foreground',
144
+ pulse: 'bg-info',
145
+ },
146
+ },
147
+ size: {
148
+ sm: { root: 'h-6 gap-1.5 px-2', label: 'text-[11px]' },
149
+ md: { root: 'h-8 gap-2 px-3', label: 'text-xs' },
150
+ },
151
+ },
152
+ defaultVariants: {
153
+ status: 'neutral',
154
+ size: 'md',
155
+ },
156
+ });
157
+
158
+ /** The glyph each status carries, when none is passed. */
159
+ const STATUS_ICON: Record<AnimatedBadgeStatus, typeof InfoIcon> = {
160
+ neutral: CircleIcon,
161
+ info: InfoIcon,
162
+ success: CheckCircleIcon,
163
+ warning: AlertTriangleIcon,
164
+ danger: XIcon,
165
+ // Never drawn: `loading` uses the spinner instead, because a still glyph
166
+ // beside the word "loading" is the one status that has to move.
167
+ loading: CircleIcon,
168
+ };
169
+
170
+ /** Which token the glyph is tinted from, so it matches the label beside it. */
171
+ const STATUS_COLOR_VAR: Record<AnimatedBadgeStatus, string> = {
172
+ neutral: '--color-muted-foreground',
173
+ info: '--color-info-foreground',
174
+ success: '--color-success-foreground',
175
+ warning: '--color-warning-foreground',
176
+ danger: '--color-destructive-foreground',
177
+ loading: '--color-info-foreground',
178
+ };
179
+
180
+ /** Glyph sizes per badge size — the icon tracks the text, not the box. */
181
+ const ICON_SIZE: Record<AnimatedBadgeSize, number> = { sm: 12, md: 14 };
182
+
183
+ /** Milliseconds for one full turn of the loading ring. */
184
+ const SPIN_DURATION = 800;
185
+
186
+ export interface AnimatedBadgeProps
187
+ extends ViewProps,
188
+ VariantProps<typeof animatedBadgeVariants> {
189
+ status?: AnimatedBadgeStatus;
190
+ size?: AnimatedBadgeSize;
191
+ /** The word. Changing it rolls the old one out and the new one in. */
192
+ children?: ReactNode;
193
+ /** A glyph of your own, in place of the status's. */
194
+ icon?: ReactNode;
195
+ /** Whether a glyph is drawn at all. */
196
+ showIcon?: boolean;
197
+ /**
198
+ * A slow swell behind the content, for a status that is still happening.
199
+ * On by default while `status` is `loading`, and off otherwise — pass it
200
+ * explicitly for a state of your own that is also still running.
201
+ */
202
+ pulse?: boolean;
203
+ /**
204
+ * What counts as a change, when the label cannot say. The label is keyed on
205
+ * its own text, so this is only needed where it is an element rather than a
206
+ * string, or where two states share a word.
207
+ */
208
+ contentKey?: string | number;
209
+ className?: string;
210
+ labelClassName?: string;
211
+ }
212
+
213
+ export const AnimatedBadge = forwardRef<View, AnimatedBadgeProps>(
214
+ (
215
+ {
216
+ status = 'neutral',
217
+ size = 'md',
218
+ children,
219
+ icon,
220
+ showIcon = true,
221
+ pulse,
222
+ contentKey,
223
+ className,
224
+ labelClassName,
225
+ ...props
226
+ },
227
+ ref
228
+ ) => {
229
+ const reducedMotion = useReducedMotion();
230
+ const slots = animatedBadgeVariants({ status, size });
231
+
232
+ const themeColor = useCSSVariable(STATUS_COLOR_VAR[status]);
233
+ const iconColor = typeof themeColor === 'string' ? themeColor : undefined;
234
+ const Icon = STATUS_ICON[status];
235
+
236
+ const pulsing = (pulse ?? status === 'loading') && !reducedMotion;
237
+
238
+ /*
239
+ * Keyed on what it says, so a re-render with the same word does not roll.
240
+ * Falling back to the status rather than to a constant means an element
241
+ * label at least changes when the state does, which is the common case for
242
+ * one — a caller with two states sharing a word passes `contentKey`.
243
+ */
244
+ const labelKey =
245
+ contentKey ??
246
+ (typeof children === 'string' || typeof children === 'number'
247
+ ? children
248
+ : status);
249
+
250
+ /*
251
+ * The width spring is for a badge changing, not for one arriving.
252
+ *
253
+ * Arming it on mount is not enough, and that is the whole subtlety: the
254
+ * pill's first *measured* layout lands a frame or two after the first
255
+ * render — text measures, the row settles — so a spring switched on at
256
+ * mount still catches that settling and animates it. From the outside the
257
+ * badge appears, drifts, and stops, on every screen that shows one.
258
+ *
259
+ * So it arms on the first real change instead. Nothing has to be guessed
260
+ * about when layout has finished, because until the status or the word
261
+ * actually moves there is nothing the spring is for.
262
+ */
263
+ const [armed, setArmed] = useState(false);
264
+ const first = useRef<string | null>(null);
265
+ useEffect(() => {
266
+ const key = `${status}\u0000${labelKey}`;
267
+ if (first.current === null) {
268
+ first.current = key;
269
+ return;
270
+ }
271
+ if (key !== first.current) setArmed(true);
272
+ }, [status, labelKey]);
273
+
274
+ const swell = useSharedValue(0);
275
+ useEffect(() => {
276
+ if (!pulsing) {
277
+ swell.value = withTiming(0, { duration: 200 });
278
+ return;
279
+ }
280
+ swell.value = withRepeat(
281
+ withSequence(
282
+ withTiming(1, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) }),
283
+ withTiming(0, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) })
284
+ ),
285
+ -1,
286
+ false
287
+ );
288
+ }, [pulsing, swell]);
289
+
290
+ const pulseStyle = useAnimatedStyle(() => ({
291
+ opacity: 0.08 + swell.value * 0.1,
292
+ transform: [{ scale: 0.96 + swell.value * 0.08 }],
293
+ }));
294
+
295
+ return (
296
+ <Animated.View
297
+ ref={ref}
298
+ // The pill grows to the new word rather than jumping to it: a badge in
299
+ // a row of them shoves its neighbours as it changes, and a jump does
300
+ // all of that shoving in one frame.
301
+ layout={
302
+ reducedMotion || !armed
303
+ ? undefined
304
+ : LinearTransition.springify().damping(22)
305
+ }
306
+ accessibilityRole="text"
307
+ accessibilityState={{ busy: status === 'loading' }}
308
+ className={slots.root({ className })}
309
+ {...props}
310
+ >
311
+ {pulsing ? (
312
+ <Animated.View
313
+ pointerEvents="none"
314
+ className={slots.pulse()}
315
+ style={pulseStyle}
316
+ />
317
+ ) : null}
318
+
319
+ {showIcon ? (
320
+ <View className={slots.slot()} style={{ height: ICON_SIZE[size] + 4 }}>
321
+ {/*
322
+ A glyph passed in is somebody else's, from any set, and it reads
323
+ the ambient colour rather than the badge's status. Provided here
324
+ so `icon` comes out the same colour as the word beside it instead
325
+ of the icon set's own grey.
326
+ */}
327
+ <IconColorProvider color={iconColor}>
328
+ <Roll contentKey={status} reducedMotion={reducedMotion} turn>
329
+ {icon ??
330
+ (status === 'loading' ? (
331
+ <LoadingRing
332
+ size={ICON_SIZE[size]}
333
+ color={iconColor}
334
+ reducedMotion={reducedMotion}
335
+ />
336
+ ) : (
337
+ <Icon size={ICON_SIZE[size]} color={iconColor} />
338
+ ))}
339
+ </Roll>
340
+ </IconColorProvider>
341
+ </View>
342
+ ) : null}
343
+
344
+ {children != null ? (
345
+ <View className={slots.slot()}>
346
+ <Roll contentKey={labelKey} reducedMotion={reducedMotion}>
347
+ {textChildren(children, (text) => (
348
+ <Text className={slots.label({ className: labelClassName })}>{text}</Text>
349
+ ))}
350
+ </Roll>
351
+ </View>
352
+ ) : null}
353
+ </Animated.View>
354
+ );
355
+ }
356
+ );
357
+
358
+ AnimatedBadge.displayName = 'AnimatedBadge';
359
+
360
+ /**
361
+ * The turning ring `loading` draws in place of a glyph.
362
+ *
363
+ * Built here rather than borrowed from `Spinner` because this one has to be
364
+ * the status's colour and the badge's glyph size, and `Spinner` takes both
365
+ * from classes — a badge overriding them ends up fighting the merge, and an
366
+ * arbitrary border width that fails to compile leaves a ring with no border at
367
+ * all, which is an empty hole where the icon should be.
368
+ *
369
+ * The track and the arc are two views of the same colour rather than two
370
+ * colours: the track is the same stroke at low opacity, so there is nothing to
371
+ * resolve but the one value the label already uses.
372
+ */
373
+ function LoadingRing({
374
+ size,
375
+ color,
376
+ reducedMotion,
377
+ }: {
378
+ size: number;
379
+ color: string | undefined;
380
+ reducedMotion: boolean;
381
+ }) {
382
+ const turn = useSharedValue(0);
383
+
384
+ useEffect(() => {
385
+ if (reducedMotion) return;
386
+ turn.value = withRepeat(
387
+ withTiming(1, { duration: SPIN_DURATION, easing: Easing.linear }),
388
+ -1,
389
+ false
390
+ );
391
+ }, [reducedMotion, turn]);
392
+
393
+ const style = useAnimatedStyle(() => ({
394
+ transform: [{ rotate: `${turn.value * 360}deg` }],
395
+ }));
396
+
397
+ const ring = {
398
+ position: 'absolute',
399
+ inset: 0,
400
+ borderRadius: size / 2,
401
+ borderWidth: Math.max(1, Math.round(size / 8)),
402
+ } as const;
403
+
404
+ return (
405
+ <View style={{ width: size, height: size }}>
406
+ <View style={[ring, { borderColor: color, opacity: 0.25 }]} />
407
+ <Animated.View
408
+ style={[ring, { borderColor: 'transparent', borderTopColor: color }, style]}
409
+ />
410
+ </View>
411
+ );
412
+ }
413
+
414
+ /**
415
+ * One element's turn: the old one out through the top, the new one up from
416
+ * below.
417
+ *
418
+ * ## Why it is one view rather than two
419
+ *
420
+ * The obvious build is to key the element on its content and let the old one
421
+ * animate out while the new one animates in. It does not work here: for the
422
+ * length of the transition both are mounted in the same box, and the box
423
+ * becomes as large as the pair of them — a badge that swells and collapses
424
+ * around every change.
425
+ *
426
+ * So there is one view throughout, and the content is swapped at the far end
427
+ * of the roll: it travels out carrying the old word, the word is changed while
428
+ * it is off-screen, and it comes back with the new one. Nothing is ever in the
429
+ * badge twice.
430
+ *
431
+ * The two halves are deliberately not symmetrical. The entrance is what the
432
+ * reader is meant to follow, so it springs and takes its time; the exit is
433
+ * only getting out of the way, and an exit that lingers holds the badge empty.
434
+ *
435
+ * The travel is a percentage rather than a distance, so a glyph and a word of
436
+ * different heights each clear their own box by the same amount.
437
+ */
438
+ function Roll({
439
+ contentKey,
440
+ children,
441
+ reducedMotion,
442
+ turn = false,
443
+ }: {
444
+ /** What counts as a change. A new value rolls; the same value does not. */
445
+ contentKey: string | number;
446
+ children: ReactNode;
447
+ reducedMotion: boolean;
448
+ /** Add a little rotation, for a glyph. A rotating word is a gimmick. */
449
+ turn?: boolean;
450
+ }) {
451
+ const [shownKey, setShownKey] = useState(contentKey);
452
+ const settled = Object.is(shownKey, contentKey);
453
+
454
+ /*
455
+ * What is on screen while the swap is in flight. Held in a ref rather than
456
+ * state because it is only read at the moment of the swap, and holding it in
457
+ * state would re-render the badge on every parent render to store an element
458
+ * nobody is looking at yet.
459
+ */
460
+ const outgoing = useRef<ReactNode>(children);
461
+ const incomingKey = useRef(contentKey);
462
+ useEffect(() => {
463
+ incomingKey.current = contentKey;
464
+ if (settled) outgoing.current = children;
465
+ });
466
+
467
+ // -1 fully below, 0 at rest, +1 fully above.
468
+ const phase = useSharedValue(0);
469
+
470
+ const commit = useCallback(() => {
471
+ setShownKey(incomingKey.current);
472
+ phase.value = -1;
473
+ phase.value = withSpring(0, ROLL_IN);
474
+ }, [phase]);
475
+
476
+ useEffect(() => {
477
+ if (settled) {
478
+ /*
479
+ * Changed and changed back before the roll finished. The swap that would
480
+ * have brought the element home is never scheduled, so without this it
481
+ * stays parked outside the badge — an empty slot where the glyph should
482
+ * be, for as long as the status holds.
483
+ */
484
+ if (phase.value !== 0) phase.value = withSpring(0, ROLL_IN);
485
+ return;
486
+ }
487
+ if (reducedMotion) {
488
+ setShownKey(incomingKey.current);
489
+ return;
490
+ }
491
+ phase.value = withTiming(1, { duration: ROLL_OUT }, (finished) => {
492
+ 'worklet';
493
+ // Interrupted means another change arrived mid-roll; that change's own
494
+ // effect owns the swap, and committing here too would swap twice.
495
+ if (finished) runOnJS(commit)();
496
+ });
497
+ }, [contentKey, settled, reducedMotion, commit, phase]);
498
+
499
+ const style = useAnimatedStyle(() => {
500
+ const p = phase.value;
501
+ const distance = Math.min(Math.abs(p), 1);
502
+ return {
503
+ opacity: 1 - distance * 0.9,
504
+ transform: [
505
+ { translateY: `${p * -90}%` },
506
+ { scale: 1 - distance * 0.1 },
507
+ { rotate: turn ? `${p * 12}deg` : '0deg' },
508
+ ],
509
+ };
510
+ });
511
+
512
+ return <Animated.View style={style}>{settled ? children : outgoing.current}</Animated.View>;
513
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  cloneElement,
3
3
  createContext,
4
+ forwardRef,
4
5
  isValidElement,
5
6
  useCallback,
6
7
  useContext,
@@ -9,6 +10,7 @@ import {
9
10
  useRef,
10
11
  useState,
11
12
  type ComponentProps,
13
+ type ComponentRef,
12
14
  type ReactElement,
13
15
  type ReactNode,
14
16
  } from 'react';
@@ -29,6 +31,7 @@ import Animated, {
29
31
  runOnJS,
30
32
  useAnimatedScrollHandler,
31
33
  useAnimatedStyle,
34
+ useComposedEventHandler,
32
35
  useReducedMotion,
33
36
  useSharedValue,
34
37
  withSpring,
@@ -909,12 +912,10 @@ export interface BottomSheetBodyProps
909
912
  * out — pull down on a list at its top and the sheet comes with you, pull
910
913
  * down anywhere else and the list scrolls.
911
914
  */
912
- function BottomSheetBody({
913
- className,
914
- children,
915
- onScroll,
916
- ...props
917
- }: BottomSheetBodyProps) {
915
+ const BottomSheetBody = forwardRef<
916
+ ComponentRef<typeof Animated.ScrollView>,
917
+ BottomSheetBodyProps
918
+ >(function BottomSheetBody({ className, children, onScroll, ...props }, ref) {
918
919
  const surface = useContext(SheetSurfaceContext);
919
920
 
920
921
  useEffect(() => {
@@ -938,13 +939,27 @@ function BottomSheetBody({
938
939
  if (scrollOffset) scrollOffset.value = event.contentOffset.y;
939
940
  });
940
941
 
942
+ /*
943
+ * Composed, not replaced. A caller's `onScroll` arriving after this one used
944
+ * to take its place, which silently cost the sheet the position report its
945
+ * whole drag arrangement is built on — the list and the sheet went back to
946
+ * fighting over every downward swipe, and nothing said why.
947
+ *
948
+ * It has to be an animated handler for the same reason this one is.
949
+ */
950
+ const composed = useComposedEventHandler([
951
+ handler,
952
+ (onScroll as typeof handler | undefined) ?? null,
953
+ ]);
954
+
941
955
  const body = (
942
956
  <Animated.ScrollView
943
- onScroll={handler}
957
+ ref={ref}
944
958
  scrollEventThrottle={16}
945
959
  showsVerticalScrollIndicator={false}
946
960
  className={cn('flex-1', className)}
947
961
  {...props}
962
+ onScroll={composed}
948
963
  >
949
964
  {textChildren(children)}
950
965
  </Animated.ScrollView>
@@ -957,7 +972,7 @@ function BottomSheetBody({
957
972
  */
958
973
  if (!surface) return body;
959
974
  return <GestureDetector gesture={surface.scrollGesture}>{body}</GestureDetector>;
960
- }
975
+ });
961
976
  BottomSheetBody.displayName = 'BottomSheet.Body';
962
977
 
963
978
  export interface BottomSheetFooterProps extends ViewProps {