panelui-native 0.77.0 → 0.78.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 (102) hide show
  1. package/lib/module/components/area-chart/index.js +32 -25
  2. package/lib/module/components/area-chart/index.js.map +1 -1
  3. package/lib/module/components/bottom-sheet/index.js +97 -15
  4. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  5. package/lib/module/components/funnel-chart/index.js +15 -2
  6. package/lib/module/components/funnel-chart/index.js.map +1 -1
  7. package/lib/module/components/heatmap-chart/index.js +19 -27
  8. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  9. package/lib/module/components/hex-chart/index.js +2 -0
  10. package/lib/module/components/hex-chart/index.js.map +1 -1
  11. package/lib/module/components/line-chart/index.js +38 -31
  12. package/lib/module/components/line-chart/index.js.map +1 -1
  13. package/lib/module/components/marquee/index.js +91 -5
  14. package/lib/module/components/marquee/index.js.map +1 -1
  15. package/lib/module/components/pie-chart/index.js +15 -2
  16. package/lib/module/components/pie-chart/index.js.map +1 -1
  17. package/lib/module/components/plot/index.js +42 -44
  18. package/lib/module/components/plot/index.js.map +1 -1
  19. package/lib/module/components/progress-button/index.js +390 -0
  20. package/lib/module/components/progress-button/index.js.map +1 -0
  21. package/lib/module/components/progress-button/progress-button-hold.js +57 -0
  22. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -0
  23. package/lib/module/components/scatter-chart/index.js +6 -25
  24. package/lib/module/components/scatter-chart/index.js.map +1 -1
  25. package/lib/module/components/section-rail/index.js +40 -17
  26. package/lib/module/components/section-rail/index.js.map +1 -1
  27. package/lib/module/components/split-view/index.js +54 -10
  28. package/lib/module/components/split-view/index.js.map +1 -1
  29. package/lib/module/components/timeline/index.js +145 -14
  30. package/lib/module/components/timeline/index.js.map +1 -1
  31. package/lib/module/components/toast/index.js +45 -11
  32. package/lib/module/components/toast/index.js.map +1 -1
  33. package/lib/module/components/treemap-chart/index.js +63 -6
  34. package/lib/module/components/treemap-chart/index.js.map +1 -1
  35. package/lib/module/components/waterfall-chart/index.js +20 -4
  36. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  37. package/lib/module/hooks/index.js +1 -0
  38. package/lib/module/hooks/index.js.map +1 -1
  39. package/lib/module/hooks/use-scroll-sections.js +44 -2
  40. package/lib/module/hooks/use-scroll-sections.js.map +1 -1
  41. package/lib/module/hooks/use-skeleton-handoff.js +58 -0
  42. package/lib/module/hooks/use-skeleton-handoff.js.map +1 -0
  43. package/lib/module/index.js +1 -0
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/typescript/src/components/area-chart/index.d.ts +1 -1
  46. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  51. package/lib/typescript/src/components/line-chart/index.d.ts +1 -1
  52. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  53. package/lib/typescript/src/components/marquee/index.d.ts +55 -2
  54. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  55. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/plot/index.d.ts +1 -3
  57. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/progress-button/index.d.ts +295 -0
  59. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -0
  60. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +40 -0
  61. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -0
  62. package/lib/typescript/src/components/scatter-chart/index.d.ts +1 -1
  63. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
  64. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/split-view/index.d.ts +10 -1
  66. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/timeline/index.d.ts +7 -0
  68. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  72. package/lib/typescript/src/hooks/index.d.ts +1 -0
  73. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  74. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +1 -0
  75. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
  76. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts +11 -0
  77. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts.map +1 -0
  78. package/lib/typescript/src/index.d.ts +3 -2
  79. package/lib/typescript/src/index.d.ts.map +1 -1
  80. package/package.json +1 -1
  81. package/src/components/area-chart/index.tsx +49 -37
  82. package/src/components/bottom-sheet/index.tsx +109 -11
  83. package/src/components/funnel-chart/index.tsx +10 -2
  84. package/src/components/heatmap-chart/index.tsx +27 -22
  85. package/src/components/hex-chart/index.tsx +22 -6
  86. package/src/components/line-chart/index.tsx +57 -43
  87. package/src/components/marquee/index.tsx +129 -6
  88. package/src/components/pie-chart/index.tsx +10 -2
  89. package/src/components/plot/index.tsx +66 -50
  90. package/src/components/progress-button/index.tsx +465 -0
  91. package/src/components/progress-button/progress-button-hold.ts +57 -0
  92. package/src/components/scatter-chart/index.tsx +4 -25
  93. package/src/components/section-rail/index.tsx +39 -19
  94. package/src/components/split-view/index.tsx +53 -8
  95. package/src/components/timeline/index.tsx +143 -17
  96. package/src/components/toast/index.tsx +60 -11
  97. package/src/components/treemap-chart/index.tsx +65 -8
  98. package/src/components/waterfall-chart/index.tsx +14 -4
  99. package/src/hooks/index.ts +5 -0
  100. package/src/hooks/use-scroll-sections.ts +52 -3
  101. package/src/hooks/use-skeleton-handoff.ts +69 -0
  102. package/src/index.ts +10 -0
