@delacour/react-native-ui 0.5.0-alpha.20261024145030 → 0.5.0-alpha.20261024151637

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.5.0-alpha.20261024145030",
3
+ "version": "0.5.0-alpha.20261024151637",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -182,8 +182,8 @@
182
182
  "expo-blur": ">=15",
183
183
  "@maplibre/maplibre-react-native": "^11.5.0",
184
184
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
185
- "@delacour/react-native-bottom-sheet": "0.3.0-alpha.20261024145030",
186
- "@delacour/react-native-charts": "0.2.0-alpha.20261024145030",
185
+ "@delacour/react-native-bottom-sheet": "0.3.0-alpha.20261024151637",
186
+ "@delacour/react-native-charts": "0.2.0-alpha.20261024151637",
187
187
  "@legendapp/list": ">=3.3",
188
188
  "react-native-skia": ">=3.7.3",
189
189
  "expo-linear-gradient": ">=15",
@@ -24,7 +24,7 @@ There is no `slide-button.types.ts`: every prop type has exactly one consumer.
24
24
  ## Design
25
25
 
26
26
  - **Variants**: `secondary` (default), `destructive`, `success`. **Sizes**:
27
- `sm`, `md`, `lg`. Plus `isFullWidth` and `isDisabled`.
27
+ `sm`, `md`, `lg`. Plus `isFullWidth`, `isDisabled` and `isLoading`.
28
28
  - **There is no `primary` variant.** The handle is neutral, so a primary rail
29
29
  with a neutral handle draws the same picture as `secondary` in any theme whose
30
30
  primary sits close to its foreground — the default palette included. Two names
@@ -44,6 +44,14 @@ There is no `slide-button.types.ts`: every prop type has exactly one consumer.
44
44
  rear is a curve; a trail stopping at the straight edge leaves a sliver of rail
45
45
  above and below it. Ending under the handle reads as one shape — the handle
46
46
  dragging its colour along — while still sharing an edge with it as seen.
47
+ - **At the end of the travel the trail fills the whole rail.** Held at the
48
+ handle's middle all the way, a confirmed handle left the rail showing around
49
+ its front curve and in the inset past it, so the fill looked as if it stopped
50
+ halfway under the handle. Over the last `handleWidth / 2 + inset` of travel
51
+ `resolveSlideTrailWidth` runs the lead edge on to the far edge, landing there
52
+ exactly at full travel, clamped so a spring's overshoot cannot run it past.
53
+ Before that stretch it stays under the handle, so mid-drag nothing peeks out
54
+ around the handle's front curve.
47
55
  - **The trail is invisible at rest.** It fades in over the first 8pt of travel
48
56
  (`resolveSlideTrailOpacity`). Ending under the handle's middle, it would
49
57
  otherwise peek out behind the resting handle's rear curve — a coloured sliver
@@ -90,6 +98,14 @@ There is no `slide-button.types.ts`: every prop type has exactly one consumer.
90
98
  returns early. A disabled recognizer lets the touch fall through to whatever
91
99
  is behind it — inside a stack with a full-screen back swipe, dragging a
92
100
  disabled slide navigated back. Found on the simulator.
101
+ - **`isLoading` is `Button`'s prop, on the handle.** The glyph — the default
102
+ chevron or a custom `SlideButton.Thumb` child — fades out under a `Spinner`
103
+ that inherits the glyph's size and colour through the handle's icon defaults,
104
+ the drag and the screen reader's activate are refused (`isSlideDraggable`), and
105
+ the rail reports `busy`. It does not dim: the slide landed and the work is
106
+ running, where dimming says the control is unavailable. The control does not
107
+ infer it from a pending `onComplete` promise — the caller owns the request, so
108
+ the caller says when it is running, the same way `Button` never guesses.
93
109
  - **One `Gesture.Pan()` on the rail, `activeOffsetX ±8`, `failOffsetY ±12`.** A
94
110
  vertical scroll that starts on the rail fails the pan first, so the control
