panelui-native 0.77.0 → 0.79.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 (142) 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/map/index.js +63 -39
  14. package/lib/module/components/map/index.js.map +1 -1
  15. package/lib/module/components/map/map-children.js +54 -0
  16. package/lib/module/components/map/map-children.js.map +1 -0
  17. package/lib/module/components/map/maplibre.js +31 -3
  18. package/lib/module/components/map/maplibre.js.map +1 -1
  19. package/lib/module/components/markdown-editor/index.js +288 -34
  20. package/lib/module/components/markdown-editor/index.js.map +1 -1
  21. package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
  22. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
  23. package/lib/module/components/marquee/index.js +205 -46
  24. package/lib/module/components/marquee/index.js.map +1 -1
  25. package/lib/module/components/panelside/index.js +162 -34
  26. package/lib/module/components/panelside/index.js.map +1 -1
  27. package/lib/module/components/pie-chart/index.js +15 -2
  28. package/lib/module/components/pie-chart/index.js.map +1 -1
  29. package/lib/module/components/plot/index.js +42 -44
  30. package/lib/module/components/plot/index.js.map +1 -1
  31. package/lib/module/components/progress-button/index.js +617 -0
  32. package/lib/module/components/progress-button/index.js.map +1 -0
  33. package/lib/module/components/progress-button/progress-button-hold.js +91 -0
  34. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -0
  35. package/lib/module/components/scatter-chart/index.js +6 -25
  36. package/lib/module/components/scatter-chart/index.js.map +1 -1
  37. package/lib/module/components/section-rail/index.js +40 -17
  38. package/lib/module/components/section-rail/index.js.map +1 -1
  39. package/lib/module/components/split-view/index.js +54 -10
  40. package/lib/module/components/split-view/index.js.map +1 -1
  41. package/lib/module/components/theme-selector/index.js +239 -0
  42. package/lib/module/components/theme-selector/index.js.map +1 -0
  43. package/lib/module/components/theme-selector/theme-preview.js +320 -0
  44. package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
  45. package/lib/module/components/timeline/index.js +145 -14
  46. package/lib/module/components/timeline/index.js.map +1 -1
  47. package/lib/module/components/toast/index.js +45 -11
  48. package/lib/module/components/toast/index.js.map +1 -1
  49. package/lib/module/components/treemap-chart/index.js +63 -6
  50. package/lib/module/components/treemap-chart/index.js.map +1 -1
  51. package/lib/module/components/waterfall-chart/index.js +20 -4
  52. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  53. package/lib/module/hooks/index.js +1 -0
  54. package/lib/module/hooks/index.js.map +1 -1
  55. package/lib/module/hooks/use-scroll-sections.js +44 -2
  56. package/lib/module/hooks/use-scroll-sections.js.map +1 -1
  57. package/lib/module/hooks/use-skeleton-handoff.js +58 -0
  58. package/lib/module/hooks/use-skeleton-handoff.js.map +1 -0
  59. package/lib/module/index.js +2 -0
  60. package/lib/module/index.js.map +1 -1
  61. package/lib/typescript/src/components/area-chart/index.d.ts +1 -1
  62. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  64. package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  66. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/line-chart/index.d.ts +1 -1
  68. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/map/index.d.ts +8 -4
  70. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/map/map-children.d.ts +41 -0
  72. package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
  73. package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
  74. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  75. package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
  76. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
  78. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -1
  79. package/lib/typescript/src/components/marquee/index.d.ts +55 -2
  80. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  81. package/lib/typescript/src/components/panelside/index.d.ts +79 -4
  82. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -1
  84. package/lib/typescript/src/components/plot/index.d.ts +1 -3
  85. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/progress-button/index.d.ts +311 -0
  87. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -0
  88. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +60 -0
  89. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -0
  90. package/lib/typescript/src/components/scatter-chart/index.d.ts +1 -1
  91. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
  92. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/split-view/index.d.ts +10 -1
  94. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/theme-selector/index.d.ts +107 -0
  96. package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
  97. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
  98. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
  99. package/lib/typescript/src/components/timeline/index.d.ts +7 -0
  100. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  104. package/lib/typescript/src/hooks/index.d.ts +1 -0
  105. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  106. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +1 -0
  107. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
  108. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts +11 -0
  109. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts.map +1 -0
  110. package/lib/typescript/src/index.d.ts +6 -4
  111. package/lib/typescript/src/index.d.ts.map +1 -1
  112. package/package.json +1 -1
  113. package/src/components/area-chart/index.tsx +49 -37
  114. package/src/components/bottom-sheet/index.tsx +109 -11
  115. package/src/components/funnel-chart/index.tsx +10 -2
  116. package/src/components/heatmap-chart/index.tsx +27 -22
  117. package/src/components/hex-chart/index.tsx +22 -6
  118. package/src/components/line-chart/index.tsx +57 -43
  119. package/src/components/map/index.tsx +63 -36
  120. package/src/components/map/map-children.ts +66 -0
  121. package/src/components/map/maplibre.ts +32 -4
  122. package/src/components/markdown-editor/index.tsx +413 -35
  123. package/src/components/markdown-editor/markdown-transforms.ts +274 -10
  124. package/src/components/marquee/index.tsx +254 -58
  125. package/src/components/panelside/index.tsx +179 -40
  126. package/src/components/pie-chart/index.tsx +10 -2
  127. package/src/components/plot/index.tsx +66 -50
  128. package/src/components/progress-button/index.tsx +684 -0
  129. package/src/components/progress-button/progress-button-hold.ts +88 -0
  130. package/src/components/scatter-chart/index.tsx +4 -25
  131. package/src/components/section-rail/index.tsx +39 -19
  132. package/src/components/split-view/index.tsx +53 -8
  133. package/src/components/theme-selector/index.tsx +281 -0
  134. package/src/components/theme-selector/theme-preview.tsx +205 -0
  135. package/src/components/timeline/index.tsx +143 -17
  136. package/src/components/toast/index.tsx +60 -11
  137. package/src/components/treemap-chart/index.tsx +65 -8
  138. package/src/components/waterfall-chart/index.tsx +14 -4
  139. package/src/hooks/index.ts +5 -0
  140. package/src/hooks/use-scroll-sections.ts +52 -3
  141. package/src/hooks/use-skeleton-handoff.ts +69 -0
  142. package/src/index.ts +25 -0
