panelui-native 0.78.0 → 0.79.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 (64) hide show
  1. package/lib/module/components/accordion/index.js +13 -1
  2. package/lib/module/components/accordion/index.js.map +1 -1
  3. package/lib/module/components/map/index.js +63 -39
  4. package/lib/module/components/map/index.js.map +1 -1
  5. package/lib/module/components/map/map-children.js +54 -0
  6. package/lib/module/components/map/map-children.js.map +1 -0
  7. package/lib/module/components/map/maplibre.js +31 -3
  8. package/lib/module/components/map/maplibre.js.map +1 -1
  9. package/lib/module/components/markdown-editor/index.js +288 -34
  10. package/lib/module/components/markdown-editor/index.js.map +1 -1
  11. package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
  12. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
  13. package/lib/module/components/marquee/index.js +119 -46
  14. package/lib/module/components/marquee/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +162 -34
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/progress-button/index.js +279 -52
  18. package/lib/module/components/progress-button/index.js.map +1 -1
  19. package/lib/module/components/progress-button/progress-button-hold.js +40 -6
  20. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -1
  21. package/lib/module/components/theme-selector/index.js +239 -0
  22. package/lib/module/components/theme-selector/index.js.map +1 -0
  23. package/lib/module/components/theme-selector/theme-preview.js +320 -0
  24. package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
  25. package/lib/module/index.js +1 -0
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/map/index.d.ts +8 -4
  29. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  30. package/lib/typescript/src/components/map/map-children.d.ts +41 -0
  31. package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
  32. package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
  33. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  34. package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
  35. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
  37. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -1
  38. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/panelside/index.d.ts +79 -4
  40. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/progress-button/index.d.ts +29 -13
  42. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +26 -6
  44. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -1
  45. package/lib/typescript/src/components/theme-selector/index.d.ts +107 -0
  46. package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
  47. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
  48. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
  49. package/lib/typescript/src/index.d.ts +4 -3
  50. package/lib/typescript/src/index.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/components/accordion/index.tsx +13 -0
  53. package/src/components/map/index.tsx +63 -36
  54. package/src/components/map/map-children.ts +66 -0
  55. package/src/components/map/maplibre.ts +32 -4
  56. package/src/components/markdown-editor/index.tsx +413 -35
  57. package/src/components/markdown-editor/markdown-transforms.ts +274 -10
  58. package/src/components/marquee/index.tsx +128 -55
  59. package/src/components/panelside/index.tsx +179 -40
  60. package/src/components/progress-button/index.tsx +264 -45
  61. package/src/components/progress-button/progress-button-hold.ts +37 -6
  62. package/src/components/theme-selector/index.tsx +281 -0
  63. package/src/components/theme-selector/theme-preview.tsx +205 -0
  64. package/src/index.ts +15 -0
@@ -41,8 +41,10 @@
41
41
  * is a broken button — so this is a coarser indicator, not the absence of one.
42
42
  */