@@ -0,0 +1,465 @@
1
+ /**
2
+ * ProgressButton — press and hold to confirm, with the wait drawn on the button.
3
+ *
4
+ * ```tsx
5
+ * <ProgressButton onComplete={wipe}>
6
+ * <ProgressButton.Label>Hold to erase</ProgressButton.Label>
7
+ * </ProgressButton>
8
+ * ```
9
+ *
10
+ * For the action a confirmation dialog exists to slow down. A dialog asks the
11
+ * question somewhere else and takes the answer as a tap — which is two taps,
12
+ * and two taps in a row is a rhythm a hand falls into. A hold cannot be
13
+ * completed by accident and cannot be completed by habit: it has to be
14
+ * sustained, and the fill says for how much longer.
15
+ *
16
+ * ## The fill is the promise
17
+ *
18
+ * Nothing fires until the fill reaches the end. There is no tolerance near the
19
+ * top, because a tolerance means the button sometimes commits after the reader
20
+ * has deliberately let go — which is the one failure a confirmation control
21
+ * cannot have. Released early, the fill drains back, in proportion to how far
22
+ * it got.
23
+ *
24
+ * ## How it is drawn
25
+ *
26
+ * The fill is a clipped copy of the button in inverted colours, growing from
27
+ * the leading edge. That is what keeps the label legible across the boundary:
28
+ * a single label under a translucent wash goes muddy in the middle of the
29
+ * wipe, exactly where the eye is. Two labels, each at full contrast on its own
30
+ * ground, never do.
31
+ *
32
+ * The clip is a view under `overflow: 'hidden'`, and its width is animated.
33
+ * Animating a width is normally a layout pass per frame; this view is
34
+ * absolutely positioned over the button and its copy of the label is pinned to
35
+ * the button's own width, so nothing it contains reflows as it grows.
36
+ *
37
+ * ## Reduced motion
38
+ *
39
+ * The wipe is replaced by a stepped fill that advances in fifths. The hold
40
+ * still has to be legible — a control that asks you to wait and shows nothing
41
+ * is a broken button — so this is a coarser indicator, not the absence of one.
42
+ */
43
+ import {
44
+ createContext,
45
+ forwardRef,
46
+ useCallback,
47
+ useContext,
48
+ useEffect,
49
+ useMemo,
50
+ useRef,
51
+ useState,
52
+ type ReactNode,
53
+ } from 'react';
54
+ import {
55
+ Pressable,
56
+ View,
57
+ type LayoutChangeEvent,
58
+ type PressableProps,
59
+ type ViewProps,
60
+ } from 'react-native';
61
+ import Animated, {
62
+ Easing,
63
+ cancelAnimation,
64
+ runOnJS,
65
+ useAnimatedReaction,
66
+ useAnimatedStyle,
67
+ useReducedMotion,
68
+ useSharedValue,
69
+ withTiming,
70
+ type SharedValue,
71
+ } from 'react-native-reanimated';
72
+ import { tv, type VariantProps } from 'tailwind-variants';
73
+ import { Text, textChildren } from '../../primitives/text';
74
+ import { cn } from '../../utils/cn';
75
+ import { impactKnock, selectionTick } from '../../utils/haptics';
76
+ import {
77
+ DEFAULT_AUTO_RESET_DELAY,
78
+ DEFAULT_RELEASE_DURATION,
79
+ releaseDuration,
80
+ resolveHoldDuration,
81
+ } from './progress-button-hold';
82
+
83
+ /** How many steps the reduced-motion fill advances in. */
84
+ const REDUCED_STEPS = 5;
85
+
86
+ const progressButtonVariants = tv({
87
+ slots: {
88
+ /*
89
+ * A pill, not the `rounded-lg` the other buttons take.
90
+ *
91
+ * The fill is clipped by this radius, so the shape of the button is also
92
+ * the shape of the wipe's leading edge as it comes out of the corner. On a
93
+ * small radius that edge emerges square from a rounded box, which reads as
94
+ * a rectangle sliding out from under the button rather than as the button
95
+ * filling up.
96
+ */
97
+ root: 'relative overflow-hidden rounded-full border',
98
+ /*
99
+ * The row inside the button. It is separate from `root` because the fill
100
+ * has to sit over the whole button including its padding — a wipe that
101
+ * stops at the text's own box leaves an unfilled margin down each side and
102
+ * reads as a progress bar someone put inside a button.
103
+ */
104
+ content: 'flex-row items-center justify-center gap-2',
105
+ label: 'min-w-0 shrink text-center font-medium',
106
+ /** The inverted copy, drawn on the filled ground. */
107
+ fill: '',
108
+ fillLabel: 'min-w-0 shrink text-center font-medium',
109
+ },
110
+ variants: {
111
+ variant: {
112
+ primary: {
113
+ root: 'border-primary bg-transparent',
114
+ label: 'text-primary',
115
+ fill: 'bg-primary',
116
+ fillLabel: 'text-primary-foreground',
117
+ },
118
+ secondary: {
119
+ root: 'border-transparent bg-secondary',
120
+ label: 'text-secondary-foreground',
121
+ fill: 'bg-foreground',
122
+ fillLabel: 'text-background',
123
+ },
124
+ destructive: {
125
+ root: 'border-destructive bg-transparent',
126
+ label: 'text-destructive',
127
+ fill: 'bg-destructive',
128
+ fillLabel: 'text-destructive-solid-foreground',
129
+ },
130
+ success: {
131
+ root: 'border-success bg-transparent',
132
+ label: 'text-success',
133
+ fill: 'bg-success',
134
+ fillLabel: 'text-success-solid-foreground',
135
+ },
136
+ },
137
+ size: {
138
+ // Matched to Button's boxes, and `min-h-*` for the same reason: the
139
+ // label's glyphs grow with the system text size and the box has to grow
140
+ // with them.
141
+ // Wider than the equivalent Button, because the corner is a half-circle
142
+ // rather than a small radius: the curve eats into the side padding, and
143
+ // at Button's values the first and last glyphs sit against it.
144
+ sm: {
145
+ root: 'min-h-9',
146
+ content: 'min-h-9 gap-1.5 px-3.5 py-2',
147
+ label: 'text-[14px]',
148
+ fillLabel: 'text-[14px]',
149
+ },
150
+ md: {
151
+ root: 'min-h-11',
152
+ content: 'min-h-11 px-5 py-2.5',
153
+ label: 'text-[16px]',
154
+ fillLabel: 'text-[16px]',
155
+ },
156
+ lg: {
157
+ root: 'min-h-12',
158
+ content: 'min-h-12 px-7 py-2.5',
159
+ label: 'text-[18px]',
160
+ fillLabel: 'text-[18px]',
161
+ },
162
+ },
163
+ fullWidth: {
164
+ true: { root: 'w-full' },
165
+ },
166
+ disabled: {
167
+ true: { root: 'opacity-[0.64]' },
168
+ },
169
+ },
170
+ defaultVariants: {
171
+ variant: 'primary',
172
+ size: 'md',
173
+ },
174
+ });
175
+
176
+ type ProgressButtonVariantProps = VariantProps<typeof progressButtonVariants>;
177
+
178
+ /** How a progress button looks. */
179
+ export type ProgressButtonVariant = NonNullable<ProgressButtonVariantProps['variant']>;
180
+ /** How big a progress button is. */
181
+ export type ProgressButtonSize = NonNullable<ProgressButtonVariantProps['size']>;
182
+
183
+ interface ProgressButtonContextValue {
184
+ /** `0` to `1` across the hold. */
185
+ progress: SharedValue<number>;
186
+ /** Width of the button, so the fill's copy of the label can match it. */
187
+ width: number;
188
+ completed: boolean;
189
+ variant: ProgressButtonVariant;
190
+ size: ProgressButtonSize;
191
+ slots: ReturnType<typeof progressButtonVariants>;
192
+ }
193
+
194
+ const ProgressButtonContext = createContext<ProgressButtonContextValue | null>(null);
195
+
196
+ function useProgressButtonContext(component: string): ProgressButtonContextValue {
197
+ const context = useContext(ProgressButtonContext);
198
+ if (!context) throw new Error(`${component} must be used within a <ProgressButton>`);
199
+ return context;
200
+ }
201
+
202
+ /** How far through the hold this button is, for something rendered inside it. */
203
+ export function useProgressButton() {
204
+ const { progress, completed } = useProgressButtonContext('useProgressButton');
205
+ return { progress, completed };
206
+ }
207
+
208
+ export interface ProgressButtonProps
209
+ extends Omit<PressableProps, 'children' | 'disabled'>,
210
+ Omit<ProgressButtonVariantProps, 'disabled'> {
211
+ className?: string;
212
+ /**
213
+ * Milliseconds the button has to be held. Defaults to `2000`, and is floored
214
+ * at `200` — a hold that completes on touch-down is a button with extra steps.
215
+ */
216
+ holdDuration?: number;
217
+ /** Fires once the hold has been sustained to the end. */
218
+ onComplete?: () => void;
219
+ /** Fires whenever the completed state changes, including on a reset. */
220
+ onCompletedChange?: (completed: boolean) => void;
221
+ /** Controlled completion. Leave unset to let the button own it. */
222
+ completed?: boolean;
223
+ /** Return to the unfilled state after `autoResetDelay`. */
224
+ autoReset?: boolean;
225
+ /** Milliseconds to stay completed before resetting. Defaults to `1000`. */
226
+ autoResetDelay?: number;
227
+ disabled?: boolean;
228
+ /**
229
+ * A tick as the hold takes, and a knock when it completes. Off by default:
230
+ * whether an action is worth feeling is the caller's call, not the control's.
231
+ */
232
+ haptics?: boolean;
233
+ children?: ReactNode;
234
+ }
235
+
236
+ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function ProgressButtonRoot(
237
+ {
238
+ className,
239
+ variant = 'primary',
240
+ size = 'md',
241
+ fullWidth,
242
+ holdDuration,
243
+ onComplete,
244
+ onCompletedChange,
245
+ completed: completedProp,
246
+ autoReset = false,
247
+ autoResetDelay = DEFAULT_AUTO_RESET_DELAY,
248
+ disabled = false,
249
+ haptics = false,
250
+ accessibilityState,
251
+ children,
252
+ ...props
253
+ },
254
+ ref
255
+ ) {
256
+ const slots = progressButtonVariants({ variant, size, fullWidth, disabled });
257
+ const duration = resolveHoldDuration(holdDuration);
258
+ const reducedMotion = useReducedMotion();
259
+
260
+ const progress = useSharedValue(0);
261
+ const [width, setWidth] = useState(0);
262
+ const [internalCompleted, setInternalCompleted] = useState(false);
263
+ const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
264
+
265
+ const isControlled = completedProp !== undefined;
266
+ const completed = isControlled ? completedProp : internalCompleted;
267
+
268
+ const finish = useCallback(() => {
269
+ if (!isControlled) setInternalCompleted(true);
270
+ onCompletedChange?.(true);
271
+ onComplete?.();
272
+ if (haptics) impactKnock();
273
+ }, [isControlled, onComplete, onCompletedChange, haptics]);
274
+
275
+ /*
276
+ * Completion is read off the animation rather than timed alongside it.
277
+ *
278
+ * A `setTimeout` for `holdDuration` and a fill for `holdDuration` are two
279
+ * clocks that agree only while the app is idle: the moment the JS thread is
280
+ * busy the button fires before the fill arrives, or the fill sits full while
281
+ * nothing happens. The reaction fires on the frame the value actually
282
+ * reaches the end, which is the frame the reader saw it get there.
283
+ */
284
+ useAnimatedReaction(
285
+ () => progress.value >= 1,
286
+ (full, was) => {
287
+ if (full && !was) runOnJS(finish)();
288
+ }
289
+ );
290
+
291
+ const begin = useCallback(() => {
292
+ if (disabled || completed) return;
293
+ if (haptics) selectionTick();
294
+ cancelAnimation(progress);
295
+ if (reducedMotion) {
296
+ /*
297
+ * Stepped rather than smooth, and still a real indicator. What reduced
298
+ * motion is about is continuous movement, not the button telling you how
299
+ * much longer to wait — take that away and the control asks for a hold
300
+ * with nothing on screen to say why.
301
+ */
302
+ progress.value = withTiming(1, {
303
+ duration,
304
+ easing: Easing.steps(REDUCED_STEPS, true),
305
+ });
306
+ return;
307
+ }
308
+ // Linear. A fill is constant motion, and an eased one misreports the wait:
309
+ // it races the first half and crawls the second, or the reverse.
310
+ progress.value = withTiming(1, { duration, easing: Easing.linear });
311
+ }, [disabled, completed, haptics, progress, reducedMotion, duration]);
312
+
313
+ const abandon = useCallback(() => {
314
+ cancelAnimation(progress);
315
+ if (progress.value <= 0) return;
316
+ progress.value = withTiming(0, {
317
+ // Proportional to how far it got. A fixed release makes a hold abandoned
318
+ // after a moment feel sticky, which reads as the button resisting.
319
+ duration: releaseDuration(progress.value, DEFAULT_RELEASE_DURATION),
320
+ easing: Easing.out(Easing.cubic),
321
+ });
322
+ }, [progress]);
323
+
324
+ const reset = useCallback(() => {
325
+ cancelAnimation(progress);
326
+ progress.value = 0;
327
+ if (!isControlled) setInternalCompleted(false);
328
+ onCompletedChange?.(false);
329
+ }, [progress, isControlled, onCompletedChange]);
330
+
331
+ useEffect(() => {
332
+ if (!completed || !autoReset) return;
333
+ resetTimer.current = setTimeout(reset, autoResetDelay);
334
+ return () => {
335
+ if (resetTimer.current) clearTimeout(resetTimer.current);
336
+ resetTimer.current = null;
337
+ };
338
+ }, [completed, autoReset, autoResetDelay, reset]);
339
+
340
+ // A controlled button told it is no longer complete has to empty its fill,
341
+ // or the next hold starts from a bar that is already full.
342
+ useEffect(() => {
343
+ if (!completed) return;
344
+ progress.value = 1;
345
+ }, [completed, progress]);
346
+
347
+ useEffect(
348
+ () => () => {
349
+ cancelAnimation(progress);
350
+ if (resetTimer.current) clearTimeout(resetTimer.current);
351
+ },
352
+ [progress]
353
+ );
354
+
355
+ const onLayout = (event: LayoutChangeEvent) => {
356
+ const next = Math.round(event.nativeEvent.layout.width);
357
+ setWidth((current) => (current === next ? current : next));
358
+ props.onLayout?.(event);
359
+ };
360
+
361
+ const context = useMemo<ProgressButtonContextValue>(
362
+ () => ({ progress, width, completed, variant, size, slots }),
363
+ [progress, width, completed, variant, size, slots]
364
+ );
365
+
366
+ const body = children ?? (
367
+ <>
368
+ <ProgressButtonLabel>Hold to confirm</ProgressButtonLabel>
369
+ </>
370
+ );
371
+
372
+ return (
373
+ <ProgressButtonContext.Provider value={context}>
374
+ <Pressable
375
+ ref={ref}
376
+ accessibilityRole="button"
377
+ /*
378
+ * Spoken as what it is. A button whose label says "Hold to confirm"
379
+ * still announces as an ordinary button to anyone who cannot see the
380
+ * fill, and a single activation does nothing — so the hint carries the
381
+ * instruction and the state carries the outcome.
382
+ */
383
+ accessibilityHint="Press and hold to confirm"
384
+ accessibilityState={{
385
+ ...accessibilityState,
386
+ disabled,
387
+ checked: completed,
388
+ }}
389
+ disabled={disabled}
390
+ onPressIn={begin}
391
+ onPressOut={abandon}
392
+ // A few points of drift should not abandon a hold the reader is
393
+ // still making. Fingers move.
394
+ pressRetentionOffset={16}
395
+ {...props}
396
+ onLayout={onLayout}
397
+ className={slots.root({ className })}
398
+ >
399
+ {body}
400
+ </Pressable>
401
+ </ProgressButtonContext.Provider>
402
+ );
403
+ });
404
+
405
+ export interface ProgressButtonLabelProps extends ViewProps {
406
+ className?: string;
407
+ children?: ReactNode;
408
+ }
409
+
410
+ /**
411
+ * What the button says, drawn twice.
412
+ *
413
+ * The second copy is the one inside the fill, in the inverted colour, pinned to
414
+ * the button's own width and clipped to however far the hold has got. Both are
415
+ * the same text at the same position, so the boundary between them falls in the
416
+ * middle of a glyph rather than between two differently laid-out lines.
417
+ */
418
+ function ProgressButtonLabel({ className, children, ...props }: ProgressButtonLabelProps) {
419
+ const { progress, width, slots } = useProgressButtonContext('ProgressButton.Label');
420
+
421
+ const fillStyle = useAnimatedStyle(() => ({ width: width * progress.value }));
422
+
423
+ return (
424
+ <View {...props} className={slots.content({ className })}>
425
+ {textChildren(children, (text) => (
426
+ <Text className={slots.label()}>{text}</Text>
427
+ ))}
428
+
429
+ {/* The wipe. `pointerEvents` off so it never takes the press it is
430
+ drawn on top of, and `width` fixed to the button so the copy inside
431
+ it is laid out exactly where the original is rather than reflowing
432
+ into whatever the clip currently allows. */}
433
+ <Animated.View
434
+ pointerEvents="none"
435
+ style={[
436
+ { position: 'absolute', top: 0, bottom: 0, left: 0, overflow: 'hidden' },
437
+ fillStyle,
438
+ ]}
439
+ className={slots.fill()}
440
+ >
441
+ <View style={{ width }} className={slots.content()}>
442
+ {textChildren(children, (text) => (
443
+ <Text className={slots.fillLabel()}>{text}</Text>
444
+ ))}
445
+ </View>
446
+ </Animated.View>
447
+ </View>
448
+ );
449
+ }
450
+ ProgressButtonLabel.displayName = 'ProgressButton.Label';
451
+
452
+ ProgressButtonRoot.displayName = 'ProgressButton';
453
+
454
+ export const ProgressButton = Object.assign(ProgressButtonRoot, {
455
+ Label: ProgressButtonLabel,
456
+ });
457
+
458
+ export {
459
+ DEFAULT_AUTO_RESET_DELAY,
460
+ DEFAULT_HOLD_DURATION,
461
+ DEFAULT_RELEASE_DURATION,
462
+ isComplete,
463
+ releaseDuration,
464
+ resolveHoldDuration,
465
+ } from './progress-button-hold';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The arithmetic behind a hold, kept out of the component so it can be tested
3
+ * without a renderer — the same split `splitter-math` and `ai-input-growth` use.
4
+ *
5
+ * None of it is worklet-only, so the component can call it from either runtime.
6
+ */
7
+
8
+ /** Milliseconds a hold has to be sustained before it counts. */
9
+ export const DEFAULT_HOLD_DURATION = 2000;
10
+
11
+ /** Milliseconds the fill takes to drain when a hold is abandoned. */
12
+ export const DEFAULT_RELEASE_DURATION = 240;
13
+
14
+ /** Milliseconds before an `autoReset` button offers itself again. */
15
+ export const DEFAULT_AUTO_RESET_DELAY = 1000;
16
+
17
+ /**
18
+ * A hold duration that cannot produce a broken animation.
19
+ *
20
+ * Zero would complete on touch-down, which defeats the point of the control:
21
+ * a hold-to-confirm that confirms on a tap is a button with extra steps. A
22
+ * floor rather than a rejection, because the caller asking for `0` wants it
23
+ * fast, not broken.
24
+ */
25
+ export function resolveHoldDuration(duration: number | undefined): number {
26
+ if (duration === undefined) return DEFAULT_HOLD_DURATION;
27
+ if (!Number.isFinite(duration)) return DEFAULT_HOLD_DURATION;
28
+ return Math.max(200, duration);
29
+ }
30
+
31
+ /**
32
+ * How long the fill should take to drain from where it is.
33
+ *
34
+ * Proportional to how far it got, so abandoning a hold at a tenth of the way
35
+ * does not take the same quarter-second as abandoning it at nine tenths. A
36
+ * fixed release makes a barely-started hold feel sticky, which reads as the
37
+ * control resisting being let go.
38
+ */
39
+ export function releaseDuration(
40
+ progress: number,
41
+ full: number = DEFAULT_RELEASE_DURATION
42
+ ): number {
43
+ const travelled = Math.min(1, Math.max(0, progress));
44
+ return Math.max(80, full * travelled);
45
+ }
46
+
47
+ /**
48
+ * Whether a hold that has been released should still be honoured.
49
+ *
50
+ * The fill is the promise, so anything short of the end is not a confirmation —
51
+ * there is no "close enough" here. It exists as a named function because the
52
+ * tempting version of this rule is a tolerance, and a tolerance means the
53
+ * button sometimes fires when the reader let go early on purpose.
54
+ */
55
+ export function isComplete(progress: number): boolean {
56
+ return progress >= 1;
57
+ }
@@ -70,6 +70,7 @@ import { Text } from '../../primitives/text';
70
70
  import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
