panelui-native 0.77.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/module/components/area-chart/index.js +32 -25
- package/lib/module/components/area-chart/index.js.map +1 -1
- package/lib/module/components/bottom-sheet/index.js +97 -15
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/funnel-chart/index.js +15 -2
- package/lib/module/components/funnel-chart/index.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +19 -27
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/hex-chart/index.js +2 -0
- package/lib/module/components/hex-chart/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +38 -31
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/map/index.js +63 -39
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/map/map-children.js +54 -0
- package/lib/module/components/map/map-children.js.map +1 -0
- package/lib/module/components/map/maplibre.js +31 -3
- package/lib/module/components/map/maplibre.js.map +1 -1
- package/lib/module/components/markdown-editor/index.js +288 -34
- package/lib/module/components/markdown-editor/index.js.map +1 -1
- package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
- package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
- package/lib/module/components/marquee/index.js +205 -46
- package/lib/module/components/marquee/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +162 -34
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/pie-chart/index.js +15 -2
- package/lib/module/components/pie-chart/index.js.map +1 -1
- package/lib/module/components/plot/index.js +42 -44
- package/lib/module/components/plot/index.js.map +1 -1
- package/lib/module/components/progress-button/index.js +617 -0
- package/lib/module/components/progress-button/index.js.map +1 -0
- package/lib/module/components/progress-button/progress-button-hold.js +91 -0
- package/lib/module/components/progress-button/progress-button-hold.js.map +1 -0
- package/lib/module/components/scatter-chart/index.js +6 -25
- package/lib/module/components/scatter-chart/index.js.map +1 -1
- package/lib/module/components/section-rail/index.js +40 -17
- package/lib/module/components/section-rail/index.js.map +1 -1
- package/lib/module/components/split-view/index.js +54 -10
- package/lib/module/components/split-view/index.js.map +1 -1
- package/lib/module/components/theme-selector/index.js +239 -0
- package/lib/module/components/theme-selector/index.js.map +1 -0
- package/lib/module/components/theme-selector/theme-preview.js +320 -0
- package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
- package/lib/module/components/timeline/index.js +145 -14
- package/lib/module/components/timeline/index.js.map +1 -1
- package/lib/module/components/toast/index.js +45 -11
- package/lib/module/components/toast/index.js.map +1 -1
- package/lib/module/components/treemap-chart/index.js +63 -6
- package/lib/module/components/treemap-chart/index.js.map +1 -1
- package/lib/module/components/waterfall-chart/index.js +20 -4
- package/lib/module/components/waterfall-chart/index.js.map +1 -1
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-scroll-sections.js +44 -2
- package/lib/module/hooks/use-scroll-sections.js.map +1 -1
- package/lib/module/hooks/use-skeleton-handoff.js +58 -0
- package/lib/module/hooks/use-skeleton-handoff.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/area-chart/index.d.ts +1 -1
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/index.d.ts +8 -4
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/map-children.d.ts +41 -0
- package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
- package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
- package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
- package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
- package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -1
- package/lib/typescript/src/components/marquee/index.d.ts +55 -2
- package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +79 -4
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
- package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/plot/index.d.ts +1 -3
- package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
- package/lib/typescript/src/components/progress-button/index.d.ts +311 -0
- package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -0
- package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +60 -0
- package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -0
- package/lib/typescript/src/components/scatter-chart/index.d.ts +1 -1
- package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
- package/lib/typescript/src/components/split-view/index.d.ts +10 -1
- package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
- package/lib/typescript/src/components/theme-selector/index.d.ts +107 -0
- package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
- package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
- package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
- package/lib/typescript/src/components/timeline/index.d.ts +7 -0
- package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
- package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +1 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts +1 -0
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts +11 -0
- package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +6 -4
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/area-chart/index.tsx +49 -37
- package/src/components/bottom-sheet/index.tsx +109 -11
- package/src/components/funnel-chart/index.tsx +10 -2
- package/src/components/heatmap-chart/index.tsx +27 -22
- package/src/components/hex-chart/index.tsx +22 -6
- package/src/components/line-chart/index.tsx +57 -43
- package/src/components/map/index.tsx +63 -36
- package/src/components/map/map-children.ts +66 -0
- package/src/components/map/maplibre.ts +32 -4
- package/src/components/markdown-editor/index.tsx +413 -35
- package/src/components/markdown-editor/markdown-transforms.ts +274 -10
- package/src/components/marquee/index.tsx +254 -58
- package/src/components/panelside/index.tsx +179 -40
- package/src/components/pie-chart/index.tsx +10 -2
- package/src/components/plot/index.tsx +66 -50
- package/src/components/progress-button/index.tsx +684 -0
- package/src/components/progress-button/progress-button-hold.ts +88 -0
- package/src/components/scatter-chart/index.tsx +4 -25
- package/src/components/section-rail/index.tsx +39 -19
- package/src/components/split-view/index.tsx +53 -8
- package/src/components/theme-selector/index.tsx +281 -0
- package/src/components/theme-selector/theme-preview.tsx +205 -0
- package/src/components/timeline/index.tsx +143 -17
- package/src/components/toast/index.tsx +60 -11
- package/src/components/treemap-chart/index.tsx +65 -8
- package/src/components/waterfall-chart/index.tsx +14 -4
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-scroll-sections.ts +52 -3
- package/src/hooks/use-skeleton-handoff.ts +69 -0
- package/src/index.ts +25 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProgressButton — press and hold to confirm, with the wait drawn on the button.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <ProgressButton onComplete={wipe}>
|
|
6
|
+
* <ProgressButton.Label>Hold to erase</ProgressButton.Label>
|
|
7
|
+
* </ProgressButton>
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* For the action a confirmation dialog exists to slow down. A dialog asks the
|
|
11
|
+
* question somewhere else and takes the answer as a tap — which is two taps,
|
|
12
|
+
* and two taps in a row is a rhythm a hand falls into. A hold cannot be
|
|
13
|
+
* completed by accident and cannot be completed by habit: it has to be
|
|
14
|
+
* sustained, and the fill says for how much longer.
|
|
15
|
+
*
|
|
16
|
+
* ## The fill is the promise
|
|
17
|
+
*
|
|
18
|
+
* Nothing fires until the fill reaches the end. There is no tolerance near the
|
|
19
|
+
* top, because a tolerance means the button sometimes commits after the reader
|
|
20
|
+
* has deliberately let go — which is the one failure a confirmation control
|
|
21
|
+
* cannot have. Released early, the fill drains back, in proportion to how far
|
|
22
|
+
* it got.
|
|
23
|
+
*
|
|
24
|
+
* ## How it is drawn
|
|
25
|
+
*
|
|
26
|
+
* The fill is a clipped copy of the button in inverted colours, growing from
|
|
27
|
+
* the leading edge. That is what keeps the label legible across the boundary:
|
|
28
|
+
* a single label under a translucent wash goes muddy in the middle of the
|
|
29
|
+
* wipe, exactly where the eye is. Two labels, each at full contrast on its own
|
|
30
|
+
* ground, never do.
|
|
31
|
+
*
|
|
32
|
+
* The clip is a view under `overflow: 'hidden'`, and its width is animated.
|
|
33
|
+
* Animating a width is normally a layout pass per frame; this view is
|
|
34
|
+
* absolutely positioned over the button and its copy of the label is pinned to
|
|
35
|
+
* the button's own width, so nothing it contains reflows as it grows.
|
|
36
|
+
*
|
|
37
|
+
* ## Reduced motion
|
|
38
|
+
*
|
|
39
|
+
* The wipe is replaced by a stepped fill that advances in fifths. The hold
|
|
40
|
+
* still has to be legible — a control that asks you to wait and shows nothing
|
|
41
|
+
* is a broken button — so this is a coarser indicator, not the absence of one.
|
|
42
|
+
*/
|
|
43
|
+
import {
|
|
44
|
+
Children,
|
|
45
|
+
createContext,
|
|
46
|
+
forwardRef,
|
|
47
|
+
isValidElement,
|
|
48
|
+
useCallback,
|
|
49
|
+
useContext,
|
|
50
|
+
useEffect,
|
|
51
|
+
useMemo,
|
|
52
|
+
useRef,
|
|
53
|
+
useState,
|
|
54
|
+
type ReactNode,
|
|
55
|
+
} from 'react';
|
|
56
|
+
import {
|
|
57
|
+
Pressable,
|
|
58
|
+
View,
|
|
59
|
+
type LayoutChangeEvent,
|
|
60
|
+
type PressableProps,
|
|
61
|
+
type ViewProps,
|
|
62
|
+
} from 'react-native';
|
|
63
|
+
import Animated, {
|
|
64
|
+
Easing,
|
|
65
|
+
cancelAnimation,
|
|
66
|
+
runOnJS,
|
|
67
|
+
runOnUI,
|
|
68
|
+
useAnimatedReaction,
|
|
69
|
+
useAnimatedStyle,
|
|
70
|
+
useReducedMotion,
|
|
71
|
+
useSharedValue,
|
|
72
|
+
withSpring,
|
|
73
|
+
withTiming,
|
|
74
|
+
type SharedValue,
|
|
75
|
+
} from 'react-native-reanimated';
|
|
76
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
77
|
+
import { useCSSVariable } from 'uniwind';
|
|
78
|
+
import { CheckIcon, IconColorProvider } from '../../icons';
|
|
79
|
+
import { Text, textChildren } from '../../primitives/text';
|
|
80
|
+
import { impactKnock, selectionTick } from '../../utils/haptics';
|
|
81
|
+
import {
|
|
82
|
+
DEFAULT_AUTO_RESET_DELAY,
|
|
83
|
+
fillDuration,
|
|
84
|
+
releaseDuration,
|
|
85
|
+
resolveHoldDuration,
|
|
86
|
+
} from './progress-button-hold';
|
|
87
|
+
|
|
88
|
+
/** How many steps the reduced-motion fill advances in. */
|
|
89
|
+
const REDUCED_STEPS = 5;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The arrival of the completed drawing.
|
|
93
|
+
*
|
|
94
|
+
* A spring rather than a timing, and slightly overshooting: the fill has just
|
|
95
|
+
* spent two seconds moving at a constant rate, and something that lands with a
|
|
96
|
+
* little weight is what tells the reader the waiting part is over.
|
|
97
|
+
*/
|
|
98
|
+
const DONE_SPRING = { damping: 14, stiffness: 220, mass: 0.6 } as const;
|
|
99
|
+
|
|
100
|
+
/** How long the completed drawing takes to leave again on a reset. */
|
|
101
|
+
const DONE_EXIT = 140;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Which token the tick is drawn in, per variant.
|
|
105
|
+
*
|
|
106
|
+
* It sits on the finished fill, so it takes that fill's own foreground rather
|
|
107
|
+
* than the button's — the same pairing `fillLabel` uses, which is what keeps
|
|
108
|
+
* the contrast right in both themes without a hardcoded colour.
|
|
109
|
+
*/
|
|
110
|
+
const DONE_TINT = {
|
|
111
|
+
primary: '--color-primary-foreground',
|
|
112
|
+
secondary: '--color-background',
|
|
113
|
+
destructive: '--color-destructive-solid-foreground',
|
|
114
|
+
success: '--color-success-solid-foreground',
|
|
115
|
+
} as const;
|
|
116
|
+
|
|
117
|
+
const progressButtonVariants = tv({
|
|
118
|
+
slots: {
|
|
119
|
+
/*
|
|
120
|
+
* A pill, not the `rounded-lg` the other buttons take.
|
|
121
|
+
*
|
|
122
|
+
* The fill is clipped by this radius, so the shape of the button is also
|
|
123
|
+
* the shape of the wipe's leading edge as it comes out of the corner. On a
|
|
124
|
+
* small radius that edge emerges square from a rounded box, which reads as
|
|
125
|
+
* a rectangle sliding out from under the button rather than as the button
|
|
126
|
+
* filling up.
|
|
127
|
+
*/
|
|
128
|
+
/*
|
|
129
|
+
* Every variant rests on the same secondary surface, and `variant` decides
|
|
130
|
+
* only what colour comes across it.
|
|
131
|
+
*
|
|
132
|
+
* Drawn as outlines they were four different buttons before anything had
|
|
133
|
+
* happened, and the one thing they all do — wait to be held — was the
|
|
134
|
+
* thing the drawing did not say. A solid ground says it: the button is
|
|
135
|
+
* unfilled, and the fill is what the hold produces.
|
|
136
|
+
*
|
|
137
|
+
* The variant is still legible at rest, because the label carries its
|
|
138
|
+
* colour. That is the half worth keeping — a destructive hold should not
|
|
139
|
+
* look like an ordinary one before it is touched — and it is also the half
|
|
140
|
+
* that survives the wipe, since the fill covers the ground the label was
|
|
141
|
+
* standing on and the second copy takes over.
|
|
142
|
+
*/
|
|
143
|
+
root: 'relative overflow-hidden rounded-full border border-transparent bg-secondary',
|
|
144
|
+
/*
|
|
145
|
+
* The row inside the button. It is separate from `root` because the fill
|
|
146
|
+
* has to sit over the whole button including its padding — a wipe that
|
|
147
|
+
* stops at the text's own box leaves an unfilled margin down each side and
|
|
148
|
+
* reads as a progress bar someone put inside a button.
|
|
149
|
+
*/
|
|
150
|
+
content: 'flex-row items-center justify-center gap-2',
|
|
151
|
+
label: 'min-w-0 shrink text-center font-medium',
|
|
152
|
+
/** The inverted copy, drawn on the filled ground. */
|
|
153
|
+
fill: '',
|
|
154
|
+
fillLabel: 'min-w-0 shrink text-center font-medium',
|
|
155
|
+
},
|
|
156
|
+
variants: {
|
|
157
|
+
variant: {
|
|
158
|
+
primary: {
|
|
159
|
+
label: 'text-primary',
|
|
160
|
+
fill: 'bg-primary',
|
|
161
|
+
fillLabel: 'text-primary-foreground',
|
|
162
|
+
},
|
|
163
|
+
secondary: {
|
|
164
|
+
label: 'text-secondary-foreground',
|
|
165
|
+
fill: 'bg-foreground',
|
|
166
|
+
fillLabel: 'text-background',
|
|
167
|
+
},
|
|
168
|
+
destructive: {
|
|
169
|
+
label: 'text-destructive',
|
|
170
|
+
fill: 'bg-destructive',
|
|
171
|
+
fillLabel: 'text-destructive-solid-foreground',
|
|
172
|
+
},
|
|
173
|
+
success: {
|
|
174
|
+
label: 'text-success',
|
|
175
|
+
fill: 'bg-success',
|
|
176
|
+
fillLabel: 'text-success-solid-foreground',
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
size: {
|
|
180
|
+
// Matched to Button's boxes, and `min-h-*` for the same reason: the
|
|
181
|
+
// label's glyphs grow with the system text size and the box has to grow
|
|
182
|
+
// with them.
|
|
183
|
+
// Wider than the equivalent Button, because the corner is a half-circle
|
|
184
|
+
// rather than a small radius: the curve eats into the side padding, and
|
|
185
|
+
// at Button's values the first and last glyphs sit against it.
|
|
186
|
+
sm: {
|
|
187
|
+
root: 'min-h-9',
|
|
188
|
+
content: 'min-h-9 gap-1.5 px-3.5 py-2',
|
|
189
|
+
label: 'text-[14px]',
|
|
190
|
+
fillLabel: 'text-[14px]',
|
|
191
|
+
},
|
|
192
|
+
md: {
|
|
193
|
+
root: 'min-h-11',
|
|
194
|
+
content: 'min-h-11 px-5 py-2.5',
|
|
195
|
+
label: 'text-[16px]',
|
|
196
|
+
fillLabel: 'text-[16px]',
|
|
197
|
+
},
|
|
198
|
+
lg: {
|
|
199
|
+
root: 'min-h-12',
|
|
200
|
+
content: 'min-h-12 px-7 py-2.5',
|
|
201
|
+
label: 'text-[18px]',
|
|
202
|
+
fillLabel: 'text-[18px]',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
fullWidth: {
|
|
206
|
+
true: { root: 'w-full' },
|
|
207
|
+
},
|
|
208
|
+
disabled: {
|
|
209
|
+
true: { root: 'opacity-[0.64]' },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
defaultVariants: {
|
|
213
|
+
variant: 'primary',
|
|
214
|
+
size: 'md',
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
type ProgressButtonVariantProps = VariantProps<typeof progressButtonVariants>;
|
|
219
|
+
|
|
220
|
+
/** How a progress button looks. */
|
|
221
|
+
export type ProgressButtonVariant = NonNullable<ProgressButtonVariantProps['variant']>;
|
|
222
|
+
/** How big a progress button is. */
|
|
223
|
+
export type ProgressButtonSize = NonNullable<ProgressButtonVariantProps['size']>;
|
|
224
|
+
|
|
225
|
+
interface ProgressButtonContextValue {
|
|
226
|
+
/** `0` to `1` across the hold. */
|
|
227
|
+
progress: SharedValue<number>;
|
|
228
|
+
/**
|
|
229
|
+
* `0` to `1` across the arrival of the completed drawing. Separate from
|
|
230
|
+
* `progress` because the fill has finished by the time this starts, and the
|
|
231
|
+
* two would otherwise have to share one clock running at two speeds.
|
|
232
|
+
*/
|
|
233
|
+
done: SharedValue<number>;
|
|
234
|
+
/** Width of the button, so the fill's copy of the label can match it. */
|
|
235
|
+
width: number;
|
|
236
|
+
completed: boolean;
|
|
237
|
+
variant: ProgressButtonVariant;
|
|
238
|
+
size: ProgressButtonSize;
|
|
239
|
+
slots: ReturnType<typeof progressButtonVariants>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ProgressButtonContext = createContext<ProgressButtonContextValue | null>(null);
|
|
243
|
+
|
|
244
|
+
function useProgressButtonContext(component: string): ProgressButtonContextValue {
|
|
245
|
+
const context = useContext(ProgressButtonContext);
|
|
246
|
+
if (!context) throw new Error(`${component} must be used within a <ProgressButton>`);
|
|
247
|
+
return context;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** How far through the hold this button is, for something rendered inside it. */
|
|
251
|
+
export function useProgressButton() {
|
|
252
|
+
const { progress, completed } = useProgressButtonContext('useProgressButton');
|
|
253
|
+
return { progress, completed };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface ProgressButtonProps
|
|
257
|
+
extends Omit<PressableProps, 'children' | 'disabled'>,
|
|
258
|
+
Omit<ProgressButtonVariantProps, 'disabled'> {
|
|
259
|
+
className?: string;
|
|
260
|
+
/**
|
|
261
|
+
* Milliseconds the button has to be held. Defaults to `2000`, and is floored
|
|
262
|
+
* at `200` — a hold that completes on touch-down is a button with extra steps.
|
|
263
|
+
*/
|
|
264
|
+
holdDuration?: number;
|
|
265
|
+
/** Fires once the hold has been sustained to the end. */
|
|
266
|
+
onComplete?: () => void;
|
|
267
|
+
/** Fires whenever the completed state changes, including on a reset. */
|
|
268
|
+
onCompletedChange?: (completed: boolean) => void;
|
|
269
|
+
/** Controlled completion. Leave unset to let the button own it. */
|
|
270
|
+
completed?: boolean;
|
|
271
|
+
/** Return to the unfilled state after `autoResetDelay`. */
|
|
272
|
+
autoReset?: boolean;
|
|
273
|
+
/** Milliseconds to stay completed before resetting. Defaults to `1000`. */
|
|
274
|
+
autoResetDelay?: number;
|
|
275
|
+
/**
|
|
276
|
+
* Dim the button and refuse the hold outright. The fill never starts, so
|
|
277
|
+
* there is no half-finished state to explain.
|
|
278
|
+
*/
|
|
279
|
+
disabled?: boolean;
|
|
280
|
+
/**
|
|
281
|
+
* A tick as the hold takes, and a knock when it completes. Off by default:
|
|
282
|
+
* whether an action is worth feeling is the caller's call, not the control's.
|
|
283
|
+
*/
|
|
284
|
+
haptics?: boolean;
|
|
285
|
+
children?: ReactNode;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function ProgressButtonRoot(
|
|
289
|
+
{
|
|
290
|
+
className,
|
|
291
|
+
variant = 'primary',
|
|
292
|
+
size = 'md',
|
|
293
|
+
fullWidth,
|
|
294
|
+
holdDuration,
|
|
295
|
+
onComplete,
|
|
296
|
+
onCompletedChange,
|
|
297
|
+
completed: completedProp,
|
|
298
|
+
autoReset = false,
|
|
299
|
+
autoResetDelay = DEFAULT_AUTO_RESET_DELAY,
|
|
300
|
+
disabled = false,
|
|
301
|
+
haptics = false,
|
|
302
|
+
accessibilityState,
|
|
303
|
+
children,
|
|
304
|
+
...props
|
|
305
|
+
},
|
|
306
|
+
ref
|
|
307
|
+
) {
|
|
308
|
+
const slots = progressButtonVariants({ variant, size, fullWidth, disabled });
|
|
309
|
+
const duration = resolveHoldDuration(holdDuration);
|
|
310
|
+
const reducedMotion = useReducedMotion();
|
|
311
|
+
|
|
312
|
+
const progress = useSharedValue(0);
|
|
313
|
+
const done = useSharedValue(0);
|
|
314
|
+
const [width, setWidth] = useState(0);
|
|
315
|
+
const [internalCompleted, setInternalCompleted] = useState(false);
|
|
316
|
+
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
317
|
+
|
|
318
|
+
const isControlled = completedProp !== undefined;
|
|
319
|
+
const completed = isControlled ? completedProp : internalCompleted;
|
|
320
|
+
|
|
321
|
+
/*
|
|
322
|
+
* The reaction below watches a shared value, and a shared value can reach the
|
|
323
|
+
* end for reasons other than a hold — a controlled button being told it is
|
|
324
|
+
* complete fills instantly. Read from a ref rather than from `completed` so
|
|
325
|
+
* the guard sees the current answer without the reaction being rebuilt.
|
|
326
|
+
*/
|
|
327
|
+
const completedRef = useRef(completed);
|
|
328
|
+
completedRef.current = completed;
|
|
329
|
+
|
|
330
|
+
const finish = useCallback(() => {
|
|
331
|
+
// Already complete: the fill was set from outside, and firing the action
|
|
332
|
+
// again would run it a second time for something the reader did not do.
|
|
333
|
+
if (completedRef.current) return;
|
|
334
|
+
if (!isControlled) setInternalCompleted(true);
|
|
335
|
+
onCompletedChange?.(true);
|
|
336
|
+
onComplete?.();
|
|
337
|
+
if (haptics) impactKnock();
|
|
338
|
+
}, [isControlled, onComplete, onCompletedChange, haptics]);
|
|
339
|
+
|
|
340
|
+
/*
|
|
341
|
+
* Completion is read off the animation rather than timed alongside it.
|
|
342
|
+
*
|
|
343
|
+
* A `setTimeout` for `holdDuration` and a fill for `holdDuration` are two
|
|
344
|
+
* clocks that agree only while the app is idle: the moment the JS thread is
|
|
345
|
+
* busy the button fires before the fill arrives, or the fill sits full while
|
|
346
|
+
* nothing happens. The reaction fires on the frame the value actually
|
|
347
|
+
* reaches the end, which is the frame the reader saw it get there.
|
|
348
|
+
*/
|
|
349
|
+
useAnimatedReaction(
|
|
350
|
+
() => progress.value >= 1,
|
|
351
|
+
(full, was) => {
|
|
352
|
+
if (full && !was) runOnJS(finish)();
|
|
353
|
+
}
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
/*
|
|
357
|
+
* Both directions are started on the UI thread, and that is not a detail.
|
|
358
|
+
*
|
|
359
|
+
* A shared value animated on the UI thread does not report back to
|
|
360
|
+
* JavaScript, so `progress.value` read from a press handler is the value
|
|
361
|
+
* from before the hold began — zero. The release computed from it either did
|
|
362
|
+
* nothing or, worse, `cancelAnimation` wrote that stale zero back and the
|
|
363
|
+
* fill vanished on touch-up instead of travelling home.
|
|
364
|
+
*
|
|
365
|
+
* `runOnUI` puts the read where the value actually lives.
|
|
366
|
+
*/
|
|
367
|
+
const begin = useCallback(() => {
|
|
368
|
+
if (disabled || completed) return;
|
|
369
|
+
if (haptics) selectionTick();
|
|
370
|
+
runOnUI(() => {
|
|
371
|
+
'worklet';
|
|
372
|
+
cancelAnimation(progress);
|
|
373
|
+
const from = progress.value;
|
|
374
|
+
if (from >= 1) return;
|
|
375
|
+
progress.value = withTiming(1, {
|
|
376
|
+
// The distance still ahead, at the fill's own rate — so a press that
|
|
377
|
+
// catches the fill on its way back carries on from there rather than
|
|
378
|
+
// restarting the clock.
|
|
379
|
+
duration: fillDuration(from, duration),
|
|
380
|
+
/*
|
|
381
|
+
* Linear. A fill is constant motion, and an eased one misreports the
|
|
382
|
+
* wait: it races the first half and crawls the second, or the reverse.
|
|
383
|
+
*
|
|
384
|
+
* Under reduced motion it steps instead, and is still a real
|
|
385
|
+
* indicator — what that setting is about is continuous movement, not
|
|
386
|
+
* the button saying how much longer to wait. Take that away and the
|
|
387
|
+
* control asks for a hold with nothing on screen to say why.
|
|
388
|
+
*/
|
|
389
|
+
easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear,
|
|
390
|
+
});
|
|
391
|
+
})();
|
|
392
|
+
}, [disabled, completed, haptics, progress, reducedMotion, duration]);
|
|
393
|
+
|
|
394
|
+
/*
|
|
395
|
+
* The fill, played backwards.
|
|
396
|
+
*
|
|
397
|
+
* Same rate, same easing, same stepping under reduced motion — only the
|
|
398
|
+
* direction differs. Let go at nine tenths of a two-second hold and the fill
|
|
399
|
+
* takes 1.8 seconds to travel home, which is the 1.8 seconds it took to get
|
|
400
|
+
* there. A fill that vanishes has been deleted; a fill that travels back has
|
|
401
|
+
* been let go, and telling those apart is the whole reason the wait is drawn
|
|
402
|
+
* on the button.
|
|
403
|
+
*
|
|
404
|
+
* Every path that empties the fill goes through here — a hold let go, an
|
|
405
|
+
* `autoReset` landing, a controlled button told it is no longer complete.
|
|
406
|
+
* They were three separate assignments and two of them snapped.
|
|
407
|
+
*/
|
|
408
|
+
const rewind = useCallback(() => {
|
|
409
|
+
runOnUI(() => {
|
|
410
|
+
'worklet';
|
|
411
|
+
cancelAnimation(progress);
|
|
412
|
+
const from = progress.value;
|
|
413
|
+
if (from <= 0) {
|
|
414
|
+
progress.value = 0;
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
progress.value = withTiming(0, {
|
|
418
|
+
duration: releaseDuration(from, duration),
|
|
419
|
+
easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear,
|
|
420
|
+
});
|
|
421
|
+
})();
|
|
422
|
+
}, [progress, duration, reducedMotion]);
|
|
423
|
+
|
|
424
|
+
const abandon = useCallback(() => {
|
|
425
|
+
/*
|
|
426
|
+
* A completed hold has nothing to abandon. Without this the fill drains on
|
|
427
|
+
* the release that follows a successful hold — the button empties, looks
|
|
428
|
+
* untouched, and then refuses every press after it, because `begin` bails
|
|
429
|
+
* on a completed button. That combination is a control that has silently
|
|
430
|
+
* stopped working.
|
|
431
|
+
*/
|
|
432
|
+
if (completedRef.current) return;
|
|
433
|
+
rewind();
|
|
434
|
+
}, [rewind]);
|
|
435
|
+
|
|
436
|
+
const reset = useCallback(() => {
|
|
437
|
+
rewind();
|
|
438
|
+
if (!isControlled) setInternalCompleted(false);
|
|
439
|
+
onCompletedChange?.(false);
|
|
440
|
+
}, [rewind, isControlled, onCompletedChange]);
|
|
441
|
+
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
if (!completed || !autoReset) return;
|
|
444
|
+
resetTimer.current = setTimeout(reset, autoResetDelay);
|
|
445
|
+
return () => {
|
|
446
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
447
|
+
resetTimer.current = null;
|
|
448
|
+
};
|
|
449
|
+
}, [completed, autoReset, autoResetDelay, reset]);
|
|
450
|
+
|
|
451
|
+
/*
|
|
452
|
+
* The fill follows the completed state, in both directions.
|
|
453
|
+
*
|
|
454
|
+
* A controlled button told it is complete fills without a hold, and one told
|
|
455
|
+
* it is no longer complete empties — otherwise the next hold would start from
|
|
456
|
+
* a bar that is already full.
|
|
457
|
+
*/
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (completed) {
|
|
460
|
+
cancelAnimation(progress);
|
|
461
|
+
progress.value = 1;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
rewind();
|
|
465
|
+
}, [completed, progress, rewind]);
|
|
466
|
+
|
|
467
|
+
/*
|
|
468
|
+
* And the completed drawing follows it too. Under reduced motion it is a
|
|
469
|
+
* swap: what that setting is about is movement, and a tick that grows is
|
|
470
|
+
* movement with nothing to report.
|
|
471
|
+
*/
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
if (reducedMotion) {
|
|
474
|
+
done.value = completed ? 1 : 0;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
done.value = completed ? withSpring(1, DONE_SPRING) : withTiming(0, { duration: DONE_EXIT });
|
|
478
|
+
}, [completed, done, reducedMotion]);
|
|
479
|
+
|
|
480
|
+
useEffect(
|
|
481
|
+
() => () => {
|
|
482
|
+
cancelAnimation(progress);
|
|
483
|
+
cancelAnimation(done);
|
|
484
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
485
|
+
},
|
|
486
|
+
[progress, done]
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
490
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
491
|
+
setWidth((current) => (current === next ? current : next));
|
|
492
|
+
props.onLayout?.(event);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/*
|
|
496
|
+
* `slots` is rebuilt every render, so it is deliberately not a dependency —
|
|
497
|
+
* including it would make this memo re-run every time and hand every consumer
|
|
498
|
+
* a new object for no change. The variant and size it is derived from are the
|
|
499
|
+
* dependencies instead.
|
|
500
|
+
*/
|
|
501
|
+
const context = useMemo<ProgressButtonContextValue>(
|
|
502
|
+
() => ({ progress, done, width, completed, variant, size, slots }),
|
|
503
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
504
|
+
[progress, done, width, completed, variant, size]
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
/*
|
|
508
|
+
* The completed drawing is part of the button rather than something every
|
|
509
|
+
* call site has to remember, so one is added unless the caller wrote their
|
|
510
|
+
* own. A hold that lands and shows nothing is the reader wondering whether it
|
|
511
|
+
* worked, which is the thing this control exists to remove.
|
|
512
|
+
*/
|
|
513
|
+
const written = Children.toArray(children);
|
|
514
|
+
const hasDone = written.some(
|
|
515
|
+
(child) => isValidElement(child) && child.type === ProgressButtonDone
|
|
516
|
+
);
|
|
517
|
+
const body = (
|
|
518
|
+
<>
|
|
519
|
+
{written.length > 0 ? written : <ProgressButtonLabel>Hold to confirm</ProgressButtonLabel>}
|
|
520
|
+
{hasDone ? null : <ProgressButtonDone />}
|
|
521
|
+
</>
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
<ProgressButtonContext.Provider value={context}>
|
|
526
|
+
<Pressable
|
|
527
|
+
ref={ref}
|
|
528
|
+
accessibilityRole="button"
|
|
529
|
+
/*
|
|
530
|
+
* Spoken as what it is. A button whose label says "Hold to confirm"
|
|
531
|
+
* still announces as an ordinary button to anyone who cannot see the
|
|
532
|
+
* fill, and a single activation does nothing — so the hint carries the
|
|
533
|
+
* instruction and the state carries the outcome.
|
|
534
|
+
*/
|
|
535
|
+
accessibilityHint="Press and hold to confirm"
|
|
536
|
+
accessibilityState={{
|
|
537
|
+
...accessibilityState,
|
|
538
|
+
disabled,
|
|
539
|
+
checked: completed,
|
|
540
|
+
}}
|
|
541
|
+
disabled={disabled}
|
|
542
|
+
/*
|
|
543
|
+
* The spread sits above the hold rather than below it. Underneath, a
|
|
544
|
+
* caller passing `onPressIn` — reasonably, to log a tap — would replace
|
|
545
|
+
* the gesture the whole control is, and the button would simply never
|
|
546
|
+
* fill.
|
|
547
|
+
*/
|
|
548
|
+
{...props}
|
|
549
|
+
onPressIn={begin}
|
|
550
|
+
onPressOut={abandon}
|
|
551
|
+
// A few points of drift should not abandon a hold the reader is
|
|
552
|
+
// still making. Fingers move.
|
|
553
|
+
pressRetentionOffset={16}
|
|
554
|
+
onLayout={onLayout}
|
|
555
|
+
className={slots.root({ className })}
|
|
556
|
+
>
|
|
557
|
+
{body}
|
|
558
|
+
</Pressable>
|
|
559
|
+
</ProgressButtonContext.Provider>
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
export interface ProgressButtonLabelProps extends ViewProps {
|
|
564
|
+
className?: string;
|
|
565
|
+
children?: ReactNode;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* What the button says, drawn twice.
|
|
570
|
+
*
|
|
571
|
+
* The second copy is the one inside the fill, in the inverted colour, pinned to
|
|
572
|
+
* the button's own width and clipped to however far the hold has got. Both are
|
|
573
|
+
* the same text at the same position, so the boundary between them falls in the
|
|
574
|
+
* middle of a glyph rather than between two differently laid-out lines.
|
|
575
|
+
*/
|
|
576
|
+
function ProgressButtonLabel({ className, children, ...props }: ProgressButtonLabelProps) {
|
|
577
|
+
const { progress, width, slots } = useProgressButtonContext('ProgressButton.Label');
|
|
578
|
+
|
|
579
|
+
const fillStyle = useAnimatedStyle(() => ({ width: width * progress.value }));
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<View {...props} className={slots.content({ className })}>
|
|
583
|
+
{textChildren(children, (text) => (
|
|
584
|
+
<Text className={slots.label()}>{text}</Text>
|
|
585
|
+
))}
|
|
586
|
+
|
|
587
|
+
{/* The wipe. `pointerEvents` off so it never takes the press it is
|
|
588
|
+
drawn on top of, and `width` fixed to the button so the copy inside
|
|
589
|
+
it is laid out exactly where the original is rather than reflowing
|
|
590
|
+
into whatever the clip currently allows. */}
|
|
591
|
+
<Animated.View
|
|
592
|
+
pointerEvents="none"
|
|
593
|
+
style={[
|
|
594
|
+
{ position: 'absolute', top: 0, bottom: 0, left: 0, overflow: 'hidden' },
|
|
595
|
+
fillStyle,
|
|
596
|
+
]}
|
|
597
|
+
className={slots.fill()}
|
|
598
|
+
>
|
|
599
|
+
<View style={{ width }} className={slots.content()}>
|
|
600
|
+
{textChildren(children, (text) => (
|
|
601
|
+
<Text className={slots.fillLabel()}>{text}</Text>
|
|
602
|
+
))}
|
|
603
|
+
</View>
|
|
604
|
+
</Animated.View>
|
|
605
|
+
</View>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
ProgressButtonLabel.displayName = 'ProgressButton.Label';
|
|
609
|
+
|
|
610
|
+
export interface ProgressButtonDoneProps extends ViewProps {
|
|
611
|
+
className?: string;
|
|
612
|
+
/**
|
|
613
|
+
* What the button shows once the hold has landed. A tick on its own by
|
|
614
|
+
* default; children replace it, so a word beside one is
|
|
615
|
+
* `<ProgressButton.Done><CheckIcon /><Text>Paid</Text></ProgressButton.Done>`.
|
|
616
|
+
*/
|
|
617
|
+
children?: ReactNode;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The drawing the button lands on.
|
|
622
|
+
*
|
|
623
|
+
* It sits over the finished fill rather than beside the label, so the button
|
|
624
|
+
* does not change width at the moment it completes — a control that resizes as
|
|
625
|
+
* it succeeds moves everything under it, and the reader's eye is on the button.
|
|
626
|
+
*
|
|
627
|
+
* One is added for you unless you write your own, because a hold that lands and
|
|
628
|
+
* shows nothing leaves the reader checking whether it worked.
|
|
629
|
+
*/
|
|
630
|
+
function ProgressButtonDone({ className, children, ...props }: ProgressButtonDoneProps) {
|
|
631
|
+
const { done, completed, variant, slots } = useProgressButtonContext('ProgressButton.Done');
|
|
632
|
+
const tint = useCSSVariable(DONE_TINT[variant]);
|
|
633
|
+
|
|
634
|
+
/*
|
|
635
|
+
* It carries the fill's own colour and covers the button edge to edge.
|
|
636
|
+
*
|
|
637
|
+
* The alternative — fading the label out from under it — takes the fill with
|
|
638
|
+
* it, because the fill is drawn inside the label so that the two copies of
|
|
639
|
+
* the text line up. The button would empty at the exact moment it succeeded,
|
|
640
|
+
* and the tick, drawn in the colour that reads against a full fill, would be
|
|
641
|
+
* left standing on nothing.
|
|
642
|
+
*/
|
|
643
|
+
const style = useAnimatedStyle(() => ({ opacity: done.value }));
|
|
644
|
+
// The tick arrives from slightly under full size. The ground it lands on
|
|
645
|
+
// does not: a background box that grows reads as the button resizing.
|
|
646
|
+
const markStyle = useAnimatedStyle(() => ({ transform: [{ scale: 0.7 + done.value * 0.3 }] }));
|
|
647
|
+
|
|
648
|
+
return (
|
|
649
|
+
<Animated.View
|
|
650
|
+
{...props}
|
|
651
|
+
// Never takes the press: the button underneath still owns it, which is
|
|
652
|
+
// what lets a completed button be reset and held again.
|
|
653
|
+
pointerEvents="none"
|
|
654
|
+
accessibilityElementsHidden={!completed}
|
|
655
|
+
importantForAccessibility={completed ? 'auto' : 'no-hide-descendants'}
|
|
656
|
+
style={[{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }, style]}
|
|
657
|
+
className={slots.fill({ className: slots.content({ className }) })}
|
|
658
|
+
>
|
|
659
|
+
<Animated.View style={markStyle} className={slots.content()}>
|
|
660
|
+
<IconColorProvider color={typeof tint === 'string' ? tint : undefined}>
|
|
661
|
+
{children ?? <CheckIcon size={18} />}
|
|
662
|
+
</IconColorProvider>
|
|
663
|
+
</Animated.View>
|
|
664
|
+
</Animated.View>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
ProgressButtonDone.displayName = 'ProgressButton.Done';
|
|
668
|
+
|
|
669
|
+
ProgressButtonRoot.displayName = 'ProgressButton';
|
|
670
|
+
|
|
671
|
+
export const ProgressButton = Object.assign(ProgressButtonRoot, {
|
|
672
|
+
Label: ProgressButtonLabel,
|
|
673
|
+
Done: ProgressButtonDone,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
export {
|
|
677
|
+
DEFAULT_AUTO_RESET_DELAY,
|
|
678
|
+
DEFAULT_HOLD_DURATION,
|
|
679
|
+
DEFAULT_RELEASE_DURATION,
|
|
680
|
+
fillDuration,
|
|
681
|
+
isComplete,
|
|
682
|
+
releaseDuration,
|
|
683
|
+
resolveHoldDuration,
|
|
684
|
+
} from './progress-button-hold';
|