43
43
  import {
44
+ Children,
44
45
  createContext,
45
46
  forwardRef,
47
+ isValidElement,
46
48
  useCallback,
47
49
  useContext,
48
50
  useEffect,
@@ -62,20 +64,23 @@ import Animated, {
62
64
  Easing,
63
65
  cancelAnimation,
64
66
  runOnJS,
67
+ runOnUI,
65
68
  useAnimatedReaction,
66
69
  useAnimatedStyle,
67
70
  useReducedMotion,
68
71
  useSharedValue,
72
+ withSpring,
69
73
  withTiming,
70
74
  type SharedValue,
71
75
  } from 'react-native-reanimated';
72
76
  import { tv, type VariantProps } from 'tailwind-variants';
77
+ import { useCSSVariable } from 'uniwind';
78
+ import { CheckIcon, IconColorProvider } from '../../icons';
73
79
  import { Text, textChildren } from '../../primitives/text';
74
- import { cn } from '../../utils/cn';
75
80
  import { impactKnock, selectionTick } from '../../utils/haptics';
76
81
  import {
77
82
  DEFAULT_AUTO_RESET_DELAY,
78
- DEFAULT_RELEASE_DURATION,
83
+ fillDuration,
79
84
  releaseDuration,
80
85
  resolveHoldDuration,
81
86
  } from './progress-button-hold';
@@ -83,6 +88,32 @@ import {
83
88
  /** How many steps the reduced-motion fill advances in. */
84
89
  const REDUCED_STEPS = 5;
85
90
 
91
+ /**
92
+ * The arrival of the completed drawing.
93
+ *
94
+ * A spring rather than a timing, and slightly overshooting: the fill has just
95
+ * spent two seconds moving at a constant rate, and something that lands with a
96
+ * little weight is what tells the reader the waiting part is over.
97
+ */
98
+ const DONE_SPRING = { damping: 14, stiffness: 220, mass: 0.6 } as const;
99
+
100
+ /** How long the completed drawing takes to leave again on a reset. */
101
+ const DONE_EXIT = 140;
102
+
103
+ /**
104
+ * Which token the tick is drawn in, per variant.
105
+ *
106
+ * It sits on the finished fill, so it takes that fill's own foreground rather
107
+ * than the button's — the same pairing `fillLabel` uses, which is what keeps
108
+ * the contrast right in both themes without a hardcoded colour.
109
+ */
110
+ const DONE_TINT = {
111
+ primary: '--color-primary-foreground',
112
+ secondary: '--color-background',
113
+ destructive: '--color-destructive-solid-foreground',
114
+ success: '--color-success-solid-foreground',
115
+ } as const;
116
+
86
117
  const progressButtonVariants = tv({
87
118
  slots: {
88
119
  /*
@@ -94,7 +125,22 @@ const progressButtonVariants = tv({
94
125
  * a rectangle sliding out from under the button rather than as the button
95
126
  * filling up.
96
127
  */
97
- root: 'relative overflow-hidden rounded-full border',
128
+ /*
129
+ * Every variant rests on the same secondary surface, and `variant` decides
130
+ * only what colour comes across it.
131
+ *
132
+ * Drawn as outlines they were four different buttons before anything had
133
+ * happened, and the one thing they all do — wait to be held — was the
134
+ * thing the drawing did not say. A solid ground says it: the button is
135
+ * unfilled, and the fill is what the hold produces.
136
+ *
137
+ * The variant is still legible at rest, because the label carries its
138
+ * colour. That is the half worth keeping — a destructive hold should not
139
+ * look like an ordinary one before it is touched — and it is also the half
140
+ * that survives the wipe, since the fill covers the ground the label was
141
+ * standing on and the second copy takes over.
142
+ */
143
+ root: 'relative overflow-hidden rounded-full border border-transparent bg-secondary',
98
144
  /*
99
145
  * The row inside the button. It is separate from `root` because the fill
100
146
  * has to sit over the whole button including its padding — a wipe that
@@ -110,25 +156,21 @@ const progressButtonVariants = tv({
110
156
  variants: {
111
157
  variant: {
112
158
  primary: {
113
- root: 'border-primary bg-transparent',
114
159
  label: 'text-primary',
115
160
  fill: 'bg-primary',
116
161
  fillLabel: 'text-primary-foreground',
117
162
  },
118
163
  secondary: {
119
- root: 'border-transparent bg-secondary',
120
164
  label: 'text-secondary-foreground',
121
165
  fill: 'bg-foreground',
122
166
  fillLabel: 'text-background',
123
167
  },
124
168
  destructive: {
125
- root: 'border-destructive bg-transparent',
126
169
  label: 'text-destructive',
127
170
  fill: 'bg-destructive',
128
171
  fillLabel: 'text-destructive-solid-foreground',
129
172
  },
130
173
  success: {
131
- root: 'border-success bg-transparent',
132
174
  label: 'text-success',
133
175
  fill: 'bg-success',
134
176
  fillLabel: 'text-success-solid-foreground',
@@ -183,6 +225,12 @@ export type ProgressButtonSize = NonNullable<ProgressButtonVariantProps['size']>
183
225
  interface ProgressButtonContextValue {
184
226
  /** `0` to `1` across the hold. */
185
227
  progress: SharedValue<number>;
228
+ /**
229
+ * `0` to `1` across the arrival of the completed drawing. Separate from
230
+ * `progress` because the fill has finished by the time this starts, and the
231
+ * two would otherwise have to share one clock running at two speeds.
232
+ */
233
+ done: SharedValue<number>;
186
234
  /** Width of the button, so the fill's copy of the label can match it. */
187
235
  width: number;
188
236
  completed: boolean;
@@ -224,6 +272,10 @@ export interface ProgressButtonProps
224
272
  autoReset?: boolean;
225
273
  /** Milliseconds to stay completed before resetting. Defaults to `1000`. */
226
274
  autoResetDelay?: number;
275
+ /**
276
+ * Dim the button and refuse the hold outright. The fill never starts, so
277
+ * there is no half-finished state to explain.
278
+ */
227
279
  disabled?: boolean;
228
280
  /**
229
281
  * A tick as the hold takes, and a knock when it completes. Off by default:
@@ -258,6 +310,7 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
258
310
  const reducedMotion = useReducedMotion();
259
311
 
260
312
  const progress = useSharedValue(0);
313
+ const done = useSharedValue(0);
261
314
  const [width, setWidth] = useState(0);
262
315
  const [internalCompleted, setInternalCompleted] = useState(false);
263
316
  const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -265,7 +318,19 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
265
318
  const isControlled = completedProp !== undefined;
266
319
  const completed = isControlled ? completedProp : internalCompleted;
267
320
 
321
+ /*
322
+ * The reaction below watches a shared value, and a shared value can reach the
323
+ * end for reasons other than a hold — a controlled button being told it is
324
+ * complete fills instantly. Read from a ref rather than from `completed` so
325
+ * the guard sees the current answer without the reaction being rebuilt.
326
+ */
327
+ const completedRef = useRef(completed);
328
+ completedRef.current = completed;
329
+
268
330
  const finish = useCallback(() => {
331
+ // Already complete: the fill was set from outside, and firing the action
332
+ // again would run it a second time for something the reader did not do.
333
+ if (completedRef.current) return;
269
334
  if (!isControlled) setInternalCompleted(true);
270
335
  onCompletedChange?.(true);
271
336
  onComplete?.();
@@ -288,45 +353,91 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
288
353
  }
289
354
  );
290
355
 
356
+ /*
357
+ * Both directions are started on the UI thread, and that is not a detail.
358
+ *
359
+ * A shared value animated on the UI thread does not report back to
360
+ * JavaScript, so `progress.value` read from a press handler is the value
361
+ * from before the hold began — zero. The release computed from it either did
362
+ * nothing or, worse, `cancelAnimation` wrote that stale zero back and the
363
+ * fill vanished on touch-up instead of travelling home.
364
+ *
365
+ * `runOnUI` puts the read where the value actually lives.
366
+ */
291
367
  const begin = useCallback(() => {
292
368
  if (disabled || completed) return;
293
369
  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
- */
370
+ runOnUI(() => {
371
+ 'worklet';
372
+ cancelAnimation(progress);
373
+ const from = progress.value;
374
+ if (from >= 1) return;
302
375
  progress.value = withTiming(1, {
303
- duration,
304
- easing: Easing.steps(REDUCED_STEPS, true),
376
+ // The distance still ahead, at the fill's own rate — so a press that
377
+ // catches the fill on its way back carries on from there rather than
378
+ // restarting the clock.
379
+ duration: fillDuration(from, duration),
380
+ /*
381
+ * Linear. A fill is constant motion, and an eased one misreports the
382
+ * wait: it races the first half and crawls the second, or the reverse.
383
+ *
384
+ * Under reduced motion it steps instead, and is still a real
385
+ * indicator — what that setting is about is continuous movement, not
386
+ * the button saying how much longer to wait. Take that away and the
387
+ * control asks for a hold with nothing on screen to say why.
388
+ */
389
+ easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear,
305
390
  });
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 });
391
+ })();
311
392
  }, [disabled, completed, haptics, progress, reducedMotion, duration]);
312
393
 
394
+ /*
395
+ * The fill, played backwards.
396
+ *
397
+ * Same rate, same easing, same stepping under reduced motion — only the
398
+ * direction differs. Let go at nine tenths of a two-second hold and the fill
399
+ * takes 1.8 seconds to travel home, which is the 1.8 seconds it took to get
400
+ * there. A fill that vanishes has been deleted; a fill that travels back has
401
+ * been let go, and telling those apart is the whole reason the wait is drawn
402
+ * on the button.
403
+ *
404
+ * Every path that empties the fill goes through here — a hold let go, an
405
+ * `autoReset` landing, a controlled button told it is no longer complete.
406
+ * They were three separate assignments and two of them snapped.
407
+ */
408
+ const rewind = useCallback(() => {
409
+ runOnUI(() => {
410
+ 'worklet';
411
+ cancelAnimation(progress);
412
+ const from = progress.value;
413
+ if (from <= 0) {
414
+ progress.value = 0;
415
+ return;
416
+ }
417
+ progress.value = withTiming(0, {
418
+ duration: releaseDuration(from, duration),
419
+ easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear,
420
+ });
421
+ })();
422
+ }, [progress, duration, reducedMotion]);
423
+
313
424
  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]);
425
+ /*
426
+ * A completed hold has nothing to abandon. Without this the fill drains on
427
+ * the release that follows a successful hold — the button empties, looks
428
+ * untouched, and then refuses every press after it, because `begin` bails
429
+ * on a completed button. That combination is a control that has silently
430
+ * stopped working.
431
+ */
432
+ if (completedRef.current) return;
433
+ rewind();
434
+ }, [rewind]);
323
435
 
