@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 +3 -3
- package/src/components/slide-button/AGENTS.md +17 -1
- package/src/components/slide-button/slide-button-label.tsx +6 -1
- package/src/components/slide-button/slide-button-thumb.tsx +21 -5
- package/src/components/slide-button/slide-button.context.tsx +2 -0
- package/src/components/slide-button/slide-button.tsx +33 -10
- package/src/components/slide-button/slide-button.variants.test.ts +38 -3
- package/src/components/slide-button/slide-button.variants.ts +39 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
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.
|
|
186
|
-
"@delacour/react-native-charts": "0.2.0-alpha.
|
|
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 `
|
|
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({
|
|
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}>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
|
|
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.
|
|
262
|
-
*
|
|
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
|
-
|
|
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
|
/**
|