panelui-native 0.80.0 → 0.82.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 (92) hide show
  1. package/lib/module/components/bottom-sheet/index.js +90 -17
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/button/index.js +13 -0
  4. package/lib/module/components/button/index.js.map +1 -1
  5. package/lib/module/components/button-group/index.js +3 -0
  6. package/lib/module/components/button-group/index.js.map +1 -1
  7. package/lib/module/components/image-generation/dot-field.js +210 -0
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -0
  9. package/lib/module/components/image-generation/index.js +417 -0
  10. package/lib/module/components/image-generation/index.js.map +1 -0
  11. package/lib/module/components/kpi/index.js +2 -4
  12. package/lib/module/components/kpi/index.js.map +1 -1
  13. package/lib/module/components/menu/index.js +8 -16
  14. package/lib/module/components/menu/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +340 -33
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/planner/index.js +446 -32
  18. package/lib/module/components/planner/index.js.map +1 -1
  19. package/lib/module/components/planner/planner-entries.js +36 -0
  20. package/lib/module/components/planner/planner-entries.js.map +1 -1
  21. package/lib/module/components/planner/planner-grid-navigation.js +34 -2
  22. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -1
  23. package/lib/module/components/planner/planner-weeks.js +95 -0
  24. package/lib/module/components/planner/planner-weeks.js.map +1 -0
  25. package/lib/module/components/select/index.js +127 -23
  26. package/lib/module/components/select/index.js.map +1 -1
  27. package/lib/module/components/slide-button/index.js +610 -0
  28. package/lib/module/components/slide-button/index.js.map +1 -0
  29. package/lib/module/components/slide-button/slide-button-track.js +98 -0
  30. package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
  31. package/lib/module/icons/index.js +553 -1934
  32. package/lib/module/icons/index.js.map +1 -1
  33. package/lib/module/index.js +3 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/primitives/portal.js +15 -1
  36. package/lib/module/primitives/portal.js.map +1 -1
  37. package/lib/module/utils/date.js +10 -3
  38. package/lib/module/utils/date.js.map +1 -1
  39. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/button/index.d.ts +12 -0
  41. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button-group/index.d.ts +9 -0
  43. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/image-generation/dot-field.d.ts +61 -0
  45. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
  46. package/lib/typescript/src/components/image-generation/index.d.ts +126 -0
  47. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/panelside/index.d.ts +194 -10
  51. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/planner/index.d.ts +65 -4
  53. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/planner/planner-entries.d.ts +18 -0
  55. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  56. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +7 -1
  57. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -1
  58. package/lib/typescript/src/components/planner/planner-weeks.d.ts +50 -0
  59. package/lib/typescript/src/components/planner/planner-weeks.d.ts.map +1 -0
  60. package/lib/typescript/src/components/select/index.d.ts +75 -2
  61. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
  63. package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
  65. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
  66. package/lib/typescript/src/icons/index.d.ts +136 -189
  67. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  68. package/lib/typescript/src/index.d.ts +5 -3
  69. package/lib/typescript/src/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  71. package/lib/typescript/src/utils/date.d.ts +1 -1
  72. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  73. package/package.json +3 -2
  74. package/src/components/bottom-sheet/index.tsx +97 -17
  75. package/src/components/button/index.tsx +10 -2
  76. package/src/components/button-group/index.tsx +1 -0
  77. package/src/components/image-generation/dot-field.ts +226 -0
  78. package/src/components/image-generation/index.tsx +488 -0
  79. package/src/components/kpi/index.tsx +2 -4
  80. package/src/components/menu/index.tsx +10 -15
  81. package/src/components/panelside/index.tsx +498 -33
  82. package/src/components/planner/index.tsx +516 -38
  83. package/src/components/planner/planner-entries.ts +33 -0
  84. package/src/components/planner/planner-grid-navigation.ts +37 -2
  85. package/src/components/planner/planner-weeks.ts +90 -0
  86. package/src/components/select/index.tsx +158 -31
  87. package/src/components/slide-button/index.tsx +681 -0
  88. package/src/components/slide-button/slide-button-track.ts +100 -0
  89. package/src/icons/index.tsx +338 -1327
  90. package/src/index.ts +30 -0
  91. package/src/primitives/portal.tsx +15 -1
  92. package/src/utils/date.ts +13 -3