324
436
  const reset = useCallback(() => {
325
- cancelAnimation(progress);
326
- progress.value = 0;
437
+ rewind();
327
438
  if (!isControlled) setInternalCompleted(false);
328
439
  onCompletedChange?.(false);
329
- }, [progress, isControlled, onCompletedChange]);
440
+ }, [rewind, isControlled, onCompletedChange]);
330
441
 
331
442
  useEffect(() => {
332
443
  if (!completed || !autoReset) return;
@@ -337,19 +448,42 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
337
448
  };
338
449
  }, [completed, autoReset, autoResetDelay, reset]);
339
450
 
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.
451
+ /*
452
+ * The fill follows the completed state, in both directions.
453
+ *
454
+ * A controlled button told it is complete fills without a hold, and one told
455
+ * it is no longer complete empties — otherwise the next hold would start from
456
+ * a bar that is already full.
457
+ */
458
+ useEffect(() => {
459
+ if (completed) {
460
+ cancelAnimation(progress);
461
+ progress.value = 1;
462
+ return;
463
+ }
464
+ rewind();
465
+ }, [completed, progress, rewind]);
466
+
467
+ /*
468
+ * And the completed drawing follows it too. Under reduced motion it is a
469
+ * swap: what that setting is about is movement, and a tick that grows is
470
+ * movement with nothing to report.
471
+ */
342
472
  useEffect(() => {
343
- if (!completed) return;
344
- progress.value = 1;
345
- }, [completed, progress]);
473
+ if (reducedMotion) {
474
+ done.value = completed ? 1 : 0;
475
+ return;
476
+ }
477
+ done.value = completed ? withSpring(1, DONE_SPRING) : withTiming(0, { duration: DONE_EXIT });
478
+ }, [completed, done, reducedMotion]);
346
479
 
