@delacour/react-native-ui 0.1.0-alpha.20261009011834 → 0.1.0-alpha.20261009012154

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.1.0-alpha.20261009011834",
3
+ "version": "0.1.0-alpha.20261009012154",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,6 +64,7 @@
64
64
  "./popover": "./src/components/popover/index.ts",
65
65
  "./pressable": "./src/components/pressable/index.ts",
66
66
  "./progress": "./src/components/progress/index.ts",
67
+ "./progress-button": "./src/components/progress-button/index.ts",
67
68
  "./provider": "./src/components/provider/index.ts",
68
69
  "./radio": "./src/components/radio/index.ts",
69
70
  "./rating": "./src/components/rating/index.ts",
@@ -115,8 +116,8 @@
115
116
  },
116
117
  "peerDependencies": {
117
118
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
118
- "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261009011834",
119
- "@delacour/react-native-charts": "0.1.0-alpha.20261009011834",
119
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261009012154",
120
+ "@delacour/react-native-charts": "0.1.0-alpha.20261009012154",
120
121
  "@legendapp/list": ">=3.3",
121
122
  "expo-linear-gradient": ">=15",
122
123
  "expo-router": ">=57",
@@ -0,0 +1,116 @@
1
+ # ProgressButton
2
+
3
+ A button that has to be **held**, not tapped. A fill grows from the leading edge for
4
+ `holdDuration`, and the action fires when it reaches the end. Compound root plus
5
+ `ProgressButton.Label` and `ProgressButton.Done`. For an irreversible action that would
6
+ otherwise need a confirmation dialog — erasing, paying. Two taps in a row are a rhythm a hand
7
+ falls into; a two-second hold cannot be done by accident or by habit.
8
+
9
+ `import { ProgressButton } from "@delacour/react-native-ui/progress-button";`
10
+
11
+ ## Files
12
+
13
+ | File | What it holds |
14
+ | --- | --- |
15
+ | `index.ts` | → `@delacour/react-native-ui/progress-button` |
16
+ | `progress-button.tsx` | Root + the `Object.assign` compound surface: the gesture, the fill, the two label layers, the done mark |
17
+ | `progress-button-label.tsx` | `ProgressButton.Label`, coloured by the layer it renders in |
18
+ | `progress-button-done.tsx` | `ProgressButton.Done`, the default tick or the caller's content |
19
+ | `progress-button.context.tsx` | `ProgressButtonContext`, the layer context, `useProgressButton()` |
20
+ | `progress-button.variants.ts` | Pure `tv()` slots, colour tables, and the hold, playback, stepping and reset resolvers |
21
+ | `progress-button.variants.test.ts` | |
22
+
23
+ ## Design
24
+
25
+ - **Variants**: `primary`, `secondary`, `destructive`, `success`. **Sizes**: `sm`, `md`, `lg`.
26
+ **Shapes**: `pill` (default), `rounded`. There is no square `icon-*` size: a hold has to
27
+ say what it does, and a glyph alone cannot carry "hold to erase".
28
+ - **The box is the button's box.** `h-button-*`, the button's `px-*`, `text-button-*`,
29
+ `size-icon-*` and, as a pill, `rounded-button-*` — the tokens `buttonVariants` reads, and a
30
+ test pins them equal to the button's own, so the two sit level in a row and a retune moves
31
+ both. `rounded` is the card corner, `rounded-lg`, so the button sits square with a
32
+ `Button` given the same class beside it. No token is minted.
33
+ - **Every variant rests on `bg-secondary` and carries its colour in the label and the fill.**
34
+ A resting button that is already solid red has nothing left to say when the fill arrives.
35
+ `secondary` fills with `foreground` and draws its inner label on `background`, because a
36
+ `secondary` fill on a `secondary` surface would not be seen and `secondary-foreground` on a
37
+ `foreground` fill is the same colour twice. The three colour tables are exported and a test
38
+ checks every token in them exists in both themes.
39
+ - **The label is drawn twice.** Once on the surface in the variant colour, once inside the
40
+ clipped fill in the variant's foreground, laid out at the button's **measured** width
41
+ (`onLayout`), so both copies wrap identically and the wipe's edge cuts through a glyph. A
42
+ single label under a translucent wash goes muddy exactly at the boundary, which is where
43
+ the eye is. A test asserts the two label slots differ in colour and nothing else.
44
+ `ProgressButton.Label` reads which copy it is from a layer context rather than a prop the
45
+ caller would have to repeat; a composed `Icon` takes the layer's colour through
46
+ `IconDefaultsProvider`, the same mechanism `Button` uses.
47
+ - **The fill's copy and the done mark are hidden from assistive technology.** The root is the
48
+ one accessible element; announcing the label twice would be the fill's bookkeeping leaking
49
+ into a screen reader.
50
+ - **Bare strings are wrapped in a `ProgressButton.Label`**, consecutive ones collapsing into
51
+ one, for the reason `Button` gives: React Native crashes on text outside a `<Text>`.
52
+ - **`ProgressButton.Done` is lifted out of the children by type** and drawn over the full fill.
53
+ With none written the root draws one, which is a full-size `IconCheckmark2` at `size-icon-*` (the `Small` glyph, alone on a button, read as a speck) in
54
+ the fill's foreground. A caller's `Done` replaces it whole.
55
+
56
+ ## Behaviour
57
+
58
+ - **The gesture is a pan with no minimum distance**, `onBegin` to `onFinalize`. A long-press
59
+ gesture fires once and cannot tell "still holding" from "held long enough".
60
+ `shouldCancelWhenOutside(false)`, and a finger may drift 16pt
61
+ (`PROGRESS_BUTTON_MAX_DRIFT`) before the hold lets go, because a hand resting on a control for
62
+ two seconds moves.
63
+ - **Pressing runs the fill toward 1 over what is left** — `holdDuration × (1 − p)` — so a second
64
+ attempt resumes rather than restarting. **Releasing runs it back over what is filled** —
65
+ `holdDuration × p` — the same rate, played backwards, never a snap to empty.
66
+ `resolveRemainingDuration` is the tested copy; the worklets restate it inline because a
67
+ worklet body must be self-contained.
68
+ - **Completion is read from the animation**, in the timing's finish callback (`finished` at 1),
69
+ then crosses to JS with `scheduleOnRN`. Never a JS `setTimeout` running alongside: two clocks
70
+ disagree under load, and the one the user can see is the fill. There is no tolerance near the
71
+ end — released at 99%, nothing fires.
72
+ - **Completed, it is locked.** A shared `isLocked` flag makes the gesture ignore further presses
73
+ and stops a release from draining a finished fill, until a reset: `isAutoReset` after
74
+ `autoResetDelay` (a timer is fine here — it schedules the reset, not the completion), or a
75
+ controlled `isCompleted` going `false`. **A reset travels** back over
76
+ `PROGRESS_BUTTON_TRAVEL_MS` scaled by how full the fill is; it is never set to 0. A completion
77
+ set from outside travels forward the same way.
78
+ - **Controlled, the caller has to accept a hold in the same update.** A completed hold calls
79
+ `onCompletedChange(true)` and `onComplete`. If `isCompleted` is still `false` once those have
80
+ run, the fill travels back — the caller declined. The alternative, sitting full and locked
81
+ until the prop changes, leaves a caller who declines with no prop change to make, so the
82
+ button would be stuck. A caller with async work accepts at once (`isCompleted` true) and shows
83
+ progress in a custom `Done`, then sets `false` to rewind on failure; the `controlled` demo does
84
+ exactly that.
85
+ - **Haptics are off by default.** `haptic` plays when the hold takes, and a `success` knock
86
+ follows on completion, both from the UI thread through `playHaptic`.
87
+ - **`isDisabled`** fades the button (`opacity-50`) and turns the gesture off, so the fill never
88
+ starts. A hold in progress when it flips is cancelled and plays back.
89
+
90
+ ## Reduced motion
91
+
92
+ - **The fill steps in fifths instead of sweeping.** `Math.floor(p × 5) / 5`, applied in the
93
+ animated style, rounding **down** so it never shows more than the hold has earned. It stays an
94
+ indicator, because a control that asks you to wait and shows nothing is broken.
95
+ `resolveSteppedProgress` is the tested copy.
96
+ - **Every timing opts out of the reduce-motion policy** (`ReduceMotion.Never`). Under the default
97
+ policy Reanimated completes a timing instantly when the setting is on — so a two-second hold
98
+ would complete on touch-down, which is the one thing this component exists to prevent. The
99
+ stepping is what reduces the motion; the clock underneath has to keep real time.
100
+ - **The done mark fades without scaling** under reduced motion.
101
+
102
+ ## Accessibility
103
+
104
+ - `accessibilityRole="button"`, `accessibilityHint` (default: "Press and hold to confirm"), and
105
+ `accessibilityState={{ disabled, checked: isCompleted }}`.
106
+ - **Decision: activate completes.** The root declares an `activate` accessibility action, and a
107
+ screen reader's activate completes the button outright. A screen reader cannot hold, the hint
108
+ has already announced what the button does, and refusing would leave the action unreachable
109
+ for anyone using one. The slide-to-confirm control makes the same call.
110
+
111
+ ## Out of scope
112
+
113
+ - An `icon-*` square size, and a loading state of its own — a caller shows work in a custom
114
+ `Done`.
115
+ - Progress that is not time-based (a fill driven by an upload). That is `Progress`.
116
+ - Cancelling a completed hold from inside the button. Reset is `isAutoReset` or the caller's.
@@ -0,0 +1,36 @@
1
+ export { ProgressButton, type ProgressButtonProps } from "./progress-button";
2
+ export {
3
+ type ProgressButtonContextValue,
4
+ type ProgressButtonLayer,
5
+ ProgressButtonProvider,
6
+ useProgressButton,
7
+ useProgressButtonContext,
8
+ } from "./progress-button.context";
9
+ export {
10
+ PROGRESS_BUTTON_CROSSFADE_MS,
11
+ PROGRESS_BUTTON_DEFAULT_AUTO_RESET_MS,
12
+ PROGRESS_BUTTON_DEFAULT_HINT,
13
+ PROGRESS_BUTTON_DEFAULT_HOLD_MS,
14
+ PROGRESS_BUTTON_FILL_FOREGROUND_TOKEN,
15
+ PROGRESS_BUTTON_FILL_TOKEN,
16
+ PROGRESS_BUTTON_LABEL_TOKEN,
17
+ PROGRESS_BUTTON_MAX_DRIFT,
18
+ PROGRESS_BUTTON_MIN_HOLD_MS,
19
+ PROGRESS_BUTTON_REDUCED_MOTION_STEPS,
20
+ PROGRESS_BUTTON_SHAPES,
21
+ PROGRESS_BUTTON_SIZES,
22
+ PROGRESS_BUTTON_TRAVEL_MS,
23
+ PROGRESS_BUTTON_VARIANTS,
24
+ type ProgressButtonShape,
25
+ type ProgressButtonSize,
26
+ type ProgressButtonVariant,
27
+ type ProgressButtonVariantProps,
28
+ progressButtonVariants,
29
+ resolveAutoResetDelay,
30
+ resolveHoldDuration,
31
+ resolveProgressButtonAccessibilityState,
32
+ resolveRemainingDuration,
33
+ resolveSteppedProgress,
34
+ } from "./progress-button.variants";
35
+ export type { ProgressButtonDoneProps } from "./progress-button-done";
36
+ export type { ProgressButtonLabelProps } from "./progress-button-label";
@@ -0,0 +1,33 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { IconCheckmark2 } from "../../icons/central";
4
+ import { cn } from "../../lib/cn";
5
+ import { Icon } from "../icon";
6
+ import { useProgressButtonPart } from "./progress-button.context";
7
+
8
+ export type ProgressButtonDoneProps = ViewProps & {
9
+ /** Replaces the default tick. A bare `Icon` inherits the fill's foreground and the button's icon size. */
10
+ children?: ReactNode;
11
+ className?: string;
12
+ };
13
+
14
+ /**
15
+ * What the button shows once the hold completes.
16
+ *
17
+ * The root lifts this part out of its children by type and draws it over the
18
+ * full fill, fading and scaling it in as the labels fade out. With no children
19
+ * it draws a tick; the root draws one of these by itself when the caller writes
20
+ * none, so the tick is there by default.
21
+ *
22
+ * Size and colour come from the `IconDefaultsProvider` the root wraps it in, so
23
+ * a composed `Icon` matches the default tick with nothing said at the call site.
24
+ */
25
+ export function ProgressButtonDone({ children, className, ...props }: ProgressButtonDoneProps): ReactElement {
26
+ useProgressButtonPart("ProgressButton.Done");
27
+ return (
28
+ <View className={cn("flex-row items-center justify-center gap-2", className)} {...props}>
29
+ {children ?? <Icon icon={IconCheckmark2} />}
30
+ </View>
31
+ );
32
+ }
33
+ ProgressButtonDone.displayName = "DelacourUI.ProgressButton.Done";
@@ -0,0 +1,27 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { TextProps } from "react-native";
3
+ import { Text } from "../text";
4
+ import { useProgressButtonLayer, useProgressButtonPart } from "./progress-button.context";
5
+ import { progressButtonVariants } from "./progress-button.variants";
6
+
7
+ export type ProgressButtonLabelProps = Omit<TextProps, "children"> & {
8
+ children: ReactNode;
9
+ className?: string;
10
+ };
11
+
12
+ /**
13
+ * The button's text.
14
+ *
15
+ * Rendered twice by the root, once per layer, and picks its colour from the
16
+ * layer it is in: the variant colour on the resting surface, the variant's
17
+ * foreground inside the fill. The two copies share every other class, so they
18
+ * wrap the same and the wipe's edge falls inside a glyph.
19
+ */
20
+ export function ProgressButtonLabel({ className, ...props }: ProgressButtonLabelProps): ReactElement {
21
+ const { variant, size } = useProgressButtonPart("ProgressButton.Label");
22
+ const layer = useProgressButtonLayer();
23
+ const slots = progressButtonVariants({ size, variant });
24
+ const resolved = layer === "fill" ? slots.fillLabel({ className }) : slots.label({ className });
25
+ return <Text className={resolved} {...props} />;
26
+ }
27
+ ProgressButtonLabel.displayName = "DelacourUI.ProgressButton.Label";
@@ -0,0 +1,93 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { SharedValue } from "react-native-reanimated";
3
+ import type { ProgressButtonSize, ProgressButtonVariant } from "./progress-button.variants";
4
+
5
+ export type ProgressButtonContextValue = {
6
+ variant: ProgressButtonVariant;
7
+ size: ProgressButtonSize;
8
+ /** Whether the hold has completed and not yet been reset. */
9
+ isCompleted: boolean;
10
+ isDisabled: boolean;
11
+ /** How far the fill has got, 0 to 1, on the UI thread. */
12
+ progress: SharedValue<number>;
13
+ };
14
+
15
+ /**
16
+ * Which copy of the children a part is rendering in.
17
+ *
18
+ * The children are drawn twice — once on the resting surface and once inside
19
+ * the fill — and a `ProgressButton.Label` picks its colour from this rather
20
+ * than from a prop the caller would have to repeat.
21
+ */
22
+ export type ProgressButtonLayer = "surface" | "fill";
23
+
24
+ const ProgressButtonContext = createContext<ProgressButtonContextValue | null>(null);
25
+ const ProgressButtonLayerContext = createContext<ProgressButtonLayer>("surface");
26
+
27
+ /**
28
+ * Supplies the enclosing button's state to its subtree.
29
+ *
30
+ * In a leaf of its own, importing nothing but types, so a part can read it
31
+ * without importing `./progress-button` and closing a cycle (package AGENTS.md
32
+ * rule 3).
33
+ */
34
+ export function ProgressButtonProvider({
35
+ value,
36
+ children,
37
+ }: {
38
+ value: ProgressButtonContextValue;
39
+ children: ReactNode;
40
+ }): ReactElement {
41
+ return <ProgressButtonContext value={value}>{children}</ProgressButtonContext>;
42
+ }
43
+ ProgressButtonProvider.displayName = "DelacourUI.ProgressButton.Provider";
44
+
45
+ /** Marks which copy of the children — surface or fill — its subtree is. */
46
+ export function ProgressButtonLayerProvider({
47
+ value,
48
+ children,
49
+ }: {
50
+ value: ProgressButtonLayer;
51
+ children: ReactNode;
52
+ }): ReactElement {
53
+ return <ProgressButtonLayerContext value={value}>{children}</ProgressButtonLayerContext>;
54
+ }
55
+ ProgressButtonLayerProvider.displayName = "DelacourUI.ProgressButton.LayerProvider";
56
+
57
+ /** The enclosing button's state, or null outside a `<ProgressButton>`. */
58
+ export function useProgressButtonContext(): ProgressButtonContextValue | null {
59
+ return use(ProgressButtonContext);
60
+ }
61
+
62
+ /**
63
+ * Reads the enclosing button's state.
64
+ *
65
+ * For a custom child that changes with the hold — `progress` is a shared value,
66
+ * so a child can drive an animated style off it without a render per frame.
67
+ * Throws outside a `<ProgressButton>`.
68
+ */
69
+ export function useProgressButton(): ProgressButtonContextValue {
70
+ const context = useProgressButtonContext();
71
+ if (!context) {
72
+ throw new Error("useProgressButton must be called inside a <ProgressButton>.");
73
+ }
74
+ return context;
75
+ }
76
+
77
+ /**
78
+ * The enclosing button's state, for a compound part that cannot work without it.
79
+ *
80
+ * Internal: deliberately not re-exported from `index.ts`.
81
+ */
82
+ export function useProgressButtonPart(component: string): ProgressButtonContextValue {
83
+ const context = useProgressButtonContext();
84
+ if (!context) {
85
+ throw new Error(`${component} must be rendered inside a <ProgressButton>.`);
86
+ }
87
+ return context;
88
+ }
89
+
90
+ /** Which copy of the children this part is in. `surface` outside a button. */
91
+ export function useProgressButtonLayer(): ProgressButtonLayer {
92
+ return use(ProgressButtonLayerContext);
93
+ }
@@ -0,0 +1,414 @@
1
+ import {
2
+ Children,
3
+ type ComponentRef,
4
+ isValidElement,
5
+ type ReactElement,
6
+ type ReactNode,
7
+ type Ref,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useState,
12
+ } from "react";
13
+ import type { AccessibilityActionEvent, LayoutChangeEvent, ViewProps } from "react-native";
14
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
15
+ import Animated, {
16
+ Easing,
17
+ ReduceMotion,
18
+ useAnimatedStyle,
19
+ useReducedMotion,
20
+ useSharedValue,
21
+ withTiming,
22
+ } from "react-native-reanimated";
23
+ import { scheduleOnRN } from "react-native-worklets";
24
+ import { useControllableState } from "../../hooks/use-controllable-state";
25
+ import { IconDefaultsProvider } from "../icon";
26
+ import { type HapticFeedback, playHaptic } from "../pressable/pressable";
27
+ import {
28
+ type ProgressButtonContextValue,
29
+ ProgressButtonLayerProvider,
30
+ ProgressButtonProvider,
31
+ } from "./progress-button.context";
32
+ import {
33
+ PROGRESS_BUTTON_CROSSFADE_MS,
34
+ PROGRESS_BUTTON_DEFAULT_HINT,
35
+ PROGRESS_BUTTON_FILL_FOREGROUND_TOKEN,
36
+ PROGRESS_BUTTON_LABEL_TOKEN,
37
+ PROGRESS_BUTTON_MAX_DRIFT,
38
+ PROGRESS_BUTTON_REDUCED_MOTION_STEPS,
39
+ PROGRESS_BUTTON_TRAVEL_MS,
40
+ type ProgressButtonShape,
41
+ type ProgressButtonSize,
42
+ type ProgressButtonVariant,
43
+ progressButtonVariants,
44
+ resolveAutoResetDelay,
45
+ resolveHoldDuration,
46
+ resolveProgressButtonAccessibilityState,
47
+ resolveRemainingDuration,
48
+ } from "./progress-button.variants";
49
+ import { ProgressButtonDone } from "./progress-button-done";
50
+ import { ProgressButtonLabel } from "./progress-button-label";
51
+
52
+ export type ProgressButtonProps = Omit<ViewProps, "children"> & {
53
+ children: ReactNode;
54
+ className?: string;
55
+ /** The colour the label and the fill carry. Every variant rests on the same surface. */
56
+ variant?: ProgressButtonVariant;
57
+ /** The button's own height, padding, label step and icon step. */
58
+ size?: ProgressButtonSize;
59
+ /** `pill` (the default) takes the button's capsule corner; `rounded` takes `rounded-lg`. */
60
+ shape?: ProgressButtonShape;
61
+ /** Stretch to the parent's width. */
62
+ isFullWidth?: boolean;
63
+ /** How long a full hold takes, in milliseconds. Defaults to 2000, floored at 200. */
64
+ holdDuration?: number;
65
+ /** Called once, when the fill reaches the end. */
66
+ onComplete?: () => void;
67
+ /** Controlled completion. */
68
+ isCompleted?: boolean;
69
+ /** Starting completion while uncontrolled. */
70
+ defaultCompleted?: boolean;
71
+ /** Called with `true` when a hold completes, and `false` when the button resets. */
72
+ onCompletedChange?: (isCompleted: boolean) => void;
73
+ /** Rewind by itself after `autoResetDelay`. Off by default. */
74
+ isAutoReset?: boolean;
75
+ /** How long a completed button waits before `isAutoReset` rewinds it. Defaults to 1000. */
76
+ autoResetDelay?: number;
77
+ isDisabled?: boolean;
78
+ /** Played when the hold takes; a `success` knock follows on completion. Off by default. */
79
+ haptic?: false | HapticFeedback;
80
+ /** Defaults to saying the button has to be held. */
81
+ accessibilityHint?: string;
82
+ ref?: Ref<ComponentRef<typeof Animated.View>>;
83
+ };
84
+
85
+ /**
86
+ * Splits the children into the content drawn on each layer and the caller's
87
+ * `ProgressButton.Done`, if one was written.
88
+ *
89
+ * Consecutive strings collapse into one label, so `Erase {count} files` stays a
90
+ * single piece of text rather than three spaced apart by the row's gap.
91
+ */
92
+ function splitChildren(children: ReactNode): { content: ReactNode[]; done: ReactElement | null } {
93
+ const content: ReactNode[] = [];
94
+ let done: ReactElement | null = null;
95
+ let text: string[] = [];
96
+
97
+ const flushText = () => {
98
+ if (text.length === 0) return;
99
+ content.push(<ProgressButtonLabel key={`label-${content.length}`}>{text.join("")}</ProgressButtonLabel>);
100
+ text = [];
101
+ };
102
+
103
+ for (const child of Children.toArray(children)) {
104
+ if (typeof child === "string" || typeof child === "number") {
105
+ text.push(String(child));
106
+ continue;
107
+ }
108
+ flushText();
109
+ if (isValidElement(child) && child.type === ProgressButtonDone) {
110
+ done = child;
111
+ continue;
112
+ }
113
+ content.push(child);
114
+ }
115
+ flushText();
116
+
117
+ return { content, done };
118
+ }
119
+
120
+ function ProgressButtonRoot({
121
+ children,
122
+ className,
123
+ variant = "primary",
124
+ size = "md",
125
+ shape = "pill",
126
+ isFullWidth = false,
127
+ holdDuration: holdDurationProp,
128
+ onComplete,
129
+ isCompleted: isCompletedProp,
130
+ defaultCompleted = false,
131
+ onCompletedChange,
132
+ isAutoReset = false,
133
+ autoResetDelay: autoResetDelayProp,
134
+ isDisabled = false,
135
+ haptic = false,
136
+ accessibilityHint = PROGRESS_BUTTON_DEFAULT_HINT,
137
+ onLayout,
138
+ ref,
139
+ ...props
140
+ }: ProgressButtonProps): ReactElement {
141
+ const holdDuration = resolveHoldDuration(holdDurationProp);
142
+ const autoResetDelay = resolveAutoResetDelay(autoResetDelayProp);
143
+ const isReducedMotion = useReducedMotion();
144
+
145
+ const [isCompleted, setCompleted] = useControllableState<boolean>({
146
+ defaultValue: defaultCompleted,
147
+ onChange: onCompletedChange,
148
+ value: isCompletedProp,
149
+ });
150
+
151
+ // Bumped by every completed hold. A controlled caller that does not accept
152
+ // the completion leaves `isCompleted` false, and without a change to key on
153
+ // the button would sit full and locked forever; this is the change.
154
+ const [holdCount, setHoldCount] = useState(0);
155
+
156
+ const progress = useSharedValue(isCompleted ? 1 : 0);
157
+ // 1 while completed: the gesture worklets read it to refuse a new hold and to
158
+ // leave a completed fill where it is on release.
159
+ const isLocked = useSharedValue(isCompleted ? 1 : 0);
160
+ const isHolding = useSharedValue(0);
161
+ const completion = useSharedValue(isCompleted ? 1 : 0);
162
+ const boxWidth = useSharedValue(0);
163
+ const [measuredWidth, setMeasuredWidth] = useState(0);
164
+
165
+ const complete = useCallback(() => {
166
+ setCompleted(true);
167
+ onComplete?.();
168
+ setHoldCount((count) => count + 1);
169
+ }, [onComplete, setCompleted]);
170
+
171
+ // Follows `isCompleted` whichever side moved it. A completion travels the
172
+ // fill to the end (already there after a hold); a reset travels it back.
173
+ // Neither is ever a jump.
174
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `holdCount` is the re-run trigger, not a value read
175
+ useEffect(() => {
176
+ completion.value = withTiming(isCompleted ? 1 : 0, {
177
+ duration: PROGRESS_BUTTON_CROSSFADE_MS,
178
+ reduceMotion: ReduceMotion.Never,
179
+ });
180
+
181
+ if (isCompleted) {
182
+ isLocked.value = 1;
183
+ const remaining = resolveRemainingDuration({
184
+ direction: "forward",
185
+ holdDuration: PROGRESS_BUTTON_TRAVEL_MS,
186
+ progress: progress.value,
187
+ });
188
+ if (remaining > 0) {
189
+ progress.value = withTiming(1, {
190
+ duration: remaining,
191
+ easing: Easing.out(Easing.cubic),
192
+ reduceMotion: ReduceMotion.Never,
193
+ });
194
+ }
195
+ return;
196
+ }
197
+
198
+ if (isLocked.value === 1) {
199
+ isLocked.value = 0;
200
+ progress.value = withTiming(0, {
201
+ duration: resolveRemainingDuration({
202
+ direction: "reverse",
203
+ holdDuration: PROGRESS_BUTTON_TRAVEL_MS,
204
+ progress: progress.value,
205
+ }),
206
+ easing: Easing.inOut(Easing.cubic),
207
+ reduceMotion: ReduceMotion.Never,
208
+ });
209
+ }
210
+ }, [completion, holdCount, isCompleted, isLocked, progress]);
211
+
212
+ useEffect(() => {
213
+ if (!isCompleted || !isAutoReset) return;
214
+ const timer = setTimeout(() => setCompleted(false), autoResetDelay);
215
+ return () => clearTimeout(timer);
216
+ }, [autoResetDelay, isAutoReset, isCompleted, setCompleted]);
217
+
218
+ // A pan with no minimum distance begins on touch-down and finalizes on
219
+ // release or cancel, which is the whole hold. A long-press gesture fires once
220
+ // and cannot tell "still holding" from "held long enough".
221
+ const gesture = useMemo(
222
+ () =>
223
+ Gesture.Pan()
224
+ .minDistance(0)
225
+ .enabled(!isDisabled)
226
+ .shouldCancelWhenOutside(false)
227
+ .onBegin(() => {
228
+ "worklet";
229
+ if (isLocked.value === 1) return;
230
+ isHolding.value = 1;
231
+ if (haptic) playHaptic(haptic);
232
+ const from = Math.min(1, Math.max(0, progress.value));
233
+ progress.value = withTiming(
234
+ 1,
235
+ { duration: holdDuration * (1 - from), easing: Easing.linear, reduceMotion: ReduceMotion.Never },
236
+ (finished) => {
237
+ "worklet";
238
+ if (!finished || isLocked.value === 1) return;
239
+ isLocked.value = 1;
240
+ isHolding.value = 0;
241
+ if (haptic) playHaptic("success");
242
+ scheduleOnRN(complete);
243
+ }
244
+ );
245
+ })
246
+ .onUpdate((event) => {
247
+ "worklet";
248
+ if (isHolding.value === 0 || isLocked.value === 1) return;
249
+ const drift = event.translationX * event.translationX + event.translationY * event.translationY;
250
+ if (drift <= PROGRESS_BUTTON_MAX_DRIFT * PROGRESS_BUTTON_MAX_DRIFT) return;
251
+ isHolding.value = 0;
252
+ const from = Math.min(1, Math.max(0, progress.value));
253
+ progress.value = withTiming(0, {
254
+ duration: holdDuration * from,
255
+ easing: Easing.linear,
256
+ reduceMotion: ReduceMotion.Never,
257
+ });
258
+ })
259
+ .onFinalize(() => {
260
+ "worklet";
261
+ if (isHolding.value === 0 || isLocked.value === 1) return;
262
+ isHolding.value = 0;
263
+ const from = Math.min(1, Math.max(0, progress.value));
264
+ progress.value = withTiming(0, {
265
+ duration: holdDuration * from,
266
+ easing: Easing.linear,
267
+ reduceMotion: ReduceMotion.Never,
268
+ });
269
+ }),
270
+ [complete, haptic, holdDuration, isDisabled, isHolding, isLocked, progress]
271
+ );
272
+
273
+ const handleLayout = useCallback(
274
+ (event: LayoutChangeEvent) => {
275
+ const { width } = event.nativeEvent.layout;
276
+ boxWidth.value = width;
277
+ setMeasuredWidth(width);
278
+ onLayout?.(event);
279
+ },
280
+ [boxWidth, onLayout]
281
+ );
282
+
283
+ // A screen reader cannot hold, so activating completes outright. The hint
284
+ // has already said what the button does.
285
+ const handleAccessibilityAction = useCallback(
286
+ (event: AccessibilityActionEvent) => {
287
+ if (event.nativeEvent.actionName !== "activate") return;
288
+ if (isDisabled || isCompleted) return;
289
+ isLocked.value = 1;
290
+ complete();
291
+ },
292
+ [complete, isCompleted, isDisabled, isLocked]
293
+ );
294
+
295
+ const fillStyle = useAnimatedStyle(() => {
296
+ const p = progress.value;
297
+ const steps = PROGRESS_BUTTON_REDUCED_MOTION_STEPS;
298
+ const shown = isReducedMotion && p < 1 ? Math.floor(Math.max(0, p) * steps) / steps : p;
299
+ return { width: shown * boxWidth.value };
300
+ });
301
+
302
+ // One style per view: each copy of the labels fades on its own binding.
303
+ const labelsStyle = useAnimatedStyle(() => ({ opacity: 1 - completion.value }));
304
+ const fillLabelsStyle = useAnimatedStyle(() => ({ opacity: 1 - completion.value }));
305
+
306
+ const doneStyle = useAnimatedStyle(() => ({
307
+ opacity: completion.value,
308
+ transform: [{ scale: isReducedMotion ? 1 : 0.6 + 0.4 * completion.value }],
309
+ }));
310
+
311
+ const context = useMemo<ProgressButtonContextValue>(
312
+ () => ({ isCompleted, isDisabled, progress, size, variant }),
313
+ [isCompleted, isDisabled, progress, size, variant]
314
+ );
315
+
316
+ const { content, done } = useMemo(() => splitChildren(children), [children]);
317
+
318
+ const slots = progressButtonVariants({ isDisabled, isFullWidth, shape, size, variant });
319
+ const iconClass = slots.icon();
320
+ const surfaceIcons = useMemo(
321
+ () => ({ className: iconClass, color: PROGRESS_BUTTON_LABEL_TOKEN[variant] }),
322
+ [iconClass, variant]
323
+ );
324
+ const fillIcons = useMemo(
325
+ () => ({ className: iconClass, color: PROGRESS_BUTTON_FILL_FOREGROUND_TOKEN[variant] }),
326
+ [iconClass, variant]
327
+ );
328
+ const innerWidth = { width: measuredWidth };
329
+
330
+ // The fill copy and the done mark are hidden from assistive technology: the
331
+ // root is the one accessible element, and the surface copy already names it.
332
+ return (
333
+ <ProgressButtonProvider value={context}>
334
+ <GestureDetector gesture={gesture}>
335
+ <Animated.View
336
+ accessibilityActions={[{ name: "activate" }]}
337
+ accessibilityHint={accessibilityHint}
338
+ accessibilityRole="button"
339
+ accessibilityState={resolveProgressButtonAccessibilityState({ isCompleted, isDisabled })}
340
+ accessible
341
+ className={slots.root({ className })}
342
+ onAccessibilityAction={handleAccessibilityAction}
343
+ onLayout={handleLayout}
344
+ ref={ref}
345
+ {...props}
346
+ >
347
+ <Animated.View className={slots.content()} style={labelsStyle}>
348
+ <ProgressButtonLayerProvider value="surface">
349
+ <IconDefaultsProvider value={surfaceIcons}>{content}</IconDefaultsProvider>
350
+ </ProgressButtonLayerProvider>
351
+ </Animated.View>
352
+ <Animated.View
353
+ accessibilityElementsHidden
354
+ className={slots.fill()}
355
+ importantForAccessibility="no-hide-descendants"
356
+ pointerEvents="none"
357
+ style={fillStyle}
358
+ >
359
+ <Animated.View className={slots.fillContent()} style={[innerWidth, fillLabelsStyle]}>
360
+ <ProgressButtonLayerProvider value="fill">
361
+ <IconDefaultsProvider value={fillIcons}>{content}</IconDefaultsProvider>
362
+ </ProgressButtonLayerProvider>
363
+ </Animated.View>
364
+ <Animated.View className={slots.done()} style={[innerWidth, doneStyle]}>
365
+ <ProgressButtonLayerProvider value="fill">
366
+ <IconDefaultsProvider value={fillIcons}>{done ?? <ProgressButtonDone />}</IconDefaultsProvider>
367
+ </ProgressButtonLayerProvider>
368
+ </Animated.View>
369
+ </Animated.View>
370
+ </Animated.View>
371
+ </GestureDetector>
372
+ </ProgressButtonProvider>
373
+ );
374
+ }
375
+
376
+ /**
377
+ * A button that has to be held, not tapped.
378
+ *
379
+ * A fill grows from the leading edge for `holdDuration` and the action fires
380
+ * when it reaches the end — never before, with no tolerance near it. Released
381
+ * early, the fill plays back at the rate it went in; pressed again, it resumes
382
+ * from where it is. For an irreversible action that would otherwise need a
383
+ * confirmation dialog: two taps are a rhythm a hand falls into, a two-second
384
+ * hold cannot be done by habit.
385
+ *
386
+ * Completion is read off the fill's own animation, then the labels cross-fade
387
+ * to `ProgressButton.Done` — a tick unless the caller writes one. It stays
388
+ * completed until `isAutoReset` rewinds it, or a controlled `isCompleted` goes
389
+ * `false`; either way the fill travels back rather than jumping.
390
+ *
391
+ * A screen reader cannot hold, so its activate action completes the button
392
+ * outright; the default hint says it must be held.
393
+ *
394
+ * @example
395
+ * <ProgressButton onComplete={erase} variant="destructive">
396
+ * <ProgressButton.Label>Hold to erase</ProgressButton.Label>
397
+ * </ProgressButton>
398
+ *
399
+ * @example
400
+ * <ProgressButton isAutoReset haptic="medium" onComplete={pay} variant="success">
401
+ * <Icon icon={IconCreditCard1} />
402
+ * <ProgressButton.Label>Hold to pay</ProgressButton.Label>
403
+ * <ProgressButton.Done>
404
+ * <ProgressButton.Label>Paid</ProgressButton.Label>
405
+ * </ProgressButton.Done>
406
+ * </ProgressButton>
407
+ */
408
+ export const ProgressButton = Object.assign(ProgressButtonRoot, {
409
+ /** The button's text, drawn once on the surface and once inside the fill. */
410
+ Label: ProgressButtonLabel,
411
+ /** What shows once the hold completes. A tick when omitted. */
412
+ Done: ProgressButtonDone,
413
+ displayName: "DelacourUI.ProgressButton",
414
+ });
@@ -0,0 +1,314 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { declaredTokens } from "../../styles/theme-tokens.test";
3
+ import { buttonVariants } from "../button/button.variants";
4
+ import {
5
+ PROGRESS_BUTTON_DEFAULT_AUTO_RESET_MS,
6
+ PROGRESS_BUTTON_DEFAULT_HINT,
7
+ PROGRESS_BUTTON_DEFAULT_HOLD_MS,
8
+ PROGRESS_BUTTON_FILL_FOREGROUND_TOKEN,
9
+ PROGRESS_BUTTON_FILL_TOKEN,
10
+ PROGRESS_BUTTON_LABEL_TOKEN,
11
+ PROGRESS_BUTTON_MIN_HOLD_MS,
12
+ PROGRESS_BUTTON_REDUCED_MOTION_STEPS,
13
+ PROGRESS_BUTTON_SHAPES,
14
+ PROGRESS_BUTTON_SIZES,
15
+ PROGRESS_BUTTON_VARIANTS,
16
+ progressButtonVariants,
17
+ resolveAutoResetDelay,
18
+ resolveHoldDuration,
19
+ resolveProgressButtonAccessibilityState,
20
+ resolveRemainingDuration,
21
+ resolveSteppedProgress,
22
+ } from "./progress-button.variants";
23
+
24
+ const LIGHT = declaredTokens("light");
25
+ const DARK = declaredTokens("dark");
26
+
27
+ /** Every theme token a class string paints with, with any `/alpha` suffix dropped. */
28
+ function colorTokens(cls: string): string[] {
29
+ const tokens: string[] = [];
30
+ for (const [, utility, token] of cls.matchAll(/\b(bg|text)-([a-z][\w-]*)(?:\/\d+)?\b/g)) {
31
+ if (utility === "text" && token.startsWith("button-")) continue;
32
+ if (utility === "text" && (token === "center" || token === "left" || token === "right")) continue;
33
+ tokens.push(token);
34
+ }
35
+ return tokens;
36
+ }
37
+
38
+ const SLOT_NAMES = ["root", "content", "fill", "fillContent", "label", "fillLabel", "done", "icon"] as const;
39
+
40
+ /** Every colour token any slot names, across the whole variant × size × shape matrix. */
41
+ function everySlotToken(): string[] {
42
+ const tokens = new Set<string>();
43
+ const combos = PROGRESS_BUTTON_VARIANTS.flatMap((variant) =>
44
+ PROGRESS_BUTTON_SIZES.flatMap((size) => PROGRESS_BUTTON_SHAPES.map((shape) => ({ shape, size, variant })))
45
+ );
46
+ for (const combo of combos) {
47
+ const slots = progressButtonVariants(combo);
48
+ for (const name of SLOT_NAMES) {
49
+ for (const token of colorTokens(slots[name]() ?? "")) tokens.add(token);
50
+ }
51
+ }
52
+ return [...tokens];
53
+ }
54
+
55
+ describe("resolveHoldDuration", () => {
56
+ test("defaults to two seconds", () => {
57
+ expect(PROGRESS_BUTTON_DEFAULT_HOLD_MS).toBe(2000);
58
+ expect(resolveHoldDuration()).toBe(2000);
59
+ expect(resolveHoldDuration(undefined)).toBe(2000);
60
+ });
61
+
62
+ test("passes a sensible value through", () => {
63
+ expect(resolveHoldDuration(1500)).toBe(1500);
64
+ expect(resolveHoldDuration(200)).toBe(200);
65
+ });
66
+
67
+ test("floors a hold too short to be deliberate", () => {
68
+ expect(PROGRESS_BUTTON_MIN_HOLD_MS).toBe(200);
69
+ expect(resolveHoldDuration(50)).toBe(200);
70
+ expect(resolveHoldDuration(0)).toBe(200);
71
+ expect(resolveHoldDuration(-1000)).toBe(200);
72
+ });
73
+
74
+ test("a non-finite value gives the default", () => {
75
+ expect(resolveHoldDuration(Number.NaN)).toBe(2000);
76
+ expect(resolveHoldDuration(Number.POSITIVE_INFINITY)).toBe(2000);
77
+ expect(resolveHoldDuration(Number.NEGATIVE_INFINITY)).toBe(2000);
78
+ });
79
+ });
80
+
81
+ describe("resolveRemainingDuration", () => {
82
+ test("forward from empty is the whole hold", () => {
83
+ expect(resolveRemainingDuration({ direction: "forward", holdDuration: 2000, progress: 0 })).toBe(2000);
84
+ });
85
+
86
+ test("forward resumes from where the fill is", () => {
87
+ expect(resolveRemainingDuration({ direction: "forward", holdDuration: 2000, progress: 0.25 })).toBe(1500);
88
+ expect(resolveRemainingDuration({ direction: "forward", holdDuration: 2000, progress: 1 })).toBe(0);
89
+ });
90
+
91
+ test("reverse plays back at the same rate", () => {
92
+ expect(resolveRemainingDuration({ direction: "reverse", holdDuration: 2000, progress: 0.9 })).toBeCloseTo(1800);
93
+ expect(resolveRemainingDuration({ direction: "reverse", holdDuration: 2000, progress: 0 })).toBe(0);
94
+ });
95
+
96
+ test("forward and reverse at one point sum to the hold", () => {
97
+ for (const progress of [0, 0.1, 0.5, 0.73, 1]) {
98
+ const forward = resolveRemainingDuration({ direction: "forward", holdDuration: 1200, progress });
99
+ const reverse = resolveRemainingDuration({ direction: "reverse", holdDuration: 1200, progress });
100
+ expect(forward + reverse).toBeCloseTo(1200);
101
+ }
102
+ });
103
+
104
+ test("clamps progress outside 0..1 and treats a non-finite one as empty", () => {
105
+ expect(resolveRemainingDuration({ direction: "forward", holdDuration: 2000, progress: -0.5 })).toBe(2000);
106
+ expect(resolveRemainingDuration({ direction: "forward", holdDuration: 2000, progress: 1.5 })).toBe(0);
107
+ expect(resolveRemainingDuration({ direction: "reverse", holdDuration: 2000, progress: Number.NaN })).toBe(0);
108
+ });
109
+ });
110
+
111
+ describe("resolveSteppedProgress", () => {
112
+ test("steps in fifths by default", () => {
113
+ expect(PROGRESS_BUTTON_REDUCED_MOTION_STEPS).toBe(5);
114
+ expect(resolveSteppedProgress(0)).toBe(0);
115
+ expect(resolveSteppedProgress(0.19)).toBe(0);
116
+ expect(resolveSteppedProgress(0.2)).toBe(0.2);
117
+ expect(resolveSteppedProgress(0.39)).toBe(0.2);
118
+ expect(resolveSteppedProgress(0.5)).toBe(0.4);
119
+ expect(resolveSteppedProgress(0.99)).toBe(0.8);
120
+ expect(resolveSteppedProgress(1)).toBe(1);
121
+ });
122
+
123
+ test("takes another step count", () => {
124
+ expect(resolveSteppedProgress(0.6, 2)).toBe(0.5);
125
+ expect(resolveSteppedProgress(0.3, 4)).toBe(0.25);
126
+ });
127
+
128
+ test("clamps, and never shows more than the hold has earned", () => {
129
+ expect(resolveSteppedProgress(-1)).toBe(0);
130
+ expect(resolveSteppedProgress(2)).toBe(1);
131
+ expect(resolveSteppedProgress(Number.NaN)).toBe(0);
132
+ for (let p = 0; p <= 1; p += 0.01) {
133
+ expect(resolveSteppedProgress(p)).toBeLessThanOrEqual(p + 1e-9);
134
+ }
135
+ });
136
+
137
+ test("a step count below one is treated as one", () => {
138
+ expect(resolveSteppedProgress(0.5, 0)).toBe(0);
139
+ expect(resolveSteppedProgress(1, 0)).toBe(1);
140
+ });
141
+ });
142
+
143
+ describe("resolveAutoResetDelay", () => {
144
+ test("defaults to a second", () => {
145
+ expect(PROGRESS_BUTTON_DEFAULT_AUTO_RESET_MS).toBe(1000);
146
+ expect(resolveAutoResetDelay()).toBe(1000);
147
+ });
148
+
149
+ test("passes a value through and floors a negative one at zero", () => {
150
+ expect(resolveAutoResetDelay(2500)).toBe(2500);
151
+ expect(resolveAutoResetDelay(0)).toBe(0);
152
+ expect(resolveAutoResetDelay(-5)).toBe(0);
153
+ });
154
+
155
+ test("a non-finite value gives the default", () => {
156
+ expect(resolveAutoResetDelay(Number.NaN)).toBe(1000);
157
+ expect(resolveAutoResetDelay(Number.POSITIVE_INFINITY)).toBe(1000);
158
+ });
159
+ });
160
+
161
+ describe("resolveProgressButtonAccessibilityState", () => {
162
+ test("announces disabled and completed as checked", () => {
163
+ expect(resolveProgressButtonAccessibilityState({ isCompleted: false, isDisabled: false })).toEqual({
164
+ checked: false,
165
+ disabled: false,
166
+ });
167
+ expect(resolveProgressButtonAccessibilityState({ isCompleted: true, isDisabled: true })).toEqual({
168
+ checked: true,
169
+ disabled: true,
170
+ });
171
+ });
172
+
173
+ test("the default hint says the button has to be held", () => {
174
+ expect(PROGRESS_BUTTON_DEFAULT_HINT.toLowerCase()).toContain("hold");
175
+ });
176
+ });
177
+
178
+ describe("progressButtonVariants — axes", () => {
179
+ test("exposes the variants, sizes and shapes the API names", () => {
180
+ expect([...PROGRESS_BUTTON_VARIANTS]).toEqual(["primary", "secondary", "destructive", "success"]);
181
+ expect([...PROGRESS_BUTTON_SIZES]).toEqual(["sm", "md", "lg"]);
182
+ expect([...PROGRESS_BUTTON_SHAPES]).toEqual(["pill", "rounded"]);
183
+ });
184
+
185
+ test("defaults to primary, md, pill", () => {
186
+ const root = progressButtonVariants().root();
187
+ expect(root).toContain("h-button-md");
188
+ expect(root).toContain("rounded-button-md");
189
+ expect(progressButtonVariants().fill()).toContain("bg-primary");
190
+ });
191
+
192
+ test("every variant rests on the same surface", () => {
193
+ for (const variant of PROGRESS_BUTTON_VARIANTS) {
194
+ const root = progressButtonVariants({ variant }).root();
195
+ expect(root).toContain("bg-secondary");
196
+ expect(colorTokens(root)).toEqual(["secondary"]);
197
+ }
198
+ });
199
+
200
+ test("the variant colour is carried by the fill and the labels", () => {
201
+ for (const variant of PROGRESS_BUTTON_VARIANTS) {
202
+ const slots = progressButtonVariants({ variant });
203
+ expect(slots.fill()).toContain(`bg-${PROGRESS_BUTTON_FILL_TOKEN[variant]}`);
204
+ expect(slots.label()).toContain(`text-${PROGRESS_BUTTON_LABEL_TOKEN[variant]}`);
205
+ expect(slots.fillLabel()).toContain(`text-${PROGRESS_BUTTON_FILL_FOREGROUND_TOKEN[variant]}`);
206
+ }
207
+ });
208
+
209
+ test("secondary fills with the foreground and draws its inner label on the background", () => {
210
+ const slots = progressButtonVariants({ variant: "secondary" });
211
+ expect(slots.fill()).toContain("bg-foreground");
212
+ expect(slots.label()).toContain("text-secondary-foreground");
213
+ expect(slots.fillLabel()).toContain("text-background");
214
+ });
215
+
216
+ test("the two label copies differ only in colour", () => {
217
+ for (const variant of PROGRESS_BUTTON_VARIANTS) {
218
+ for (const size of PROGRESS_BUTTON_SIZES) {
219
+ const slots = progressButtonVariants({ size, variant });
220
+ const strip = (cls: string) =>
221
+ cls
222
+ .split(/\s+/)
223
+ .filter((c) => colorTokens(c).length === 0)
224
+ .sort();
225
+ expect(strip(slots.label())).toEqual(strip(slots.fillLabel()));
226
+ }
227
+ }
228
+ });
229
+
230
+ test("the fill is clipped and anchored to the leading edge", () => {
231
+ const fill = progressButtonVariants().fill();
232
+ for (const cls of ["absolute", "overflow-hidden", "inset-y-0", "start-0"]) {
233
+ expect(fill).toContain(cls);
234
+ }
235
+ });
236
+ });
237
+
238
+ describe("progressButtonVariants — the button's box", () => {
239
+ test("height, padding, label step and icon step come off the button's own size tokens", () => {
240
+ for (const size of PROGRESS_BUTTON_SIZES) {
241
+ const ours = progressButtonVariants({ size });
242
+ const button = buttonVariants({ size });
243
+ const buttonRoot = button.root().split(/\s+/);
244
+ const height = buttonRoot.find((c) => c.startsWith("h-button-"));
245
+ const padding = buttonRoot.find((c) => c.startsWith("px-"));
246
+ expect(height).toBeDefined();
247
+ expect(padding).toBeDefined();
248
+ expect(ours.root()).toContain(height as string);
249
+ expect(ours.root()).toContain(padding as string);
250
+ expect(ours.fillContent()).toContain(padding as string);
251
+ expect(ours.label()).toContain(`text-button-${size}`);
252
+ expect(ours.icon()).toContain(`size-icon-${size}`);
253
+ }
254
+ });
255
+
256
+ test("a pill takes the button's corner, rounded takes the card's", () => {
257
+ for (const size of PROGRESS_BUTTON_SIZES) {
258
+ expect(progressButtonVariants({ shape: "pill", size }).root()).toContain(`rounded-button-${size}`);
259
+ const rounded = progressButtonVariants({ shape: "rounded", size }).root();
260
+ expect(rounded).toContain("rounded-lg");
261
+ expect(rounded).not.toContain("rounded-button-");
262
+ }
263
+ });
264
+
265
+ test("full width stretches", () => {
266
+ const root = progressButtonVariants({ isFullWidth: true }).root();
267
+ expect(root).toContain("w-full");
268
+ expect(root).toContain("self-stretch");
269
+ expect(progressButtonVariants({ isFullWidth: false }).root()).not.toContain("w-full");
270
+ });
271
+
272
+ test("disabled fades", () => {
273
+ expect(progressButtonVariants({ isDisabled: true }).root()).toContain("opacity-50");
274
+ expect(progressButtonVariants({ isDisabled: false }).root()).not.toContain("opacity-50");
275
+ });
276
+
277
+ test("a caller's className merges over the root's own", () => {
278
+ const root = progressButtonVariants().root({ className: "rounded-none" });
279
+ expect(root).toContain("rounded-none");
280
+ expect(root).not.toContain("rounded-button-md");
281
+ });
282
+ });
283
+
284
+ describe("progressButtonVariants — theme tokens", () => {
285
+ test("the theme reader found both variants, and the slots name some tokens", () => {
286
+ expect(LIGHT.size).toBeGreaterThan(0);
287
+ expect(DARK.size).toBeGreaterThan(0);
288
+ expect(everySlotToken().length).toBeGreaterThan(4);
289
+ });
290
+
291
+ test("every token named in every slot, at every axis, exists in both themes", () => {
292
+ const missing = everySlotToken().filter((token) => !(LIGHT.has(token) && DARK.has(token)));
293
+ expect(missing).toEqual([]);
294
+ });
295
+
296
+ test("every token in the colour tables exists in both themes", () => {
297
+ for (const table of [
298
+ PROGRESS_BUTTON_FILL_TOKEN,
299
+ PROGRESS_BUTTON_LABEL_TOKEN,
300
+ PROGRESS_BUTTON_FILL_FOREGROUND_TOKEN,
301
+ ]) {
302
+ for (const variant of PROGRESS_BUTTON_VARIANTS) {
303
+ expect(LIGHT.has(table[variant])).toBe(true);
304
+ expect(DARK.has(table[variant])).toBe(true);
305
+ }
306
+ }
307
+ });
308
+
309
+ test("the fill and its label never share a token", () => {
310
+ for (const variant of PROGRESS_BUTTON_VARIANTS) {
311
+ expect(PROGRESS_BUTTON_FILL_TOKEN[variant]).not.toBe(PROGRESS_BUTTON_FILL_FOREGROUND_TOKEN[variant]);
312
+ }
313
+ });
314
+ });
@@ -0,0 +1,236 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+
4
+ export const PROGRESS_BUTTON_VARIANTS = ["primary", "secondary", "destructive", "success"] as const;
5
+
6
+ /** The button's label sizes. A hold needs a label, so there is no square `icon-*` step. */
7
+ export const PROGRESS_BUTTON_SIZES = ["sm", "md", "lg"] as const;
8
+
9
+ /** `pill` takes the button's capsule corner; `rounded` takes the card's `rounded-lg`. */
10
+ export const PROGRESS_BUTTON_SHAPES = ["pill", "rounded"] as const;
11
+
12
+ export type ProgressButtonVariant = (typeof PROGRESS_BUTTON_VARIANTS)[number];
13
+ export type ProgressButtonSize = (typeof PROGRESS_BUTTON_SIZES)[number];
14
+ export type ProgressButtonShape = (typeof PROGRESS_BUTTON_SHAPES)[number];
15
+
16
+ /** How long a hold takes when nothing says otherwise. */
17
+ export const PROGRESS_BUTTON_DEFAULT_HOLD_MS = 2000;
18
+
19
+ /** The shortest hold accepted. Anything quicker is a long tap, not a decision. */
20
+ export const PROGRESS_BUTTON_MIN_HOLD_MS = 200;
21
+
22
+ /** How long a completed button waits before `isAutoReset` rewinds it. */
23
+ export const PROGRESS_BUTTON_DEFAULT_AUTO_RESET_MS = 1000;
24
+
25
+ /** How long a reset, or a completion set from outside, takes to travel the whole width. */
26
+ export const PROGRESS_BUTTON_TRAVEL_MS = 400;
27
+
28
+ /** How long the labels and the done mark take to cross-fade. */
29
+ export const PROGRESS_BUTTON_CROSSFADE_MS = 200;
30
+
31
+ /** How far, in points, a resting finger may drift before the hold lets go. */
32
+ export const PROGRESS_BUTTON_MAX_DRIFT = 16;
33
+
34
+ /** How many discrete steps the fill takes under reduced motion. */
35
+ export const PROGRESS_BUTTON_REDUCED_MOTION_STEPS = 5;
36
+
37
+ /** What VoiceOver and TalkBack read after the label when no `accessibilityHint` is given. */
38
+ export const PROGRESS_BUTTON_DEFAULT_HINT = "Press and hold to confirm";
39
+
40
+ /**
41
+ * Theme token each variant fills with.
42
+ *
43
+ * `secondary` fills with the foreground: its resting surface already is
44
+ * `bg-secondary`, and a fill the same colour as the surface under it would not
45
+ * be seen at all.
46
+ */
47
+ export const PROGRESS_BUTTON_FILL_TOKEN: Record<ProgressButtonVariant, string> = {
48
+ primary: "primary",
49
+ secondary: "foreground",
50
+ destructive: "destructive",
51
+ success: "success",
52
+ };
53
+
54
+ /** Theme token the resting label, and a composed icon on the surface, are drawn in. */
55
+ export const PROGRESS_BUTTON_LABEL_TOKEN: Record<ProgressButtonVariant, string> = {
56
+ primary: "primary",
57
+ secondary: "secondary-foreground",
58
+ destructive: "destructive",
59
+ success: "success",
60
+ };
61
+
62
+ /**
63
+ * Theme token the label copy inside the fill, its icons and the done mark are drawn in.
64
+ *
65
+ * `secondary` draws on `background`, the token `foreground` is the foreground
66
+ * *of* — `secondary-foreground` on a `foreground` fill would be the same colour
67
+ * twice.
68
+ */
69
+ export const PROGRESS_BUTTON_FILL_FOREGROUND_TOKEN: Record<ProgressButtonVariant, string> = {
70
+ primary: "primary-foreground",
71
+ secondary: "background",
72
+ destructive: "destructive-foreground",
73
+ success: "success-foreground",
74
+ };
75
+
76
+ /**
77
+ * Styling for every part of a progress button.
78
+ *
79
+ * The box is a button's box: `h-button-*`, the button's `px-*` and, as a pill,
80
+ * `rounded-button-*` — the same tokens `buttonVariants` reads, never new ones,
81
+ * and a test pins them equal. Every variant rests on one surface,
82
+ * `bg-secondary`, and carries its colour in the label and the fill.
83
+ *
84
+ * The label is drawn twice. `label` sits on the surface; `fillLabel` sits inside
85
+ * the clipped fill, laid out in `fillContent` at the button's measured width so
86
+ * both copies wrap the same and the wipe's edge cuts through a glyph rather
87
+ * than between two different layouts. The two differ only in colour — a test
88
+ * asserts it.
89
+ *
90
+ * Free of React Native imports so it stays unit-testable. See AGENTS.md.
91
+ */
92
+ export const progressButtonVariants = tv({
93
+ slots: {
94
+ root: "relative flex-row items-center justify-center overflow-hidden bg-secondary",
95
+ /** The surface layer: the resting label and anything composed beside it. */
96
+ content: "flex-row items-center justify-center gap-2",
97
+ fill: "absolute inset-y-0 start-0 overflow-hidden",
98
+ /** The fill's inner copy, laid out at the root's measured width. */
99
+ fillContent: "absolute inset-y-0 start-0 flex-row items-center justify-center gap-2",
100
+ label: "text-center font-semibold",
101
+ fillLabel: "text-center font-semibold",
102
+ /** Where the done mark sits: the whole box, on the fill. */
103
+ done: "absolute inset-y-0 start-0 items-center justify-center",
104
+ /** Edge length a composed `Icon`, and the default tick, inherit. */
105
+ icon: "",
106
+ },
107
+ variants: {
108
+ variant: {
109
+ primary: { fill: "bg-primary", label: "text-primary", fillLabel: "text-primary-foreground" },
110
+ secondary: {
111
+ fill: "bg-foreground",
112
+ label: "text-secondary-foreground",
113
+ fillLabel: "text-background",
114
+ },
115
+ destructive: {
116
+ fill: "bg-destructive",
117
+ label: "text-destructive",
118
+ fillLabel: "text-destructive-foreground",
119
+ },
120
+ success: { fill: "bg-success", label: "text-success", fillLabel: "text-success-foreground" },
121
+ },
122
+ // Written out rather than built: Tailwind scans source text, and a class
123
+ // assembled at runtime is never compiled.
124
+ size: {
125
+ sm: {
126
+ root: "h-button-sm px-3",
127
+ content: "gap-1.5",
128
+ fillContent: "gap-1.5 px-3",
129
+ label: "text-button-sm",
130
+ fillLabel: "text-button-sm",
131
+ icon: "size-icon-sm",
132
+ },
133
+ md: {
134
+ root: "h-button-md px-4",
135
+ fillContent: "px-4",
136
+ label: "text-button-md",
137
+ fillLabel: "text-button-md",
138
+ icon: "size-icon-md",
139
+ },
140
+ lg: {
141
+ root: "h-button-lg px-5",
142
+ fillContent: "px-5",
143
+ label: "text-button-lg",
144
+ fillLabel: "text-button-lg",
145
+ icon: "size-icon-lg",
146
+ },
147
+ },
148
+ // The pill's corner depends on the size, so it is a compound cell below.
149
+ shape: { pill: {}, rounded: { root: "rounded-lg" } },
150
+ isFullWidth: { true: { root: "w-full self-stretch" }, false: {} },
151
+ isDisabled: { true: { root: "opacity-50" }, false: {} },
152
+ },
153
+ compoundVariants: [
154
+ { shape: "pill", size: "sm", class: { root: "rounded-button-sm" } },
155
+ { shape: "pill", size: "md", class: { root: "rounded-button-md" } },
156
+ { shape: "pill", size: "lg", class: { root: "rounded-button-lg" } },
157
+ ],
158
+ defaultVariants: {
159
+ variant: "primary",
160
+ size: "md",
161
+ shape: "pill",
162
+ isFullWidth: false,
163
+ isDisabled: false,
164
+ },
165
+ });
166
+
167
+ export type ProgressButtonVariantProps = VariantProps<typeof progressButtonVariants>;
168
+
169
+ /**
170
+ * How long a full hold takes, in milliseconds.
171
+ *
172
+ * Defaults to {@link PROGRESS_BUTTON_DEFAULT_HOLD_MS} and is floored at
173
+ * {@link PROGRESS_BUTTON_MIN_HOLD_MS}. A non-finite value — `NaN` from a
174
+ * division somewhere upstream — takes the default rather than a hold that
175
+ * completes instantly or never.
176
+ */
177
+ export function resolveHoldDuration(ms?: number): number {
178
+ if (ms === undefined || !Number.isFinite(ms)) return PROGRESS_BUTTON_DEFAULT_HOLD_MS;
179
+ return Math.max(PROGRESS_BUTTON_MIN_HOLD_MS, ms);
180
+ }
181
+
182
+ /** How long a completed button waits before rewinding itself. Non-negative. */
183
+ export function resolveAutoResetDelay(ms?: number): number {
184
+ if (ms === undefined || !Number.isFinite(ms)) return PROGRESS_BUTTON_DEFAULT_AUTO_RESET_MS;
185
+ return Math.max(0, ms);
186
+ }
187
+
188
+ /**
189
+ * How long the fill takes to travel from `progress` to its end, at the hold's rate.
190
+ *
191
+ * `forward` is what is left to fill — `holdDuration × (1 − p)` — so a second
192
+ * press resumes rather than restarting. `reverse` is what is filled —
193
+ * `holdDuration × p` — so a release plays the fill back at the same speed it
194
+ * went in, and the two at one point always sum to the whole hold.
195
+ *
196
+ * The worklet that drives the fill restates this inline, because a worklet body
197
+ * must be self-contained; this copy is the one the tests pin.
198
+ */
199
+ export function resolveRemainingDuration({
200
+ holdDuration,
201
+ progress,
202
+ direction,
203
+ }: {
204
+ holdDuration: number;
205
+ progress: number;
206
+ direction: "forward" | "reverse";
207
+ }): number {
208
+ const p = Number.isFinite(progress) ? Math.min(1, Math.max(0, progress)) : 0;
209
+ return holdDuration * (direction === "forward" ? 1 - p : p);
210
+ }
211
+
212
+ /**
213
+ * The fill shown under reduced motion: `progress` rounded down to a step.
214
+ *
215
+ * Down, never to nearest, so the fill never shows more than the hold has
216
+ * earned — a full bar appears only at 1. The animated style restates this
217
+ * inline (worklet bodies must be self-contained); this copy is the tested one.
218
+ */
219
+ export function resolveSteppedProgress(progress: number, steps: number = PROGRESS_BUTTON_REDUCED_MOTION_STEPS): number {
220
+ const p = Number.isFinite(progress) ? Math.min(1, Math.max(0, progress)) : 0;
221
+ if (p >= 1) return 1;
222
+ if (!(steps >= 1)) return 0;
223
+ const count = Math.floor(steps);
224
+ return Math.floor(p * count) / count;
225
+ }
226
+
227
+ /** What a screen reader is told about the button's state. Completed reads as checked. */
228
+ export function resolveProgressButtonAccessibilityState({
229
+ isDisabled,
230
+ isCompleted,
231
+ }: {
232
+ isDisabled: boolean;
233
+ isCompleted: boolean;
234
+ }): { disabled: boolean; checked: boolean } {
235
+ return { checked: isCompleted, disabled: isDisabled };
236
+ }