@@ -0,0 +1,681 @@
1
+ /**
2
+ * SlideButton — drag across to confirm, with the distance drawn on the button.
3
+ *
4
+ * ```tsx
5
+ * <SlideButton onComplete={ship}>
6
+ * <SlideButton.Label>Slide to ship</SlideButton.Label>
7
+ * </SlideButton>
8
+ * ```
9
+ *
10
+ * For an action worth a moment's deliberation, where a hold is the wrong
11
+ * shape. A hold asks for time and shows a clock; a slide asks for a movement
12
+ * and shows a distance — so the reader can go as fast as they like, and still
13
+ * cannot arrive by tapping. Pair it with [ProgressButton](../progress-button)
14
+ * rather than choosing between them: the hold suits an action that should feel
15
+ * expensive, the slide one that should feel deliberate.
16
+ *
17
+ * ## The far end is the promise
18
+ *
19
+ * Nothing fires until the thumb clears `threshold`, which defaults to nine
20
+ * tenths of the rail. Released short, it springs home and the fill goes with
21
+ * it. Released short but travelling, it is honoured — a flick that had
22
+ * plainly committed is not worth refusing on a technicality, and the velocity
23
+ * is projected forward to decide.
24
+ *
25
+ * ## How it is drawn
26
+ *
27
+ * A track, a fill that follows the thumb, and a label that fades as the thumb
28
+ * reaches it. The label fades rather than sliding out of the way because the
29
+ * thumb is about to be where the label is, and two things moving toward each
30
+ * other at different speeds reads as a collision.
31
+ *
32
+ * The thumb is translated and the fill's width animated, both on the UI thread
33
+ * off one shared value, so a drag never re-renders React.
34
+ *
35
+ * ## Sliding without a finger
36
+ *
37
+ * A drag is not available to a screen reader, so the rail is also a button:
38
+ * it takes an `activate` accessibility action and completes on it. This is not
39
+ * a lesser path bolted on — a confirmation control that can only be reached by
40
+ * dragging is a confirmation control some people cannot use.
41
+ */
42
+ import {
43
+ Children,
44
+ createContext,
45
+ forwardRef,
46
+ isValidElement,
47
+ useCallback,
48
+ useContext,
49
+ useEffect,
50
+ useMemo,
51
+ useRef,
52
+ useState,
53
+ type ReactNode,
54
+ } from 'react';
55
+ import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
56
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
57
+ import Animated, {
58
+ cancelAnimation,
59
+ runOnJS,
60
+ useAnimatedStyle,
61
+ useReducedMotion,
62
+ useSharedValue,
63
+ withSpring,
64
+ withTiming,
65
+ type SharedValue,
66
+ } from 'react-native-reanimated';
67
+ import { tv, type VariantProps } from 'tailwind-variants';
68
+ import { useCSSVariable } from 'uniwind';
69
+ import { useDirectionSign } from '../../hooks/use-direction';
70
+ import { CheckIcon, ChevronRightIcon, IconColorProvider } from '../../icons';
71
+ import { Text, textChildren } from '../../primitives/text';
72
+ import { impactKnock, selectionTick } from '../../utils/haptics';
73
+ import {
74
+ DEFAULT_AUTO_RESET_DELAY,
75
+ OVERSHOOT_FRICTION,
76
+ VELOCITY_LOOKAHEAD,
77
+ resolveThreshold,
78
+ } from './slide-button-track';
79
+
80
+ /**
81
+ * How the handle settles when it is let go, in either direction.
82
+ *
83
+ * Clamped, because the rail has hard ends and the release carries the drag's
84
+ * own velocity into the spring. Unclamped, a fast flick overshoots past the
85
+ * end, disappears behind the rail's clip and comes back — which reads as the
86
+ * control having been thrown rather than moved, and is worst on exactly the
87
+ * gesture people make when they are confident.
88
+ */
89
+ const SPRING = {
90
+ damping: 22,
91
+ stiffness: 220,
92
+ mass: 0.7,
93
+ overshootClamping: true,
94
+ } as const;
95
+
96
+ /** How long the completed drawing takes to leave again on a reset. */
97
+ const DONE_EXIT = 140;
98
+
99
+ /** How long the reduced-motion snap takes, in place of a spring. */
100
+ const REDUCED_SNAP = 160;
101
+
102
+ /**
103
+ * The thumb is neutral, and the rail around it carries the variant.
104
+ *
105
+ * Drawn in the accent it was the loudest thing on the control, which put the
106
+ * eye on the handle rather than on the distance — and the distance is the
107
+ * question the button is asking. Neutral, it reads as a physical thing being
108
+ * moved across a coloured track, which is what it is.
109
+ */
110
+ const THUMB_GLYPH = '--color-muted-foreground';
111
+
112
+ const slideButtonVariants = tv({
113
+ slots: {
114
+ /*
115
+ * A pill, and the same secondary ground ProgressButton rests on, so the
116
+ * two read as one pair of controls rather than two unrelated ones. The
117
+ * radius is also the shape of the fill's leading edge as it comes out of
118
+ * the corner.
119
+ */
120
+ root: 'relative flex-row items-center overflow-hidden rounded-full border border-transparent bg-secondary',
121
+ /**
122
+ * The ground the handle has covered.
123
+ *
124
+ * Inset on every side by the same padding the handle sits in, so at rest
125
+ * it is exactly zero wide and there is nothing to see — a trail with a
126
+ * sliver of colour in it before anything has been dragged is a control
127
+ * that looks part-finished.
128
+ */
129
+ fill: 'absolute rounded-full',
130
+ /** The label, centred in the rail rather than in the space beside it. */
131
+ label: 'text-center font-medium',
132
+ /**
133
+ * The draggable handle. Wider than it is tall, so it reads as something to
134
+ * push rather than as a dot that happens to be on a track — and the extra
135
+ * width is what gives the chevron room to sit in without crowding the
136
+ * curve at either end.
137
+ */
138
+ thumb: 'absolute items-center justify-center rounded-full bg-card shadow-sm',
139
+ /** What the thumb holds — a chevron at rest, a tick once it has arrived. */
140
+ thumbContent: 'items-center justify-center',
141
+ },
142
+ variants: {
143
+ variant: {
144
+ secondary: { label: 'text-secondary-foreground', fill: 'bg-foreground/10' },
145
+ destructive: { label: 'text-destructive', fill: 'bg-destructive/20' },
146
+ success: { label: 'text-success', fill: 'bg-success/20' },
147
+ },
148
+ size: {
149
+ // Matched to ProgressButton's boxes so the two line up in a column, and
150
+ // `min-h-*` for the same reason: the label's glyphs grow with the system
151
+ // text size and the box has to grow with them.
152
+ sm: { root: 'min-h-11 p-1', label: 'text-[14px]' },
153
+ md: { root: 'min-h-[52px] p-1', label: 'text-[16px]' },
154
+ lg: { root: 'min-h-14 p-1', label: 'text-[18px]' },
155
+ },
156
+ fullWidth: {
157
+ true: { root: 'w-full' },
158
+ },
159
+ disabled: {
160
+ true: { root: 'opacity-[0.64]' },
161
+ },
162
+ },
163
+ defaultVariants: {
164
+ /*
165
+ * `secondary`, where ProgressButton's default is `primary`.
166
+ *
167
+ * The handle here is neutral and the rail carries the variant, so what
168
+ * `primary` had left to say was a wash of the accent behind the handle —
169
+ * and in a theme whose primary is close to its foreground that is the same
170
+ * drawing as `secondary`. A variant nobody can tell from another one is not
171
+ * a choice, so it is gone rather than kept for symmetry.
172
+ */
173
+ variant: 'secondary',
174
+ size: 'md',
175
+ },
176
+ });
177
+
178
+ type SlideButtonVariantProps = VariantProps<typeof slideButtonVariants>;
179
+
180
+ /** How a slide button looks. */
181
+ export type SlideButtonVariant = NonNullable<SlideButtonVariantProps['variant']>;
182
+ /** How big a slide button is. */
183
+ export type SlideButtonSize = NonNullable<SlideButtonVariantProps['size']>;
184
+
185
+ /** The thumb's box, per size. A stadium, so the two differ. */
186
+ const THUMB_SIZE = {
187
+ sm: { width: 60, height: 36 },
188
+ md: { width: 74, height: 44 },
189
+ lg: { width: 82, height: 48 },
190
+ } as const;
191
+
192
+ /** The rail's own padding — the gap the thumb sits inside. */
193
+ const RAIL_INSET = 4;
194
+
195
+ interface SlideButtonContextValue {
196
+ /** `0` to `1` across the rail. */
197
+ progress: SharedValue<number>;
198
+ /** Points the thumb can cover: the rail, less the thumb and both insets. */
199
+ travel: SharedValue<number>;
200
+ /** `0` to `1` across the arrival of the completed drawing. */
201
+ done: SharedValue<number>;
202
+ /** Whether the slide has been completed. */
203
+ completed: boolean;
204
+ slots: ReturnType<typeof slideButtonVariants>;
205
+ variant: SlideButtonVariant;
206
+ size: SlideButtonSize;
207
+ }
208
+
209
+ const SlideButtonContext = createContext<SlideButtonContextValue | null>(null);
210
+
211
+ function useSlideButtonContext(component: string): SlideButtonContextValue {
212
+ const context = useContext(SlideButtonContext);
213
+ if (!context) {
214
+ throw new Error(`${component} must be used within a <SlideButton>`);
215
+ }
216
+ return context;
217
+ }
218
+
219
+ /** The slide's progress and whether it has completed, for a custom part. */
220
+ export function useSlideButton(): { progress: SharedValue<number>; completed: boolean } {
221
+ const { progress, completed } = useSlideButtonContext('useSlideButton');
222
+ return { progress, completed };
223
+ }
224
+
225
+ export interface SlideButtonProps
226
+ extends Omit<ViewProps, 'children'>,
227
+ Omit<SlideButtonVariantProps, 'disabled'> {
228
+ /** Extra classes for the rail — the box the button occupies in your layout. */
229
+ className?: string;
230
+ /**
231
+ * The fraction of the rail the thumb has to cover for the slide to count.
232
+ * Defaults to `0.9`, clamped to between `0.1` and `1`.
233
+ */
234
+ threshold?: number;
235
+ /** Fires once the thumb has been taken past the threshold and released. */
236
+ onComplete?: () => void;
237
+ /** Fires whenever the completed state changes, including on a reset. */
238
+ onCompletedChange?: (completed: boolean) => void;
239
+ /** Controlled completion. Leave unset to let the button own it. */
240
+ completed?: boolean;
241
+ /** Return to the unslid state after `autoResetDelay`. */
242
+ autoReset?: boolean;
243
+ /** Milliseconds to stay completed before resetting. Defaults to `1000`. */
244
+ autoResetDelay?: number;
245
+ /** Dim the button and refuse the drag outright. */
246
+ disabled?: boolean;
247
+ /**
248
+ * A tick as the thumb arms and a knock when it commits. Off by default,
249
+ * because a control used several times in a row is one a reader may not want
250
+ * buzzing every time.
251
+ */
252
+ haptics?: boolean;
253
+ /**
254
+ * What a screen reader is told the button does, in the imperative — it is
255
+ * announced as the action of a button rather than as an instruction to drag,
256
+ * since dragging is not available there. Defaults to `'Confirm'`.
257
+ */
258
+ accessibilityActionLabel?: string;
259
+ children?: ReactNode;
260
+ }
261
+
262
+ function SlideButtonRoot(
263
+ {
264
+ className,
265
+ variant = 'secondary',
266
+ size = 'md',
267
+ fullWidth,
268
+ threshold: thresholdProp,
269
+ onComplete,
270
+ onCompletedChange,
271
+ completed: completedProp,
272
+ autoReset = false,
273
+ autoResetDelay = DEFAULT_AUTO_RESET_DELAY,
274
+ disabled = false,
275
+ haptics = false,
276
+ accessibilityActionLabel = 'Confirm',
277
+ accessibilityState,
278
+ children,
279
+ ...props
280
+ }: SlideButtonProps,
281
+ ref: React.Ref<View>
282
+ ) {
283
+ const reducedMotion = useReducedMotion();
284
+ const sign = useDirectionSign();
285
+
286
+ const progress = useSharedValue(0);
287
+ const done = useSharedValue(0);
288
+ const armed = useSharedValue(false);
289
+ const origin = useSharedValue(0);
290
+
291
+ /*
292
+ * The rail's measurements live in shared values, not in the render's
293
+ * closure.
294
+ *
295
+ * A gesture built over plain numbers is a new gesture the first time the rail
296
+ * is measured and again every time anything else changes — and re-attaching a
297
+ * handler mid-drag is how a live touch gets dropped. Built over shared values
298
+ * it is constructed once and reads the current numbers when it runs.
299
+ */
300
+ const travel = useSharedValue(0);
301
+ const threshold = useSharedValue(resolveThreshold(thresholdProp));
302
+ const active = useSharedValue(false);
303
+
304
+ const [internalCompleted, setInternalCompleted] = useState(false);
305
+ const controlled = completedProp !== undefined;
306
+ const completed = controlled ? completedProp : internalCompleted;
307
+
308
+ const thumbWidth = THUMB_SIZE[size].width;
309
+
310
+ useEffect(() => {
311
+ threshold.value = resolveThreshold(thresholdProp);
312
+ }, [threshold, thresholdProp]);
313
+
314
+ useEffect(() => {
315
+ active.value = !disabled && !completed;
316
+ }, [active, completed, disabled]);
317
+
318
+ /*
319
+ * Read from the gesture through `runOnJS`, so they have to be current
320
+ * without the gesture being rebuilt to see them.
321
+ */
322
+ const completedRef = useRef(completed);
323
+ completedRef.current = completed;
324
+ const onCompleteRef = useRef(onComplete);
325
+ onCompleteRef.current = onComplete;
326
+ const onCompletedChangeRef = useRef(onCompletedChange);
327
+ onCompletedChangeRef.current = onCompletedChange;
328
+ const hapticsRef = useRef(haptics);
329
+ hapticsRef.current = haptics;
330
+
331
+ const finish = useCallback(() => {
332
+ if (completedRef.current) return;
333
+ completedRef.current = true;
334
+ setInternalCompleted(true);
335
+ onCompletedChangeRef.current?.(true);
336
+ onCompleteRef.current?.();
337
+ if (hapticsRef.current) impactKnock();
338
+ }, []);
339
+
340
+ const tick = useCallback(() => {
341
+ if (hapticsRef.current) selectionTick();
342
+ }, []);
343
+
344
+ const reset = useCallback(() => {
345
+ completedRef.current = false;
346
+ setInternalCompleted(false);
347
+ onCompletedChangeRef.current?.(false);
348
+ }, []);
349
+
350
+ /** The screen-reader path: no drag, so the whole travel is granted at once. */
351
+ const activate = useCallback(() => {
352
+ if (disabled || completedRef.current) return;
353
+ progress.value = reducedMotion
354
+ ? withTiming(1, { duration: REDUCED_SNAP })
355
+ : withSpring(1, SPRING);
356
+ finish();
357
+ }, [disabled, finish, progress, reducedMotion]);
358
+
359
+ /*
360
+ * Built once. The arithmetic is written out here rather than called from
361
+ * `slide-button-track`: a pan handler is the one place in the library that
362
+ * cannot afford a surprise, and a worklet reaching across a module boundary
363
+ * for a helper that reaches across again is a chain with more ways to fail
364
+ * than the three lines are long. The module holds the definition and the
365
+ * tests hold this to it.
366
+ */
367
+ const gesture = useMemo(
368
+ () =>
369
+ Gesture.Pan()
370
+ /*
371
+ * A drag has to travel before it takes the touch, or a slide button
372
+ * inside a scroller steals every vertical flick that starts on it.
373
+ */
374
+ .activeOffsetX([-12, 12])
375
+ .failOffsetY([-14, 14])
376
+ .onBegin(() => {
377
+ 'worklet';
378
+ origin.value = progress.value * travel.value;
379
+ armed.value = false;
380
+ })
381
+ .onUpdate((event) => {
382
+ 'worklet';
383
+ if (!active.value || travel.value <= 0) return;
384
+
385
+ // Every raw pixel goes through `sign`: in a right-to-left subtree the
386
+ // rail runs the other way, and the gesture reports screen space.
387
+ const moved = origin.value + event.translationX * sign;
388
+ const span = travel.value;
389
+
390
+ // The finger is followed exactly inside the rail, and let go of
391
+ // gradually past either end — a thumb that lags reads as a slow app,
392
+ // and one that stops dead reads as a broken control.
393
+ let at = moved;
394
+ if (moved < 0) at = moved / OVERSHOOT_FRICTION;
395
+ else if (moved > span) at = span + (moved - span) / OVERSHOOT_FRICTION;
396
+
397
+ const next = Math.min(1, Math.max(0, at / span));
398
+ progress.value = next;
399
+
400
+ const reached = next >= threshold.value;
401
+ if (reached !== armed.value) {
402
+ armed.value = reached;
403
+ // Once per crossing, not once per frame — the arming is the event
404
+ // worth feeling, and a tick every frame is a rattle.
405
+ if (reached) runOnJS(tick)();
406
+ }
407
+ })
408
+ .onEnd((event) => {
409
+ 'worklet';
410
+ armed.value = false;
411
+ if (!active.value || travel.value <= 0) return;
412
+
413
+ // Where it got to, plus where its speed was about to carry it.
414
+ const carried = (event.velocityX * sign * VELOCITY_LOOKAHEAD) / travel.value;
415
+ const committed = progress.value + carried >= threshold.value;
416
+
417
+ // The velocity is handed to the spring rather than merely consulted,
418
+ // so the thumb keeps the speed it already had instead of restarting
419
+ // from rest.
420
+ const velocity = event.velocityX * sign;
421
+ progress.value = withSpring(committed ? 1 : 0, { ...SPRING, velocity });
422
+ if (committed) runOnJS(finish)();
423
+ }),
424
+ [active, armed, finish, origin, progress, sign, threshold, tick, travel]
425
+ );
426
+
427
+ /*
428
+ * A controlled completion arrives without a drag behind it, and so does a
429
+ * reset — so the thumb is put where the state says it should be rather than
430
+ * left wherever the finger left it.
431
+ */
432
+ useEffect(() => {
433
+ progress.value = reducedMotion
434
+ ? withTiming(completed ? 1 : 0, { duration: REDUCED_SNAP })
435
+ : withSpring(completed ? 1 : 0, SPRING);
436
+ }, [completed, progress, reducedMotion]);
437
+
438
+ useEffect(() => {
439
+ done.value = completed
440
+ ? reducedMotion
441
+ ? withTiming(1, { duration: REDUCED_SNAP })
442
+ : withSpring(1, SPRING)
443
+ : withTiming(0, { duration: DONE_EXIT });
444
+ }, [completed, done, reducedMotion]);
445
+
446
+ useEffect(() => {
447
+ if (!autoReset || !completed) return;
448
+ const timer = setTimeout(reset, autoResetDelay);
449
+ return () => clearTimeout(timer);
450
+ }, [autoReset, autoResetDelay, completed, reset]);
451
+
452
+ useEffect(
453
+ () => () => {
454
+ cancelAnimation(progress);
455
+ cancelAnimation(done);
456
+ },
457
+ [done, progress]
458
+ );
459
+
460
+ const onLayout = useCallback(
461
+ (event: LayoutChangeEvent) => {
462
+ travel.value = Math.max(
463
+ 0,
464
+ event.nativeEvent.layout.width - thumbWidth - RAIL_INSET * 2
465
+ );
466
+ },
467
+ [thumbWidth, travel]
468
+ );
469
+
470
+ const slots = slideButtonVariants({ variant, size, fullWidth, disabled });
471
+
472
+ const context = useMemo<SlideButtonContextValue>(
473
+ () => ({ progress, travel, done, completed, slots, variant, size }),
474
+ [progress, travel, done, completed, slots, variant, size]
475
+ );
476
+
477
+ /*
478
+ * A caller who wrote no thumb still gets one. The thumb is the control — a
479
+ * slide button without it is a label in a box — so it is appended rather
480
+ * than left to the caller to remember.
481
+ */
482
+ const hasThumb = Children.toArray(children).some(
483
+ (child) => isValidElement(child) && child.type === SlideButtonThumb
484
+ );
485
+
486
+ return (
487
+ <SlideButtonContext.Provider value={context}>
488
+ <View
489
+ ref={ref}
490
+ accessible
491
+ accessibilityRole="button"
492
+ accessibilityHint="Slide to confirm"
493
+ accessibilityState={{ ...accessibilityState, disabled, checked: completed }}
494
+ // A drag cannot be performed by a screen reader, so the same outcome is
495
+ // offered as an action it can perform.
496
+ accessibilityActions={[{ name: 'activate', label: accessibilityActionLabel }]}
497
+ onAccessibilityAction={activate}
498
+ onLayout={onLayout}
499
+ // After the spread, so a caller's own `onLayout` or accessibility state
500
+ // cannot take the measurement the control runs on.
501
+ {...props}
502
+ className={slots.root({ className })}
503
+ >
504
+ <SlideButtonFill />
505
+ {textChildren(children, (text) => (
506
+ <SlideButtonLabel>{text}</SlideButtonLabel>
507
+ ))}
508
+ {hasThumb ? null : <SlideButtonThumb />}
509
+
510
+ {/*
511
+ * The touch surface, and nothing else.
512
+ *
513
+ * The gesture's view carries no styling, no measurement and no
514
+ * accessibility of its own — those all belong to the rail above, and
515
+ * a detector whose child is also doing four other jobs is a detector
516
+ * whose child can be re-created for four other reasons. It sits last
517
+ * so it is over everything, which is what makes the whole rail
518
+ * draggable rather than only the parts with nothing on them.
519
+ */}
520
+ <GestureDetector gesture={gesture}>
521
+ <Animated.View style={StyleSheet.absoluteFill} />
522
+ </GestureDetector>
523
+ </View>
524
+ </SlideButtonContext.Provider>
525
+ );
526
+ }
527
+
528
+ /** The travelled part of the rail. Drawn by the root; not a public part. */
529
+ function SlideButtonFill() {
530
+ const { progress, travel, slots, size } = useSlideButtonContext('SlideButton.Fill');
531
+ const sign = useDirectionSign();
532
+ const overlap = THUMB_SIZE[size].width / 2;
533
+
534
+ /*
535
+ * The trail runs from the handle's own starting edge to somewhere under the
536
+ * handle, and it is zero wide until something has been dragged.
537
+ *
538
+ * Both ends are load-bearing, and both were wrong once.
539
+ *
540
+ * Its leading end is rounded, like the rail. Stopped exactly at the handle's
541
+ * tail that cap met the handle's own cap, and the two facing curves left a
542
+ * lens of bare track between them — two pills on a rail rather than a track
543
+ * being filled in, most obviously at the far end where the reader is looking.
544
+ * Carrying the extra half-handle-width keeps that cap under the handle at
545
+ * every point of the travel: always ahead of the tail, never past the centre.
546
+ *
547
+ * The other end is why the whole width is scaled rather than offset. Adding a
548
+ * constant put a slice of colour beside the handle on a button nobody had
549
+ * touched.
550
+ */
551
+ const style = useAnimatedStyle(() => ({
552
+ width: progress.value * (travel.value + overlap),
553
+ }));
554
+
555
+ return (
556
+ <Animated.View
557
+ pointerEvents="none"
558
+ className={slots.fill()}
559
+ style={[
560
+ {
561
+ top: RAIL_INSET,
562
+ bottom: RAIL_INSET,
563
+ [sign === 1 ? 'left' : 'right']: RAIL_INSET,
564
+ },
565
+ style,
566
+ ]}
567
+ />
568
+ );
569
+ }
570
+
571
+ export interface SlideButtonLabelProps extends ViewProps {
572
+ /** Extra classes for the label's text. */
573
+ className?: string;
574
+ children?: ReactNode;
575
+ }
576
+
577
+ /**
578
+ * What the button says.
579
+ *
580
+ * Centred in the whole rail rather than in the space beside the thumb, and it
581
+ * does not move or fade — the thumb simply passes over it, and the rail's own
582
+ * clip takes the rest.
583
+ *
584
+ * Fading it out was worse in a way that only shows on a device: the label
585
+ * disappears while there is still most of a rail left to cross, so the button
586
+ * spends the second half of the gesture saying nothing at all. Covered
587
+ * instead, the text is legible right up to the moment the handle reaches it,
588
+ * which is also the moment the reader no longer needs it.
589
+ */
590
+ function SlideButtonLabel({ className, style, children, ...props }: SlideButtonLabelProps) {
591
+ const { slots } = useSlideButtonContext('SlideButton.Label');
592
+
593
+ return (
594
+ <View
595
+ pointerEvents="none"
596
+ className="flex-1 items-center justify-center"
597
+ style={style}
598
+ {...props}
599
+ >
600
+ <Text className={slots.label({ className })}>{children}</Text>
601
+ </View>
602
+ );
603
+ }
604
+
605
+ export interface SlideButtonThumbProps extends ViewProps {
606
+ /** Extra classes for the thumb — its fill and shape. Its size comes from `size`. */
607
+ className?: string;
608
+ /** Replaces the chevron. The tick that lands on completion is unaffected. */
609
+ children?: ReactNode;
610
+ }
611
+
612
+ /**
613
+ * The disc the finger moves.
614
+ *
615
+ * Translated rather than laid out at a position, so a drag costs no layout
616
+ * pass. It sits absolutely at the rail's leading inset and travels from there.
617
+ */
618
+ function SlideButtonThumb({ className, style, children, ...props }: SlideButtonThumbProps) {
619
+ const { progress, travel, done, completed, slots, size } =
620
+ useSlideButtonContext('SlideButton.Thumb');
621
+ const sign = useDirectionSign();
622
+ const box = THUMB_SIZE[size];
623
+
624
+ const tint = useCSSVariable(THUMB_GLYPH);
625
+ const glyphColor = typeof tint === 'string' ? tint : undefined;
626
+
627
+ const slide = useAnimatedStyle(() => ({
628
+ transform: [{ translateX: progress.value * travel.value * sign }],
629
+ }));
630
+
631
+ const chevronStyle = useAnimatedStyle(() => ({ opacity: 1 - done.value }));
632
+ const checkStyle = useAnimatedStyle(() => ({
633
+ opacity: done.value,
634
+ transform: [{ scale: 0.7 + done.value * 0.3 }],
635
+ }));
636
+
637
+ return (
638
+ <Animated.View
639
+ pointerEvents="none"
640
+ className={slots.thumb({ className })}
641
+ style={[
642
+ {
643
+ width: box.width,
644
+ height: box.height,
645
+ [sign === 1 ? 'left' : 'right']: RAIL_INSET,
646
+ },
647
+ slide,
648
+ style,
649
+ ]}
650
+ {...props}
651
+ >
652
+ <IconColorProvider color={glyphColor}>
653
+ <Animated.View className={slots.thumbContent()} style={chevronStyle}>
654
+ {children ?? <ChevronRightIcon size={20} />}
655
+ </Animated.View>
656
+ {/* Lifted over the chevron rather than swapped with it, so the two
657
+ cross through each other instead of one popping out and the other
658
+ in on the same frame. */}
659
+ <Animated.View
660
+ className={slots.thumbContent()}
661
+ style={[StyleSheet.absoluteFill, checkStyle]}
662
+ accessibilityElementsHidden={!completed}
663
+ importantForAccessibility={completed ? 'auto' : 'no-hide-descendants'}
664
+ >
665
+ <CheckIcon size={20} />
666
+ </Animated.View>
667
+ </IconColorProvider>
668
+ </Animated.View>
669
+ );
670
+ }
671
+
672
+ SlideButtonLabel.displayName = 'SlideButton.Label';
673
+ SlideButtonThumb.displayName = 'SlideButton.Thumb';
674
+
675
+ const SlideButtonWithRef = forwardRef(SlideButtonRoot);
676
+ SlideButtonWithRef.displayName = 'SlideButton';
677
+
678
+ export const SlideButton = Object.assign(SlideButtonWithRef, {
679
+ Label: SlideButtonLabel,
680
+ Thumb: SlideButtonThumb,
681
+ });