347
480
  useEffect(
348
481
  () => () => {
349
482
  cancelAnimation(progress);
483
+ cancelAnimation(done);
350
484
  if (resetTimer.current) clearTimeout(resetTimer.current);
351
485
  },
352
- [progress]
486
+ [progress, done]
353
487
  );
354
488
 
355
489
  const onLayout = (event: LayoutChangeEvent) => {
@@ -358,14 +492,32 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
358
492
  props.onLayout?.(event);
359
493
  };
360
494
 
495
+ /*
496
+ * `slots` is rebuilt every render, so it is deliberately not a dependency —
497
+ * including it would make this memo re-run every time and hand every consumer
498
+ * a new object for no change. The variant and size it is derived from are the
499
+ * dependencies instead.
500
+ */
361
501
  const context = useMemo<ProgressButtonContextValue>(
362
- () => ({ progress, width, completed, variant, size, slots }),
363
- [progress, width, completed, variant, size, slots]
502
+ () => ({ progress, done, width, completed, variant, size, slots }),
503
+ // eslint-disable-next-line react-hooks/exhaustive-deps
504
+ [progress, done, width, completed, variant, size]
364
505
  );
365
506
 
366
- const body = children ?? (
507
+ /*
508
+ * The completed drawing is part of the button rather than something every
509
+ * call site has to remember, so one is added unless the caller wrote their
510
+ * own. A hold that lands and shows nothing is the reader wondering whether it
511
+ * worked, which is the thing this control exists to remove.
512
+ */
513
+ const written = Children.toArray(children);
514
+ const hasDone = written.some(
515
+ (child) => isValidElement(child) && child.type === ProgressButtonDone
516
+ );
517
+ const body = (
367
518
  <>
368
- <ProgressButtonLabel>Hold to confirm</ProgressButtonLabel>
519
+ {written.length > 0 ? written : <ProgressButtonLabel>Hold to confirm</ProgressButtonLabel>}
520
+ {hasDone ? null : <ProgressButtonDone />}
369
521
  </>
370
522
  );
371
523
 
@@ -387,12 +539,18 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
387
539
  checked: completed,
388
540
  }}