71
71
  import { compactNumber, useSeriesColor, xAt, yOf, type Plot } from '../../utils/chart';
72
72
  import { cn } from '../../utils/cn';
73
+ import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
73
74
 
74
75
  const AnimatedCircle = Animated.createAnimatedComponent(Circle);
75
76
  const AnimatedG = Animated.createAnimatedComponent(G);
@@ -81,9 +82,6 @@ const AnimatedG = Animated.createAnimatedComponent(G);
81
82
  */
82
83
  const STAGGER = 0.4;
83
84
 
84
- /** Milliseconds for the placeholder field to dissolve once the data arrives. */
85
- const SKELETON_FADE = 220;
86
-
87
85
  /** Milliseconds for a point to swell as it is selected, and settle as it is not. */
88
86
  const SELECT_DURATION = 140;
89
87
 
@@ -206,7 +204,7 @@ export interface ScatterChartProps extends ViewProps, ChartAccessibilityProps<Sc
206
204
  status?: ScatterChartStatus;
207
205
  /** Width ÷ height. `1` suits a scatter plot: neither axis is the important one. */
208
206
  aspectRatio?: number;
209
- /** Milliseconds for the reveal on mount. */
207
+ /** Milliseconds for the reveal on mount. Defaults to `650`. */
210
208
  animationDuration?: number;
