@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 +4 -3
- package/src/components/progress-button/AGENTS.md +116 -0
- package/src/components/progress-button/index.ts +36 -0
- package/src/components/progress-button/progress-button-done.tsx +33 -0
- package/src/components/progress-button/progress-button-label.tsx +27 -0
- package/src/components/progress-button/progress-button.context.tsx +93 -0
- package/src/components/progress-button/progress-button.tsx +414 -0
- package/src/components/progress-button/progress-button.variants.test.ts +314 -0
- package/src/components/progress-button/progress-button.variants.ts +236 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
119
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
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
|
+
}
|