389
541
  disabled={disabled}
542
+ /*
543
+ * The spread sits above the hold rather than below it. Underneath, a
544
+ * caller passing `onPressIn` — reasonably, to log a tap — would replace
545
+ * the gesture the whole control is, and the button would simply never
546
+ * fill.
547
+ */
548
+ {...props}
390
549
  onPressIn={begin}
391
550
  onPressOut={abandon}
392
551
  // A few points of drift should not abandon a hold the reader is
393
552
  // still making. Fingers move.
394
553
  pressRetentionOffset={16}
395
- {...props}
396
554
  onLayout={onLayout}
397
555
  className={slots.root({ className })}
398
556
  >
@@ -449,16 +607,77 @@ function ProgressButtonLabel({ className, children, ...props }: ProgressButtonLa
449
607
  }
450
608
  ProgressButtonLabel.displayName = 'ProgressButton.Label';
451
609
 
610
+ export interface ProgressButtonDoneProps extends ViewProps {
611
+ className?: string;
612
+ /**
613
+ * What the button shows once the hold has landed. A tick on its own by
614
+ * default; children replace it, so a word beside one is
615
+ * `<ProgressButton.Done><CheckIcon /><Text>Paid</Text></ProgressButton.Done>`.
616
+ */
617
+ children?: ReactNode;
618
+ }
619
+
620
+ /**
621
+ * The drawing the button lands on.
622
+ *
623
+ * It sits over the finished fill rather than beside the label, so the button
624
+ * does not change width at the moment it completes — a control that resizes as
625
+ * it succeeds moves everything under it, and the reader's eye is on the button.
626
+ *
627
+ * One is added for you unless you write your own, because a hold that lands and
628
+ * shows nothing leaves the reader checking whether it worked.
629
+ */
630
+ function ProgressButtonDone({ className, children, ...props }: ProgressButtonDoneProps) {
631
+ const { done, completed, variant, slots } = useProgressButtonContext('ProgressButton.Done');
632
+ const tint = useCSSVariable(DONE_TINT[variant]);
633
+
634
+ /*
635
+ * It carries the fill's own colour and covers the button edge to edge.
636
+ *
637
+ * The alternative — fading the label out from under it — takes the fill with
638
+ * it, because the fill is drawn inside the label so that the two copies of
639
+ * the text line up. The button would empty at the exact moment it succeeded,
640
+ * and the tick, drawn in the colour that reads against a full fill, would be
641
+ * left standing on nothing.
642
+ */
643
+ const style = useAnimatedStyle(() => ({ opacity: done.value }));
644
+ // The tick arrives from slightly under full size. The ground it lands on
645
+ // does not: a background box that grows reads as the button resizing.
646
+ const markStyle = useAnimatedStyle(() => ({ transform: [{ scale: 0.7 + done.value * 0.3 }] }));
647
+
648
+ return (
649
+ <Animated.View
650
+ {...props}
651
+ // Never takes the press: the button underneath still owns it, which is
652
+ // what lets a completed button be reset and held again.
653
+ pointerEvents="none"
654
+ accessibilityElementsHidden={!completed}
655
+ importantForAccessibility={completed ? 'auto' : 'no-hide-descendants'}
656
+ style={[{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }, style]}
657
+ className={slots.fill({ className: slots.content({ className }) })}
658
+ >
659
+ <Animated.View style={markStyle} className={slots.content()}>
660
+ <IconColorProvider color={typeof tint === 'string' ? tint : undefined}>
661
+ {children ?? <CheckIcon size={18} />}
662
+ </IconColorProvider>
663
+ </Animated.View>
664
+ </Animated.View>
665
+ );
666
+ }
667
+ ProgressButtonDone.displayName = 'ProgressButton.Done';
668
+
452
669
  ProgressButtonRoot.displayName = 'ProgressButton';