95
111
  survives inside a vertical `ScrollView`. A cancelled pan returns home.
@@ -46,7 +46,12 @@ export function SlideButtonLabel({ className, ...props }: SlideButtonLabelProps)
46
46
 
47
47
  const clipStyle = useAnimatedStyle(() => ({
48
48
  opacity: handleWidth.value > 0 ? resolveSlideTrailOpacity(offset.value) : 0,
49
- width: resolveSlideTrailWidth({ handleWidth: handleWidth.value, inset: SLIDE_BUTTON_INSET, offset: offset.value }),
49
+ width: resolveSlideTrailWidth({
50
+ handleWidth: handleWidth.value,
51
+ inset: SLIDE_BUTTON_INSET,
52
+ offset: offset.value,
53
+ travel: travel.value,
54
+ }),
50
55
  }));
51
56
 
52
57
  const onTrailStyle = useAnimatedStyle(() => ({
@@ -1,11 +1,12 @@
1
- import { type ReactElement, type ReactNode, useMemo } from "react";
1
+ import { type ReactElement, type ReactNode, useEffect, useMemo } from "react";
2
2
  import type { ViewProps } from "react-native";
3
- import Animated, { useAnimatedStyle } from "react-native-reanimated";
3
+ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
4
4
  import { useThemeColor } from "../../hooks/use-theme-color";
5
5
  import { IconCheckmark1Small, IconChevronLeft, IconChevronRight } from "../../icons/central";
6
6
  import { Icon, IconDefaultsProvider } from "../icon";
7
+ import { Spinner } from "../spinner";
7
8
  import { useSlideButtonPart } from "./slide-button.context";
8
- import { SLIDE_BUTTON_GLYPH_TOKEN, slideButtonVariants } from "./slide-button.variants";
9
+ import { SLIDE_BUTTON_GLYPH_MS, SLIDE_BUTTON_GLYPH_TOKEN, slideButtonVariants } from "./slide-button.variants";
9
10
 
10
11
  export type SlideButtonThumbProps = Omit<ViewProps, "children" | "style"> & {
11
12
  /** Replaces the chevron. An `Icon` inherits the handle's glyph size and colour. */
@@ -32,9 +33,13 @@ export type SlideButtonThumbProps = Omit<ViewProps, "children" | "style"> & {
32
33
  * With no children it draws a chevron pointing the way it travels (mirrored
33
34
  * under RTL), which crosses into a tick when the slide confirms. Children replace
34
35
  * both: a custom glyph is the caller's to change.
36
+ *
37
+ * While the root is `isLoading` the glyph — default or custom — fades out under a
38
+ * `Spinner` that takes the glyph's size and colour from the same icon defaults, so
39
+ * the handle shows the work running without a glyph of its own to restyle.
35
40
  */
36
41
  export function SlideButtonThumb({ className, children, ...props }: SlideButtonThumbProps): ReactElement {
37
- const { size, variant, isRTL, offset, handleWidth } = useSlideButtonPart("SlideButton.Thumb");
42
+ const { size, variant, isRTL, isLoading, offset, handleWidth } = useSlideButtonPart("SlideButton.Thumb");
38
43
  const direction = isRTL ? -1 : 1;
39
44
 
40
45
  const thumbStyle = useAnimatedStyle(() => ({
@@ -43,6 +48,12 @@ export function SlideButtonThumb({ className, children, ...props }: SlideButtonT
43
48
  width: handleWidth.value,
44
49
  }));
45
50
 
51
+ const loading = useSharedValue(isLoading ? 1 : 0);
52
+ useEffect(() => {
53
+ loading.value = withTiming(isLoading ? 1 : 0, { duration: SLIDE_BUTTON_GLYPH_MS });
54
+ }, [isLoading, loading]);
55
+ const glyphStyle = useAnimatedStyle(() => ({ opacity: 1 - loading.value }));
56
+
46
57
  const slots = slideButtonVariants({ size, variant });
47
58
  const glyphColor = useThemeColor(SLIDE_BUTTON_GLYPH_TOKEN);
48
59
  const glyphClassName = slots.thumbGlyph();
@@ -53,7 +64,12 @@ export function SlideButtonThumb({ className, children, ...props }: SlideButtonT
53
64
 
54
65
  return (
55
66
  <Animated.View className={slots.thumb({ className })} style={thumbStyle} {...props}>
56
- <IconDefaultsProvider value={iconDefaults}>{children ?? <SlideButtonGlyph />}</IconDefaultsProvider>
67
+ <IconDefaultsProvider value={iconDefaults}>
68
+ <Animated.View className="items-center justify-center" style={glyphStyle}>
69
+ {children ?? <SlideButtonGlyph />}
70
+ </Animated.View>
71
+ <Spinner className="absolute" isLoading={isLoading} />
72
+ </IconDefaultsProvider>
57
73
  </Animated.View>
58
74
  );
59
75
  }
@@ -8,6 +8,8 @@ export type SlideButtonContextValue = {
8
8
  /** Whether the slide has been confirmed, on the **JS thread** — what accessibility reads. */
9
9
  isCompleted: boolean;
10
10
  isDisabled: boolean;
11
+ /** Whether work is running — the handle shows a spinner and takes no drag. */
12
+ isLoading: boolean;
11
13
  /** Whether the layout runs right to left, so the handle travels leftwards. */
12
14
  isRTL: boolean;
13
15
  /**
@@ -25,6 +25,7 @@ import { type HapticFeedback, playHaptic } from "../pressable";
25
25
  import { type SlideButtonContextValue, SlideButtonProvider } from "./slide-button.context";
26
26
  import {
27
27
  isSlideArmed,
28
+ isSlideDraggable,
28
29
  resolveSlideHandleWidth,
29
30
  resolveSlideRelease,
30
31
  resolveSlideThreshold,
@@ -74,6 +75,11 @@ export type SlideButtonProps = Omit<ViewProps, "children" | "style"> & {
74
75
  autoResetDelay?: number;
75
76
  /** Fades the control and refuses the drag — the handle does not move at all. */
76
77
  isDisabled?: boolean;
78
+ /**
79
+ * Swaps the handle's glyph for a spinner, refuses the drag and announces the
80
+ * control as busy. Does not dim it — the slide landed, the work is running.
81
+ */
82
+ isLoading?: boolean;
77
83
  /** Played as a drag crosses the threshold; a success knock follows on commit. Off by default. */
78
84
  haptic?: false | HapticFeedback;
79
85
  /** What the screen reader's activate action is called. Imperative, naming the outcome. Default "Confirm". */
@@ -94,6 +100,7 @@ function SlideButtonRoot({
94
100
  isAutoReset = false,
95
101
  autoResetDelay = SLIDE_BUTTON_DEFAULT_AUTO_RESET_MS,
96
102
  isDisabled = false,
103
+ isLoading = false,
97
104
  haptic = false,
98
105
  accessibilityActionLabel = "Confirm",
99
106
  accessibilityLabel,
@@ -213,7 +220,7 @@ function SlideButtonRoot({
213
220
  .failOffsetY([-SLIDE_BUTTON_FAIL_OFFSET_Y, SLIDE_BUTTON_FAIL_OFFSET_Y])
214
221
  .onStart(() => {
215
222
  "worklet";
216
- if (isDisabled || pinned.value) return;
223
+ if (!isSlideDraggable({ isDisabled, isLoading, isPinned: pinned.value })) return;
217
224
  cancelAnimation(offset);
218
225
  grabbed.value = offset.value;
219
226
  armed.value = false;
@@ -221,7 +228,7 @@ function SlideButtonRoot({
221
228
  .onUpdate((event) => {
222
229
  "worklet";
223
230
  const span = travel.value;
224
- if (isDisabled || pinned.value || span <= 0) return;
231
+ if (!isSlideDraggable({ isDisabled, isLoading, isPinned: pinned.value }) || span <= 0) return;
225
232
 
226
233
  // No easing on the way out: the handle tracks the finger exactly.
227
234
  const next = Math.min(span, Math.max(0, grabbed.value + event.translationX * direction));
@@ -236,7 +243,7 @@ function SlideButtonRoot({
236
243
  })
237
244
  .onEnd((event, success) => {
238
245
  "worklet";
239
- if (isDisabled || pinned.value) return;
246
+ if (!isSlideDraggable({ isDisabled, isLoading, isPinned: pinned.value })) return;
240
247
  armed.value = false;
241
248
  const velocity = event.velocityX * direction;
242
249
  const release = resolveSlideRelease({
@@ -265,6 +272,7 @@ function SlideButtonRoot({
265
272
  grabbed,
266
273
  haptic,
267
274
  isDisabled,
275
+ isLoading,
268
276
  isReducedMotion,
269
277
  offset,
270
278
  pinned,
@@ -273,11 +281,11 @@ function SlideButtonRoot({
273
281
  ]);
274
282
 
275
283
  const activate = useCallback(() => {
276
- if (isDisabled || pinned.value) return;
284
+ if (!isSlideDraggable({ isDisabled, isLoading, isPinned: pinned.value })) return;
277
285
  pinned.value = true;
278
286
  moveTo(travel.value);
279
287
  complete();
280
- }, [complete, isDisabled, moveTo, pinned, travel]);
288
+ }, [complete, isDisabled, isLoading, moveTo, pinned, travel]);
281
289
 
282
290
  const handleAccessibilityAction = useCallback(
283
291
  (event: AccessibilityActionEvent) => {
@@ -297,13 +305,14 @@ function SlideButtonRoot({
297
305
  handleWidth,
298
306
  isCompleted: completed,
299
307
  isDisabled,
308
+ isLoading,
300
309
  isRTL,
301
310
  offset,
302
311
  size,
303
312
  travel,
304
313
  variant,
305
314
  }),
306
- [completed, glyph, handleWidth, isDisabled, offset, size, travel, variant]
315
+ [completed, glyph, handleWidth, isDisabled, isLoading, offset, size, travel, variant]
307
316
  );
308
317
 
309
318
  const content = useMemo(() => withThumb(children), [children]);
@@ -317,7 +326,7 @@ function SlideButtonRoot({
317
326
  accessibilityActions={accessibilityActions}
318
327
  accessibilityLabel={label}
319
328
  accessibilityRole="button"
320
- accessibilityState={{ checked: completed, disabled: isDisabled }}
329
+ accessibilityState={{ busy: isLoading, checked: completed, disabled: isDisabled }}
321
330
  accessible
322
331
  className={slots.root({ className })}
323
332
  onAccessibilityAction={handleAccessibilityAction}
@@ -325,7 +334,7 @@ function SlideButtonRoot({
325
334
  onLayout={handleLayout}
326
335
  {...props}
327
336
  >
328
- <SlideButtonTrail className={slots.trail()} handleWidth={handleWidth} offset={offset} />
337
+ <SlideButtonTrail className={slots.trail()} handleWidth={handleWidth} offset={offset} travel={travel} />
329
338
  {content}
330
339
  </Animated.View>
331
340
  </GestureDetector>
@@ -339,7 +348,9 @@ function SlideButtonRoot({
339
348
  * It ends *under* the handle rather than at its rear edge: a stadium's rear is a
340
349
  * curve, and a trail that stopped at its straight edge would leave a sliver of
341
350
  * rail showing above and below that curve. Ending under the handle means the two
342
- * read as one shape — the handle dragging its colour along.
351
+ * read as one shape — the handle dragging its colour along. Over the last
352
+ * half-handle of travel it runs on to the rail's far edge, so a confirmed slide
353
+ * reads as filled rather than stopping short of the end.
343
354
  *
344
355
  * Internal: the root renders it, so it lives here rather than in a part file.
345
356
  */
@@ -347,14 +358,21 @@ function SlideButtonTrail({
347
358
  className,
348
359
  offset,
349
360
  handleWidth,
361
+ travel,
350
362
  }: {
351
363
  className: string;
352
364
  offset: SharedValue<number>;
353
365
  handleWidth: SharedValue<number>;
366
+ travel: SharedValue<number>;
354
367
  }): ReactElement {
355
368
  const style = useAnimatedStyle(() => ({
356
369
  opacity: handleWidth.value > 0 ? resolveSlideTrailOpacity(offset.value) : 0,
357
- width: resolveSlideTrailWidth({ handleWidth: handleWidth.value, inset: SLIDE_BUTTON_INSET, offset: offset.value }),
370
+ width: resolveSlideTrailWidth({
371
+ handleWidth: handleWidth.value,
372
+ inset: SLIDE_BUTTON_INSET,
373
+ offset: offset.value,
374
+ travel: travel.value,
375
+ }),
358
376
  }));
359
377
  return <Animated.View className={className} style={style} />;
360
378
  }
@@ -427,6 +445,11 @@ function isPart(child: ReactNode, part: { displayName?: string }): boolean {
427
445
  * The label is centred in the whole rail and never fades. A `SlideButton.Thumb` is
428
446
  * composed in when none is written; write one to put a different glyph on the handle.
429
447
  *
448
+ * **`isLoading` shows the work.** The handle's glyph gives way to a spinner, the
449
+ * drag is refused and the rail announces itself busy — `Button`'s `isLoading`,
450
+ * on the handle. Pair it with a controlled `isCompleted` to show slow work
451
+ * running while the handle waits at the end.
452
+ *
430
453
  * Under RTL the handle rests at the right and travels left. A screen reader's
431
454
  * activate confirms it outright, named by `accessibilityActionLabel`.
432
455
  *
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { aliasedTokens } from "../../styles/theme-tokens.test";
3
3
  import {
4
4
  isSlideArmed,
5
+ isSlideDraggable,
5
6
  resolveSlideHandleWidth,
6
7
  resolveSlideLabelGutter,
7
8
  resolveSlideRelease,
@@ -49,9 +50,31 @@ describe("resolveSlideTrailOpacity", () => {
49
50
  });
50
51
 
51
52
  describe("resolveSlideTrailWidth", () => {
52
- test("runs from the rail's start edge to the handle's middle", () => {
53
- expect(resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 0 })).toBe(33);
54
- expect(resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 100 })).toBe(133);
53
+ test("runs from the rail's start edge to the handle's middle at rest", () => {
54
+ expect(resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 0, travel: 200 })).toBe(33);
55
+ });
56
+
57
+ test("reaches the rail's far edge at the end of the travel", () => {
58
+ expect(resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 200, travel: 200 })).toBe(200 + 58 + 4 * 2);
59
+ });
60
+
61
+ test("holds the handle's middle until the last half-handle of travel", () => {
62
+ expect(resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 100, travel: 200 })).toBe(133);
63
+ expect(resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 167, travel: 200 })).toBe(200);
64
+ });
65
+
66
+ test("closes the gap to the far edge over that last stretch", () => {
67
+ const width = resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 183.5, travel: 200 });
68
+ expect(width).toBeCloseTo(4 + 183.5 + 29 + 16.5);
69
+ expect(width).toBeLessThan(4 + 183.5 + 58);
70
+ });
71
+
72
+ test("never runs past the rail, even while a spring overshoots", () => {
73
+ expect(resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 212, travel: 200 })).toBe(266);
74
+ });
75
+
76
+ test("holds the handle's middle before the rail is measured", () => {
77
+ expect(resolveSlideTrailWidth({ handleWidth: 58, inset: 4, offset: 0, travel: 0 })).toBe(33);
55
78
  });
56
79
  });
57
80
 
@@ -286,3 +309,15 @@ describe("slideButtonVariants", () => {
286
309
  expect(ALIASED.has(`--color-${SLIDE_BUTTON_GLYPH_TOKEN}`)).toBe(true);
287
310
  });
288
311
  });
312
+
313
+ describe("isSlideDraggable", () => {
314
+ test("an idle handle takes the drag", () => {
315
+ expect(isSlideDraggable({ isDisabled: false, isLoading: false, isPinned: false })).toBe(true);
316
+ });
317
+
318
+ test("disabled, loading or pinned at the far end, it refuses", () => {
319
+ expect(isSlideDraggable({ isDisabled: true, isLoading: false, isPinned: false })).toBe(false);
320
+ expect(isSlideDraggable({ isDisabled: false, isLoading: true, isPinned: false })).toBe(false);
321
+ expect(isSlideDraggable({ isDisabled: false, isLoading: false, isPinned: true })).toBe(false);
322
+ });
323
+ });
@@ -222,6 +222,26 @@ export function resolveSlideHandleWidth({ railHeight, inset }: { railHeight: num
222
222
  return height > 0 ? Math.round(height * SLIDE_BUTTON_HANDLE_RATIO) : 0;
223
223
  }
224
224
 
225
+ /**
226
+ * Whether the handle takes a drag — or a screen reader's activate — right now.
227
+ *
228
+ * Disabled refuses it, and so does loading: the work a slide asked for is still
229
+ * running, and a second slide would ask again. Pinned at the far end, confirmed or
230
+ * awaiting an answer, the handle has nowhere left to go.
231
+ */
232
+ export function isSlideDraggable({
233
+ isDisabled,
234
+ isLoading,
235
+ isPinned,
236
+ }: {
237
+ isDisabled: boolean;
238
+ isLoading: boolean;
239
+ isPinned: boolean;
240
+ }): boolean {
241
+ "worklet";
242
+ return !isDisabled && !isLoading && !isPinned;
243
+ }
244
+
225
245
  /**
226
246
  * Whether the handle is far enough along that letting go here would confirm —
227
247
  * what the arming haptic reports.
@@ -254,24 +274,39 @@ export function resolveSlideTrailOpacity(offset: number): number {
254
274
  }
255
275
 
256
276
  /**
257
- * How wide the trail is: from the rail's start edge to the handle's middle.
277
+ * How wide the trail is: from the rail's start edge to the handle's middle, and
278
+ * over the last half-handle of travel, on to the rail's far edge.
258
279
  *
259
280
  * It ends *under* the handle rather than at its rear edge — a stadium's rear is
260
281
  * a curve, and a trail stopping at the straight edge leaves a sliver of rail
261
- * above and below it. The label's trail-coloured copy is clipped to the same
262
- * width, so the two cannot disagree.
282
+ * above and below it. Held at the middle all the way, though, a confirmed
283
+ * handle left the rail showing around its front curve and in the inset beyond
284
+ * it, so the fill read as stopping short. Over the final `handleWidth / 2 +
285
+ * inset` points the lead edge closes that gap, landing on the far edge exactly
286
+ * at the end of the travel. Clamped to the rail, so a spring's overshoot cannot
287
+ * run it past. The label's trail-coloured copy is clipped to the same width, so
288
+ * the two cannot disagree.
263
289
  */
264
290
  export function resolveSlideTrailWidth({
265
291
  offset,
266
292
  handleWidth,
267
293
  inset,
294
+ travel,
268
295
  }: {
269
296
  offset: number;
270
297
  handleWidth: number;
271
298
  inset: number;
299
+ travel: number;
272
300
  }): number {
273
301
  "worklet";
274
- return inset + offset + handleWidth / 2;
302
+ const middle = inset + offset + handleWidth / 2;
303
+ if (travel <= 0) return middle;
304
+ const ramp = handleWidth / 2 + inset;
305
+ const into = offset - (travel - ramp);
306
+ const extension = into <= 0 ? 0 : into >= ramp ? ramp : into;
307
+ const width = middle + extension;
308
+ const rail = travel + handleWidth + inset * 2;
309
+ return width > rail ? rail : width;
275
310
  }
276
311
 
277
312
  /**