@@ -0,0 +1,684 @@
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
+ Children,
45
+ createContext,
46
+ forwardRef,
47
+ isValidElement,
48
+ useCallback,
49
+ useContext,
50
+ useEffect,
51
+ useMemo,
52
+ useRef,
53
+ useState,
54
+ type ReactNode,
55
+ } from 'react';
56
+ import {
57
+ Pressable,
58
+ View,
59
+ type LayoutChangeEvent,
60
+ type PressableProps,
61
+ type ViewProps,
62
+ } from 'react-native';
63
+ import Animated, {
64
+ Easing,
65
+ cancelAnimation,
66
+ runOnJS,
67
+ runOnUI,
68
+ useAnimatedReaction,
69
+ useAnimatedStyle,
70
+ useReducedMotion,
71
+ useSharedValue,
72
+ withSpring,
73
+ withTiming,
74
+ type SharedValue,
75
+ } from 'react-native-reanimated';
76
+ import { tv, type VariantProps } from 'tailwind-variants';
77
+ import { useCSSVariable } from 'uniwind';
78
+ import { CheckIcon, IconColorProvider } from '../../icons';
79
+ import { Text, textChildren } from '../../primitives/text';
80
+ import { impactKnock, selectionTick } from '../../utils/haptics';
81
+ import {
82
+ DEFAULT_AUTO_RESET_DELAY,
83
+ fillDuration,
84
+ releaseDuration,
85
+ resolveHoldDuration,
86
+ } from './progress-button-hold';
87
+
88
+ /** How many steps the reduced-motion fill advances in. */
89
+ const REDUCED_STEPS = 5;
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
+
117
+ const progressButtonVariants = tv({
118
+ slots: {
119
+ /*
120
+ * A pill, not the `rounded-lg` the other buttons take.
121
+ *
122
+ * The fill is clipped by this radius, so the shape of the button is also
123
+ * the shape of the wipe's leading edge as it comes out of the corner. On a
124
+ * small radius that edge emerges square from a rounded box, which reads as
125
+ * a rectangle sliding out from under the button rather than as the button
126
+ * filling up.
127
+ */
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',
144
+ /*
145
+ * The row inside the button. It is separate from `root` because the fill
146
+ * has to sit over the whole button including its padding — a wipe that
147
+ * stops at the text's own box leaves an unfilled margin down each side and
148
+ * reads as a progress bar someone put inside a button.
149
+ */
150
+ content: 'flex-row items-center justify-center gap-2',
151
+ label: 'min-w-0 shrink text-center font-medium',
152
+ /** The inverted copy, drawn on the filled ground. */
153
+ fill: '',
154
+ fillLabel: 'min-w-0 shrink text-center font-medium',
155
+ },
156
+ variants: {
157
+ variant: {
158
+ primary: {
159
+ label: 'text-primary',
160
+ fill: 'bg-primary',
161
+ fillLabel: 'text-primary-foreground',
162
+ },
163
+ secondary: {
164
+ label: 'text-secondary-foreground',
165
+ fill: 'bg-foreground',
166
+ fillLabel: 'text-background',
167
+ },
168
+ destructive: {
169
+ label: 'text-destructive',
170
+ fill: 'bg-destructive',
171
+ fillLabel: 'text-destructive-solid-foreground',
172
+ },
173
+ success: {
174
+ label: 'text-success',
175
+ fill: 'bg-success',
176
+ fillLabel: 'text-success-solid-foreground',
177
+ },
178
+ },
179
+ size: {
180
+ // Matched to Button's boxes, and `min-h-*` for the same reason: the
181
+ // label's glyphs grow with the system text size and the box has to grow
182
+ // with them.
183
+ // Wider than the equivalent Button, because the corner is a half-circle
184
+ // rather than a small radius: the curve eats into the side padding, and
185
+ // at Button's values the first and last glyphs sit against it.
186
+ sm: {
187
+ root: 'min-h-9',
188
+ content: 'min-h-9 gap-1.5 px-3.5 py-2',
189
+ label: 'text-[14px]',
190
+ fillLabel: 'text-[14px]',
191
+ },
192
+ md: {
193
+ root: 'min-h-11',
194
+ content: 'min-h-11 px-5 py-2.5',
195
+ label: 'text-[16px]',
196
+ fillLabel: 'text-[16px]',
197
+ },
198
+ lg: {
199
+ root: 'min-h-12',
200
+ content: 'min-h-12 px-7 py-2.5',
201
+ label: 'text-[18px]',
202
+ fillLabel: 'text-[18px]',
203
+ },
204
+ },
205
+ fullWidth: {
206
+ true: { root: 'w-full' },
207
+ },
208
+ disabled: {
209
+ true: { root: 'opacity-[0.64]' },
210
+ },
211
+ },
212
+ defaultVariants: {
213
+ variant: 'primary',
214
+ size: 'md',
215
+ },
216
+ });
217
+
218
+ type ProgressButtonVariantProps = VariantProps<typeof progressButtonVariants>;
219
+
220
+ /** How a progress button looks. */
221
+ export type ProgressButtonVariant = NonNullable<ProgressButtonVariantProps['variant']>;
222
+ /** How big a progress button is. */
223
+ export type ProgressButtonSize = NonNullable<ProgressButtonVariantProps['size']>;
224
+
225
+ interface ProgressButtonContextValue {
226
+ /** `0` to `1` across the hold. */
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>;
234
+ /** Width of the button, so the fill's copy of the label can match it. */
235
+ width: number;
236
+ completed: boolean;
237
+ variant: ProgressButtonVariant;
238
+ size: ProgressButtonSize;
239
+ slots: ReturnType<typeof progressButtonVariants>;
240
+ }
241
+
242
+ const ProgressButtonContext = createContext<ProgressButtonContextValue | null>(null);
243
+
244
+ function useProgressButtonContext(component: string): ProgressButtonContextValue {
245
+ const context = useContext(ProgressButtonContext);
246
+ if (!context) throw new Error(`${component} must be used within a <ProgressButton>`);
247
+ return context;
248
+ }
249
+
250
+ /** How far through the hold this button is, for something rendered inside it. */
251
+ export function useProgressButton() {
252
+ const { progress, completed } = useProgressButtonContext('useProgressButton');
253
+ return { progress, completed };
254
+ }
255
+
256
+ export interface ProgressButtonProps
257
+ extends Omit<PressableProps, 'children' | 'disabled'>,
258
+ Omit<ProgressButtonVariantProps, 'disabled'> {
259
+ className?: string;
260
+ /**
261
+ * Milliseconds the button has to be held. Defaults to `2000`, and is floored
262
+ * at `200` — a hold that completes on touch-down is a button with extra steps.
263
+ */
264
+ holdDuration?: number;
265
+ /** Fires once the hold has been sustained to the end. */
266
+ onComplete?: () => void;
267
+ /** Fires whenever the completed state changes, including on a reset. */
268
+ onCompletedChange?: (completed: boolean) => void;
269
+ /** Controlled completion. Leave unset to let the button own it. */
270
+ completed?: boolean;
271
+ /** Return to the unfilled state after `autoResetDelay`. */
272
+ autoReset?: boolean;
273
+ /** Milliseconds to stay completed before resetting. Defaults to `1000`. */
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
+ */
279
+ disabled?: boolean;
280
+ /**
281
+ * A tick as the hold takes, and a knock when it completes. Off by default:
282
+ * whether an action is worth feeling is the caller's call, not the control's.
283
+ */
284
+ haptics?: boolean;
285
+ children?: ReactNode;
286
+ }
287
+
288
+ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function ProgressButtonRoot(
289
+ {
290
+ className,
291
+ variant = 'primary',
292
+ size = 'md',
293
+ fullWidth,
294
+ holdDuration,
295
+ onComplete,
296
+ onCompletedChange,
297
+ completed: completedProp,
298
+ autoReset = false,
299
+ autoResetDelay = DEFAULT_AUTO_RESET_DELAY,
300
+ disabled = false,
301
+ haptics = false,
302
+ accessibilityState,
303
+ children,
304
+ ...props
305
+ },
306
+ ref
307
+ ) {
308
+ const slots = progressButtonVariants({ variant, size, fullWidth, disabled });
309
+ const duration = resolveHoldDuration(holdDuration);
310
+ const reducedMotion = useReducedMotion();
311
+
312
+ const progress = useSharedValue(0);
313
+ const done = useSharedValue(0);
314
+ const [width, setWidth] = useState(0);
315
+ const [internalCompleted, setInternalCompleted] = useState(false);
316
+ const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
317
+
318
+ const isControlled = completedProp !== undefined;
319
+ const completed = isControlled ? completedProp : internalCompleted;
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
+
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;
334
+ if (!isControlled) setInternalCompleted(true);
335
+ onCompletedChange?.(true);
336
+ onComplete?.();
337
+ if (haptics) impactKnock();
338
+ }, [isControlled, onComplete, onCompletedChange, haptics]);
339
+
340
+ /*
341
+ * Completion is read off the animation rather than timed alongside it.
342
+ *
343
+ * A `setTimeout` for `holdDuration` and a fill for `holdDuration` are two
344
+ * clocks that agree only while the app is idle: the moment the JS thread is
345
+ * busy the button fires before the fill arrives, or the fill sits full while
346
+ * nothing happens. The reaction fires on the frame the value actually
347
+ * reaches the end, which is the frame the reader saw it get there.
348
+ */
349
+ useAnimatedReaction(
350
+ () => progress.value >= 1,
351
+ (full, was) => {
352
+ if (full && !was) runOnJS(finish)();
353
+ }
354
+ );
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
+ */
367
+ const begin = useCallback(() => {
368
+ if (disabled || completed) return;
369
+ if (haptics) selectionTick();
370
+ runOnUI(() => {
371
+ 'worklet';
372
+ cancelAnimation(progress);
373
+ const from = progress.value;
374
+ if (from >= 1) return;
375
+ progress.value = withTiming(1, {
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,
390
+ });
391
+ })();
392
+ }, [disabled, completed, haptics, progress, reducedMotion, duration]);
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
+
424
+ const abandon = useCallback(() => {
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]);
435
+
436
+ const reset = useCallback(() => {
437
+ rewind();
438
+ if (!isControlled) setInternalCompleted(false);
439
+ onCompletedChange?.(false);
440
+ }, [rewind, isControlled, onCompletedChange]);
441
+
442
+ useEffect(() => {
443
+ if (!completed || !autoReset) return;
444
+ resetTimer.current = setTimeout(reset, autoResetDelay);
445
+ return () => {
446
+ if (resetTimer.current) clearTimeout(resetTimer.current);
447
+ resetTimer.current = null;
448
+ };
449
+ }, [completed, autoReset, autoResetDelay, reset]);
450
+
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
+ */
472
+ useEffect(() => {
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]);
479
+
480
+ useEffect(
481
+ () => () => {
482
+ cancelAnimation(progress);
483
+ cancelAnimation(done);
484
+ if (resetTimer.current) clearTimeout(resetTimer.current);
485
+ },
486
+ [progress, done]
487
+ );
488
+
489
+ const onLayout = (event: LayoutChangeEvent) => {
490
+ const next = Math.round(event.nativeEvent.layout.width);
491
+ setWidth((current) => (current === next ? current : next));
492
+ props.onLayout?.(event);
493
+ };
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
+ */
501
+ const context = useMemo<ProgressButtonContextValue>(
502
+ () => ({ progress, done, width, completed, variant, size, slots }),
503
+ // eslint-disable-next-line react-hooks/exhaustive-deps
504
+ [progress, done, width, completed, variant, size]
505
+ );
506
+
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 = (
518
+ <>
519
+ {written.length > 0 ? written : <ProgressButtonLabel>Hold to confirm</ProgressButtonLabel>}
520
+ {hasDone ? null : <ProgressButtonDone />}
521
+ </>
522
+ );
523
+
524
+ return (
525
+ <ProgressButtonContext.Provider value={context}>
526
+ <Pressable
527
+ ref={ref}
528
+ accessibilityRole="button"
529
+ /*
530
+ * Spoken as what it is. A button whose label says "Hold to confirm"
531
+ * still announces as an ordinary button to anyone who cannot see the
532
+ * fill, and a single activation does nothing — so the hint carries the
533
+ * instruction and the state carries the outcome.
534
+ */
535
+ accessibilityHint="Press and hold to confirm"
536
+ accessibilityState={{
537
+ ...accessibilityState,
538
+ disabled,
539
+ checked: completed,
540
+ }}
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}
549
+ onPressIn={begin}
550
+ onPressOut={abandon}
551
+ // A few points of drift should not abandon a hold the reader is
552
+ // still making. Fingers move.
553
+ pressRetentionOffset={16}
554
+ onLayout={onLayout}
555
+ className={slots.root({ className })}
556
+ >
557
+ {body}
558
+ </Pressable>
559
+ </ProgressButtonContext.Provider>
560
+ );
561
+ });
562
+
563
+ export interface ProgressButtonLabelProps extends ViewProps {
564
+ className?: string;
565
+ children?: ReactNode;
566
+ }
567
+
568
+ /**
569
+ * What the button says, drawn twice.
570
+ *
571
+ * The second copy is the one inside the fill, in the inverted colour, pinned to
572
+ * the button's own width and clipped to however far the hold has got. Both are
573
+ * the same text at the same position, so the boundary between them falls in the
574
+ * middle of a glyph rather than between two differently laid-out lines.
575
+ */
576
+ function ProgressButtonLabel({ className, children, ...props }: ProgressButtonLabelProps) {
577
+ const { progress, width, slots } = useProgressButtonContext('ProgressButton.Label');
578
+
579
+ const fillStyle = useAnimatedStyle(() => ({ width: width * progress.value }));
580
+
581
+ return (
582
+ <View {...props} className={slots.content({ className })}>
583
+ {textChildren(children, (text) => (
584
+ <Text className={slots.label()}>{text}</Text>
585
+ ))}
586
+
587
+ {/* The wipe. `pointerEvents` off so it never takes the press it is
588
+ drawn on top of, and `width` fixed to the button so the copy inside
589
+ it is laid out exactly where the original is rather than reflowing
590
+ into whatever the clip currently allows. */}
591
+ <Animated.View
592
+ pointerEvents="none"
593
+ style={[
594
+ { position: 'absolute', top: 0, bottom: 0, left: 0, overflow: 'hidden' },
595
+ fillStyle,
596
+ ]}
597
+ className={slots.fill()}
598
+ >
599
+ <View style={{ width }} className={slots.content()}>
600
+ {textChildren(children, (text) => (
601
+ <Text className={slots.fillLabel()}>{text}</Text>
602
+ ))}
603
+ </View>
604
+ </Animated.View>
605
+ </View>
606
+ );
607
+ }
608
+ ProgressButtonLabel.displayName = 'ProgressButton.Label';
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
+
669
+ ProgressButtonRoot.displayName = 'ProgressButton';
670
+
671
+ export const ProgressButton = Object.assign(ProgressButtonRoot, {
672
+ Label: ProgressButtonLabel,
673
+ Done: ProgressButtonDone,
674
+ });
675
+
676
+ export {
677
+ DEFAULT_AUTO_RESET_DELAY,
678
+ DEFAULT_HOLD_DURATION,
679
+ DEFAULT_RELEASE_DURATION,
680
+ fillDuration,
681
+ isComplete,
682
+ releaseDuration,
683
+ resolveHoldDuration,
684
+ } from './progress-button-hold';