453
670
 
454
671
  export const ProgressButton = Object.assign(ProgressButtonRoot, {
455
672
  Label: ProgressButtonLabel,
673
+ Done: ProgressButtonDone,
456
674
  });
457
675
 
458
676
  export {
459
677
  DEFAULT_AUTO_RESET_DELAY,
460
678
  DEFAULT_HOLD_DURATION,
461
679
  DEFAULT_RELEASE_DURATION,
680
+ fillDuration,
462
681
  isComplete,
463
682
  releaseDuration,
464
683
  resolveHoldDuration,
@@ -8,8 +8,15 @@
8
8
  /** Milliseconds a hold has to be sustained before it counts. */
9
9
  export const DEFAULT_HOLD_DURATION = 2000;
10
10
 
11
- /** Milliseconds the fill takes to drain when a hold is abandoned. */
12
- export const DEFAULT_RELEASE_DURATION = 240;
11
+ /**
12
+ * Milliseconds a complete fill takes to rewind, when the hold length is not
13
+ * known — the default only exists so {@link releaseDuration} has one.
14
+ *
15
+ * The component always passes the hold's own duration instead: the drain is
16
+ * the fill running backwards at the same rate, so a two-second hold takes two
17
+ * seconds to give back.
18
+ */
19
+ export const DEFAULT_RELEASE_DURATION = DEFAULT_HOLD_DURATION;
13
20
 
14
21
  /** Milliseconds before an `autoReset` button offers itself again. */
15
22
  export const DEFAULT_AUTO_RESET_DELAY = 1000;
@@ -31,15 +38,24 @@ export function resolveHoldDuration(duration: number | undefined): number {
31
38
  /**
32
39
  * How long the fill should take to drain from where it is.
33
40
  *
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.
41
+ * The fill, run backwards. `full` is the hold's own duration, so the drain
42
+ * covers the distance left at exactly the rate it was filled at: let go at
43
+ * nine tenths of a two-second hold and it takes 1.8 seconds to give back, the
44
+ * same 1.8 seconds it took to earn.
45
+ *
46
+ * That is the point of it. The wait was drawn on the button, so undoing the
47
+ * wait is worth drawing too — a fill that vanishes has been deleted, and a
48
+ * fill that travels back has been let go.
38
49
  */
39
50
  export function releaseDuration(
40
51
  progress: number,
41
52
  full: number = DEFAULT_RELEASE_DURATION
42
53
  ): number {
54
+ // A worklet, because the only runtime that knows how far the fill actually
55
+ // got is the one animating it. Reading a shared value from JavaScript gives
56
+ // the value before the animation started, so the release has to be started
57
+ // from the UI thread — and that means this has to run there too.
58
+ 'worklet';
43
59
  const travelled = Math.min(1, Math.max(0, progress));
44
60
  return Math.max(80, full * travelled);
45
61
  }
@@ -53,5 +69,20 @@ export function releaseDuration(
53
69
  * button sometimes fires when the reader let go early on purpose.
54
70
  */
55
71
  export function isComplete(progress: number): boolean {
72
+ 'worklet';
56
73
  return progress >= 1;
57
74
  }
75
+
76
+ /**
77
+ * How long it takes to cover the distance still ahead, at the fill's own rate.
78
+ *
79
+ * A press that arrives while the fill is on its way back should carry on from
80
+ * where it is rather than restarting the clock — otherwise a fill picked up at
81
+ * halfway takes the whole hold to cover the half that is left, and the second
82
+ * attempt is twice as slow as the first for no reason the reader can see.
83
+ */
84
+ export function fillDuration(from: number, full: number): number {
85
+ 'worklet';
86
+ const remaining = Math.min(1, Math.max(0, 1 - from));
87
+ return Math.max(80, full * remaining);
88
+ }