211
209
  /** Milliseconds for the axes to settle after the data changes. */
212
210
  domainDuration?: number;
@@ -240,7 +238,7 @@ const ScatterChartRoot = forwardRef<ScatterChartHandle, ScatterChartProps>(
240
238
  xDataKey = 'x',
241
239
  status = 'ready',
242
240
  aspectRatio = 1,
243
- animationDuration = 900,
241
+ animationDuration = 650,
244
242
  domainDuration = 500,
245
243
  xDomain,
246
244
  yDomain,
@@ -869,27 +867,8 @@ function ScatterChartSkeleton({ count = 24, color }: ScatterChartSkeletonProps)
869
867
  const { plot, status } = useChart('ScatterChart.Skeleton');
870
868
  const token = useCSSVariable('--color-skeleton');
871
869
  const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
872
- const reducedMotion = useReducedMotion();
873
-
874
- const loading = status === 'loading';
875
- const fade = useSharedValue(1);
876
- // Mounted a beat longer than `loading`, so there is something to fade.
877
- const [mounted, setMounted] = useState(loading);
878
870
 
879
- useEffect(() => {
880
- if (loading) {
881
- fade.value = 1;
882
- setMounted(true);
883
- return;
884
- }
885
- if (reducedMotion) {
886
- setMounted(false);
887
- return;
888
- }
889
- fade.value = withTiming(0, { duration: SKELETON_FADE }, (finished) => {
890
- if (finished) runOnJS(setMounted)(false);
891
- });
892
- }, [loading, reducedMotion, fade]);
871
+ const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');
893
872
 
894
873
  const dots = useMemo(() => {
895
874
  // A cheap deterministic scatter: two irrational-ish strides that do not