panelui-native 0.76.0 → 0.78.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/ai-input/index.js +23 -7
- package/lib/module/components/ai-input/index.js.map +1 -1
- 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 +98 -15
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +7 -1
- package/lib/module/components/button/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/marquee/index.js +91 -5
- package/lib/module/components/marquee/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 +390 -0
- package/lib/module/components/progress-button/index.js.map +1 -0
- package/lib/module/components/progress-button/progress-button-hold.js +57 -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/select/index.js +1 -0
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slider/index.js +1 -0
- package/lib/module/components/slider/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/switch/index.js +1 -0
- package/lib/module/components/switch/index.js.map +1 -1
- 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 +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js.map +1 -1
- package/lib/typescript/src/components/ai-input/index.d.ts +24 -5
- package/lib/typescript/src/components/ai-input/index.d.ts.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/button/index.d.ts +15 -0
- package/lib/typescript/src/components/button/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/marquee/index.d.ts +55 -2
- package/lib/typescript/src/components/marquee/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 +295 -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 +40 -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/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 +3 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +17 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/ai-input/index.tsx +46 -12
- package/src/components/area-chart/index.tsx +49 -37
- package/src/components/bottom-sheet/index.tsx +110 -12
- package/src/components/button/index.tsx +20 -1
- 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/marquee/index.tsx +129 -6
- package/src/components/pie-chart/index.tsx +10 -2
- package/src/components/plot/index.tsx +66 -50
- package/src/components/progress-button/index.tsx +465 -0
- package/src/components/progress-button/progress-button-hold.ts +57 -0
- package/src/components/scatter-chart/index.tsx +4 -25
- package/src/components/section-rail/index.tsx +39 -19
- package/src/components/select/index.tsx +1 -1
- package/src/components/slider/index.tsx +1 -1
- package/src/components/split-view/index.tsx +53 -8
- package/src/components/switch/index.tsx +1 -1
- 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 +10 -0
- package/src/native/index.ts +17 -0
|
@@ -0,0 +1,465 @@
|
|
|
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
|
+
createContext,
|
|
45
|
+
forwardRef,
|
|
46
|
+
useCallback,
|
|
47
|
+
useContext,
|
|
48
|
+
useEffect,
|
|
49
|
+
useMemo,
|
|
50
|
+
useRef,
|
|
51
|
+
useState,
|
|
52
|
+
type ReactNode,
|
|
53
|
+
} from 'react';
|
|
54
|
+
import {
|
|
55
|
+
Pressable,
|
|
56
|
+
View,
|
|
57
|
+
type LayoutChangeEvent,
|
|
58
|
+
type PressableProps,
|
|
59
|
+
type ViewProps,
|
|
60
|
+
} from 'react-native';
|
|
61
|
+
import Animated, {
|
|
62
|
+
Easing,
|
|
63
|
+
cancelAnimation,
|
|
64
|
+
runOnJS,
|
|
65
|
+
useAnimatedReaction,
|
|
66
|
+
useAnimatedStyle,
|
|
67
|
+
useReducedMotion,
|
|
68
|
+
useSharedValue,
|
|
69
|
+
withTiming,
|
|
70
|
+
type SharedValue,
|
|
71
|
+
} from 'react-native-reanimated';
|
|
72
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
73
|
+
import { Text, textChildren } from '../../primitives/text';
|
|
74
|
+
import { cn } from '../../utils/cn';
|
|
75
|
+
import { impactKnock, selectionTick } from '../../utils/haptics';
|
|
76
|
+
import {
|
|
77
|
+
DEFAULT_AUTO_RESET_DELAY,
|
|
78
|
+
DEFAULT_RELEASE_DURATION,
|
|
79
|
+
releaseDuration,
|
|
80
|
+
resolveHoldDuration,
|
|
81
|
+
} from './progress-button-hold';
|
|
82
|
+
|
|
83
|
+
/** How many steps the reduced-motion fill advances in. */
|
|
84
|
+
const REDUCED_STEPS = 5;
|
|
85
|
+
|
|
86
|
+
const progressButtonVariants = tv({
|
|
87
|
+
slots: {
|
|
88
|
+
/*
|
|
89
|
+
* A pill, not the `rounded-lg` the other buttons take.
|
|
90
|
+
*
|
|
91
|
+
* The fill is clipped by this radius, so the shape of the button is also
|
|
92
|
+
* the shape of the wipe's leading edge as it comes out of the corner. On a
|
|
93
|
+
* small radius that edge emerges square from a rounded box, which reads as
|
|
94
|
+
* a rectangle sliding out from under the button rather than as the button
|
|
95
|
+
* filling up.
|
|
96
|
+
*/
|
|
97
|
+
root: 'relative overflow-hidden rounded-full border',
|
|
98
|
+
/*
|
|
99
|
+
* The row inside the button. It is separate from `root` because the fill
|
|
100
|
+
* has to sit over the whole button including its padding — a wipe that
|
|
101
|
+
* stops at the text's own box leaves an unfilled margin down each side and
|
|
102
|
+
* reads as a progress bar someone put inside a button.
|
|
103
|
+
*/
|
|
104
|
+
content: 'flex-row items-center justify-center gap-2',
|
|
105
|
+
label: 'min-w-0 shrink text-center font-medium',
|
|
106
|
+
/** The inverted copy, drawn on the filled ground. */
|
|
107
|
+
fill: '',
|
|
108
|
+
fillLabel: 'min-w-0 shrink text-center font-medium',
|
|
109
|
+
},
|
|
110
|
+
variants: {
|
|
111
|
+
variant: {
|
|
112
|
+
primary: {
|
|
113
|
+
root: 'border-primary bg-transparent',
|
|
114
|
+
label: 'text-primary',
|
|
115
|
+
fill: 'bg-primary',
|
|
116
|
+
fillLabel: 'text-primary-foreground',
|
|
117
|
+
},
|
|
118
|
+
secondary: {
|
|
119
|
+
root: 'border-transparent bg-secondary',
|
|
120
|
+
label: 'text-secondary-foreground',
|
|
121
|
+
fill: 'bg-foreground',
|
|
122
|
+
fillLabel: 'text-background',
|
|
123
|
+
},
|
|
124
|
+
destructive: {
|
|
125
|
+
root: 'border-destructive bg-transparent',
|
|
126
|
+
label: 'text-destructive',
|
|
127
|
+
fill: 'bg-destructive',
|
|
128
|
+
fillLabel: 'text-destructive-solid-foreground',
|
|
129
|
+
},
|
|
130
|
+
success: {
|
|
131
|
+
root: 'border-success bg-transparent',
|
|
132
|
+
label: 'text-success',
|
|
133
|
+
fill: 'bg-success',
|
|
134
|
+
fillLabel: 'text-success-solid-foreground',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
size: {
|
|
138
|
+
// Matched to Button's boxes, and `min-h-*` for the same reason: the
|
|
139
|
+
// label's glyphs grow with the system text size and the box has to grow
|
|
140
|
+
// with them.
|
|
141
|
+
// Wider than the equivalent Button, because the corner is a half-circle
|
|
142
|
+
// rather than a small radius: the curve eats into the side padding, and
|
|
143
|
+
// at Button's values the first and last glyphs sit against it.
|
|
144
|
+
sm: {
|
|
145
|
+
root: 'min-h-9',
|
|
146
|
+
content: 'min-h-9 gap-1.5 px-3.5 py-2',
|
|
147
|
+
label: 'text-[14px]',
|
|
148
|
+
fillLabel: 'text-[14px]',
|
|
149
|
+
},
|
|
150
|
+
md: {
|
|
151
|
+
root: 'min-h-11',
|
|
152
|
+
content: 'min-h-11 px-5 py-2.5',
|
|
153
|
+
label: 'text-[16px]',
|
|
154
|
+
fillLabel: 'text-[16px]',
|
|
155
|
+
},
|
|
156
|
+
lg: {
|
|
157
|
+
root: 'min-h-12',
|
|
158
|
+
content: 'min-h-12 px-7 py-2.5',
|
|
159
|
+
label: 'text-[18px]',
|
|
160
|
+
fillLabel: 'text-[18px]',
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
fullWidth: {
|
|
164
|
+
true: { root: 'w-full' },
|
|
165
|
+
},
|
|
166
|
+
disabled: {
|
|
167
|
+
true: { root: 'opacity-[0.64]' },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
defaultVariants: {
|
|
171
|
+
variant: 'primary',
|
|
172
|
+
size: 'md',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
type ProgressButtonVariantProps = VariantProps<typeof progressButtonVariants>;
|
|
177
|
+
|
|
178
|
+
/** How a progress button looks. */
|
|
179
|
+
export type ProgressButtonVariant = NonNullable<ProgressButtonVariantProps['variant']>;
|
|
180
|
+
/** How big a progress button is. */
|
|
181
|
+
export type ProgressButtonSize = NonNullable<ProgressButtonVariantProps['size']>;
|
|
182
|
+
|
|
183
|
+
interface ProgressButtonContextValue {
|
|
184
|
+
/** `0` to `1` across the hold. */
|
|
185
|
+
progress: SharedValue<number>;
|
|
186
|
+
/** Width of the button, so the fill's copy of the label can match it. */
|
|
187
|
+
width: number;
|
|
188
|
+
completed: boolean;
|
|
189
|
+
variant: ProgressButtonVariant;
|
|
190
|
+
size: ProgressButtonSize;
|
|
191
|
+
slots: ReturnType<typeof progressButtonVariants>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const ProgressButtonContext = createContext<ProgressButtonContextValue | null>(null);
|
|
195
|
+
|
|
196
|
+
function useProgressButtonContext(component: string): ProgressButtonContextValue {
|
|
197
|
+
const context = useContext(ProgressButtonContext);
|
|
198
|
+
if (!context) throw new Error(`${component} must be used within a <ProgressButton>`);
|
|
199
|
+
return context;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** How far through the hold this button is, for something rendered inside it. */
|
|
203
|
+
export function useProgressButton() {
|
|
204
|
+
const { progress, completed } = useProgressButtonContext('useProgressButton');
|
|
205
|
+
return { progress, completed };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ProgressButtonProps
|
|
209
|
+
extends Omit<PressableProps, 'children' | 'disabled'>,
|
|
210
|
+
Omit<ProgressButtonVariantProps, 'disabled'> {
|
|
211
|
+
className?: string;
|
|
212
|
+
/**
|
|
213
|
+
* Milliseconds the button has to be held. Defaults to `2000`, and is floored
|
|
214
|
+
* at `200` — a hold that completes on touch-down is a button with extra steps.
|
|
215
|
+
*/
|
|
216
|
+
holdDuration?: number;
|
|
217
|
+
/** Fires once the hold has been sustained to the end. */
|
|
218
|
+
onComplete?: () => void;
|
|
219
|
+
/** Fires whenever the completed state changes, including on a reset. */
|
|
220
|
+
onCompletedChange?: (completed: boolean) => void;
|
|
221
|
+
/** Controlled completion. Leave unset to let the button own it. */
|
|
222
|
+
completed?: boolean;
|
|
223
|
+
/** Return to the unfilled state after `autoResetDelay`. */
|
|
224
|
+
autoReset?: boolean;
|
|
225
|
+
/** Milliseconds to stay completed before resetting. Defaults to `1000`. */
|
|
226
|
+
autoResetDelay?: number;
|
|
227
|
+
disabled?: boolean;
|
|
228
|
+
/**
|
|
229
|
+
* A tick as the hold takes, and a knock when it completes. Off by default:
|
|
230
|
+
* whether an action is worth feeling is the caller's call, not the control's.
|
|
231
|
+
*/
|
|
232
|
+
haptics?: boolean;
|
|
233
|
+
children?: ReactNode;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function ProgressButtonRoot(
|
|
237
|
+
{
|
|
238
|
+
className,
|
|
239
|
+
variant = 'primary',
|
|
240
|
+
size = 'md',
|
|
241
|
+
fullWidth,
|
|
242
|
+
holdDuration,
|
|
243
|
+
onComplete,
|
|
244
|
+
onCompletedChange,
|
|
245
|
+
completed: completedProp,
|
|
246
|
+
autoReset = false,
|
|
247
|
+
autoResetDelay = DEFAULT_AUTO_RESET_DELAY,
|
|
248
|
+
disabled = false,
|
|
249
|
+
haptics = false,
|
|
250
|
+
accessibilityState,
|
|
251
|
+
children,
|
|
252
|
+
...props
|
|
253
|
+
},
|
|
254
|
+
ref
|
|
255
|
+
) {
|
|
256
|
+
const slots = progressButtonVariants({ variant, size, fullWidth, disabled });
|
|
257
|
+
const duration = resolveHoldDuration(holdDuration);
|
|
258
|
+
const reducedMotion = useReducedMotion();
|
|
259
|
+
|
|
260
|
+
const progress = useSharedValue(0);
|
|
261
|
+
const [width, setWidth] = useState(0);
|
|
262
|
+
const [internalCompleted, setInternalCompleted] = useState(false);
|
|
263
|
+
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
264
|
+
|
|
265
|
+
const isControlled = completedProp !== undefined;
|
|
266
|
+
const completed = isControlled ? completedProp : internalCompleted;
|
|
267
|
+
|
|
268
|
+
const finish = useCallback(() => {
|
|
269
|
+
if (!isControlled) setInternalCompleted(true);
|
|
270
|
+
onCompletedChange?.(true);
|
|
271
|
+
onComplete?.();
|
|
272
|
+
if (haptics) impactKnock();
|
|
273
|
+
}, [isControlled, onComplete, onCompletedChange, haptics]);
|
|
274
|
+
|
|
275
|
+
/*
|
|
276
|
+
* Completion is read off the animation rather than timed alongside it.
|
|
277
|
+
*
|
|
278
|
+
* A `setTimeout` for `holdDuration` and a fill for `holdDuration` are two
|
|
279
|
+
* clocks that agree only while the app is idle: the moment the JS thread is
|
|
280
|
+
* busy the button fires before the fill arrives, or the fill sits full while
|
|
281
|
+
* nothing happens. The reaction fires on the frame the value actually
|
|
282
|
+
* reaches the end, which is the frame the reader saw it get there.
|
|
283
|
+
*/
|
|
284
|
+
useAnimatedReaction(
|
|
285
|
+
() => progress.value >= 1,
|
|
286
|
+
(full, was) => {
|
|
287
|
+
if (full && !was) runOnJS(finish)();
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const begin = useCallback(() => {
|
|
292
|
+
if (disabled || completed) return;
|
|
293
|
+
if (haptics) selectionTick();
|
|
294
|
+
cancelAnimation(progress);
|
|
295
|
+
if (reducedMotion) {
|
|
296
|
+
/*
|
|
297
|
+
* Stepped rather than smooth, and still a real indicator. What reduced
|
|
298
|
+
* motion is about is continuous movement, not the button telling you how
|
|
299
|
+
* much longer to wait — take that away and the control asks for a hold
|
|
300
|
+
* with nothing on screen to say why.
|
|
301
|
+
*/
|
|
302
|
+
progress.value = withTiming(1, {
|
|
303
|
+
duration,
|
|
304
|
+
easing: Easing.steps(REDUCED_STEPS, true),
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// Linear. A fill is constant motion, and an eased one misreports the wait:
|
|
309
|
+
// it races the first half and crawls the second, or the reverse.
|
|
310
|
+
progress.value = withTiming(1, { duration, easing: Easing.linear });
|
|
311
|
+
}, [disabled, completed, haptics, progress, reducedMotion, duration]);
|
|
312
|
+
|
|
313
|
+
const abandon = useCallback(() => {
|
|
314
|
+
cancelAnimation(progress);
|
|
315
|
+
if (progress.value <= 0) return;
|
|
316
|
+
progress.value = withTiming(0, {
|
|
317
|
+
// Proportional to how far it got. A fixed release makes a hold abandoned
|
|
318
|
+
// after a moment feel sticky, which reads as the button resisting.
|
|
319
|
+
duration: releaseDuration(progress.value, DEFAULT_RELEASE_DURATION),
|
|
320
|
+
easing: Easing.out(Easing.cubic),
|
|
321
|
+
});
|
|
322
|
+
}, [progress]);
|
|
323
|
+
|
|
324
|
+
const reset = useCallback(() => {
|
|
325
|
+
cancelAnimation(progress);
|
|
326
|
+
progress.value = 0;
|
|
327
|
+
if (!isControlled) setInternalCompleted(false);
|
|
328
|
+
onCompletedChange?.(false);
|
|
329
|
+
}, [progress, isControlled, onCompletedChange]);
|
|
330
|
+
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (!completed || !autoReset) return;
|
|
333
|
+
resetTimer.current = setTimeout(reset, autoResetDelay);
|
|
334
|
+
return () => {
|
|
335
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
336
|
+
resetTimer.current = null;
|
|
337
|
+
};
|
|
338
|
+
}, [completed, autoReset, autoResetDelay, reset]);
|
|
339
|
+
|
|
340
|
+
// A controlled button told it is no longer complete has to empty its fill,
|
|
341
|
+
// or the next hold starts from a bar that is already full.
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
if (!completed) return;
|
|
344
|
+
progress.value = 1;
|
|
345
|
+
}, [completed, progress]);
|
|
346
|
+
|
|
347
|
+
useEffect(
|
|
348
|
+
() => () => {
|
|
349
|
+
cancelAnimation(progress);
|
|
350
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
351
|
+
},
|
|
352
|
+
[progress]
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
356
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
357
|
+
setWidth((current) => (current === next ? current : next));
|
|
358
|
+
props.onLayout?.(event);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const context = useMemo<ProgressButtonContextValue>(
|
|
362
|
+
() => ({ progress, width, completed, variant, size, slots }),
|
|
363
|
+
[progress, width, completed, variant, size, slots]
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const body = children ?? (
|
|
367
|
+
<>
|
|
368
|
+
<ProgressButtonLabel>Hold to confirm</ProgressButtonLabel>
|
|
369
|
+
</>
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
return (
|
|
373
|
+
<ProgressButtonContext.Provider value={context}>
|
|
374
|
+
<Pressable
|
|
375
|
+
ref={ref}
|
|
376
|
+
accessibilityRole="button"
|
|
377
|
+
/*
|
|
378
|
+
* Spoken as what it is. A button whose label says "Hold to confirm"
|
|
379
|
+
* still announces as an ordinary button to anyone who cannot see the
|
|
380
|
+
* fill, and a single activation does nothing — so the hint carries the
|
|
381
|
+
* instruction and the state carries the outcome.
|
|
382
|
+
*/
|
|
383
|
+
accessibilityHint="Press and hold to confirm"
|
|
384
|
+
accessibilityState={{
|
|
385
|
+
...accessibilityState,
|
|
386
|
+
disabled,
|
|
387
|
+
checked: completed,
|
|
388
|
+
}}
|
|
389
|
+
disabled={disabled}
|
|
390
|
+
onPressIn={begin}
|
|
391
|
+
onPressOut={abandon}
|
|
392
|
+
// A few points of drift should not abandon a hold the reader is
|
|
393
|
+
// still making. Fingers move.
|
|
394
|
+
pressRetentionOffset={16}
|
|
395
|
+
{...props}
|
|
396
|
+
onLayout={onLayout}
|
|
397
|
+
className={slots.root({ className })}
|
|
398
|
+
>
|
|
399
|
+
{body}
|
|
400
|
+
</Pressable>
|
|
401
|
+
</ProgressButtonContext.Provider>
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
export interface ProgressButtonLabelProps extends ViewProps {
|
|
406
|
+
className?: string;
|
|
407
|
+
children?: ReactNode;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* What the button says, drawn twice.
|
|
412
|
+
*
|
|
413
|
+
* The second copy is the one inside the fill, in the inverted colour, pinned to
|
|
414
|
+
* the button's own width and clipped to however far the hold has got. Both are
|
|
415
|
+
* the same text at the same position, so the boundary between them falls in the
|
|
416
|
+
* middle of a glyph rather than between two differently laid-out lines.
|
|
417
|
+
*/
|
|
418
|
+
function ProgressButtonLabel({ className, children, ...props }: ProgressButtonLabelProps) {
|
|
419
|
+
const { progress, width, slots } = useProgressButtonContext('ProgressButton.Label');
|
|
420
|
+
|
|
421
|
+
const fillStyle = useAnimatedStyle(() => ({ width: width * progress.value }));
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<View {...props} className={slots.content({ className })}>
|
|
425
|
+
{textChildren(children, (text) => (
|
|
426
|
+
<Text className={slots.label()}>{text}</Text>
|
|
427
|
+
))}
|
|
428
|
+
|
|
429
|
+
{/* The wipe. `pointerEvents` off so it never takes the press it is
|
|
430
|
+
drawn on top of, and `width` fixed to the button so the copy inside
|
|
431
|
+
it is laid out exactly where the original is rather than reflowing
|
|
432
|
+
into whatever the clip currently allows. */}
|
|
433
|
+
<Animated.View
|
|
434
|
+
pointerEvents="none"
|
|
435
|
+
style={[
|
|
436
|
+
{ position: 'absolute', top: 0, bottom: 0, left: 0, overflow: 'hidden' },
|
|
437
|
+
fillStyle,
|
|
438
|
+
]}
|
|
439
|
+
className={slots.fill()}
|
|
440
|
+
>
|
|
441
|
+
<View style={{ width }} className={slots.content()}>
|
|
442
|
+
{textChildren(children, (text) => (
|
|
443
|
+
<Text className={slots.fillLabel()}>{text}</Text>
|
|
444
|
+
))}
|
|
445
|
+
</View>
|
|
446
|
+
</Animated.View>
|
|
447
|
+
</View>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
ProgressButtonLabel.displayName = 'ProgressButton.Label';
|
|
451
|
+
|
|
452
|
+
ProgressButtonRoot.displayName = 'ProgressButton';
|
|
453
|
+
|
|
454
|
+
export const ProgressButton = Object.assign(ProgressButtonRoot, {
|
|
455
|
+
Label: ProgressButtonLabel,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
export {
|
|
459
|
+
DEFAULT_AUTO_RESET_DELAY,
|
|
460
|
+
DEFAULT_HOLD_DURATION,
|
|
461
|
+
DEFAULT_RELEASE_DURATION,
|
|
462
|
+
isComplete,
|
|
463
|
+
releaseDuration,
|
|
464
|
+
resolveHoldDuration,
|
|
465
|
+
} from './progress-button-hold';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic behind a hold, kept out of the component so it can be tested
|
|
3
|
+
* without a renderer — the same split `splitter-math` and `ai-input-growth` use.
|
|
4
|
+
*
|
|
5
|
+
* None of it is worklet-only, so the component can call it from either runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Milliseconds a hold has to be sustained before it counts. */
|
|
9
|
+
export const DEFAULT_HOLD_DURATION = 2000;
|
|
10
|
+
|
|
11
|
+
/** Milliseconds the fill takes to drain when a hold is abandoned. */
|
|
12
|
+
export const DEFAULT_RELEASE_DURATION = 240;
|
|
13
|
+
|
|
14
|
+
/** Milliseconds before an `autoReset` button offers itself again. */
|
|
15
|
+
export const DEFAULT_AUTO_RESET_DELAY = 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A hold duration that cannot produce a broken animation.
|
|
19
|
+
*
|
|
20
|
+
* Zero would complete on touch-down, which defeats the point of the control:
|
|
21
|
+
* a hold-to-confirm that confirms on a tap is a button with extra steps. A
|
|
22
|
+
* floor rather than a rejection, because the caller asking for `0` wants it
|
|
23
|
+
* fast, not broken.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveHoldDuration(duration: number | undefined): number {
|
|
26
|
+
if (duration === undefined) return DEFAULT_HOLD_DURATION;
|
|
27
|
+
if (!Number.isFinite(duration)) return DEFAULT_HOLD_DURATION;
|
|
28
|
+
return Math.max(200, duration);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How long the fill should take to drain from where it is.
|
|
33
|
+
*
|
|
34
|
+
* Proportional to how far it got, so abandoning a hold at a tenth of the way
|
|
35
|
+
* does not take the same quarter-second as abandoning it at nine tenths. A
|
|
36
|
+
* fixed release makes a barely-started hold feel sticky, which reads as the
|
|
37
|
+
* control resisting being let go.
|
|
38
|
+
*/
|
|
39
|
+
export function releaseDuration(
|
|
40
|
+
progress: number,
|
|
41
|
+
full: number = DEFAULT_RELEASE_DURATION
|
|
42
|
+
): number {
|
|
43
|
+
const travelled = Math.min(1, Math.max(0, progress));
|
|
44
|
+
return Math.max(80, full * travelled);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a hold that has been released should still be honoured.
|
|
49
|
+
*
|
|
50
|
+
* The fill is the promise, so anything short of the end is not a confirmation —
|
|
51
|
+
* there is no "close enough" here. It exists as a named function because the
|
|
52
|
+
* tempting version of this rule is a tolerance, and a tolerance means the
|
|
53
|
+
* button sometimes fires when the reader let go early on purpose.
|
|
54
|
+
*/
|
|
55
|
+
export function isComplete(progress: number): boolean {
|
|
56
|
+
return progress >= 1;
|
|
57
|
+
}
|
|
@@ -70,6 +70,7 @@ import { Text } from '../../primitives/text';
|
|
|
70
70
|
import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
|
|
71
71
|
import { compactNumber, useSeriesColor, xAt, yOf, type Plot } from '../../utils/chart';
|
|
72
72
|
import { cn } from '../../utils/cn';
|
|
73
|
+
import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
|
|
73
74
|
|
|
74
75
|
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|
75
76
|
const AnimatedG = Animated.createAnimatedComponent(G);
|
|
@@ -81,9 +82,6 @@ const AnimatedG = Animated.createAnimatedComponent(G);
|
|
|
81
82
|
*/
|
|
82
83
|
const STAGGER = 0.4;
|
|
83
84
|
|
|
84
|
-
/** Milliseconds for the placeholder field to dissolve once the data arrives. */
|
|
85
|
-
const SKELETON_FADE = 220;
|
|
86
|
-
|
|
87
85
|
/** Milliseconds for a point to swell as it is selected, and settle as it is not. */
|
|
88
86
|
const SELECT_DURATION = 140;
|
|
89
87
|
|
|
@@ -206,7 +204,7 @@ export interface ScatterChartProps extends ViewProps, ChartAccessibilityProps<Sc
|
|
|
206
204
|
status?: ScatterChartStatus;
|
|
207
205
|
/** Width ÷ height. `1` suits a scatter plot: neither axis is the important one. */
|
|
208
206
|
aspectRatio?: number;
|
|
209
|
-
/** Milliseconds for the reveal on mount. */
|
|
207
|
+
/** Milliseconds for the reveal on mount. Defaults to `650`. */
|
|
210
208
|
animationDuration?: number;
|
|
211
209
|
/** Milliseconds for the axes to settle after the data changes. */
|
|
212
210
|
domainDuration?: number;
|
|
@@ -240,7 +238,7 @@ const ScatterChartRoot = forwardRef<ScatterChartHandle, ScatterChartProps>(
|
|
|
240
238
|
xDataKey = 'x',
|
|
241
239
|
status = 'ready',
|
|
242
240
|
aspectRatio = 1,
|
|
243
|
-
animationDuration =
|
|
241
|
+
animationDuration = 650,
|
|
244
242
|
domainDuration = 500,
|
|
245
243
|
xDomain,
|
|
246
244
|
yDomain,
|
|
@@ -869,27 +867,8 @@ function ScatterChartSkeleton({ count = 24, color }: ScatterChartSkeletonProps)
|
|
|
869
867
|
const { plot, status } = useChart('ScatterChart.Skeleton');
|
|
870
868
|
const token = useCSSVariable('--color-skeleton');
|
|
871
869
|
const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
872
|
-
const reducedMotion = useReducedMotion();
|
|
873
|
-
|
|
874
|
-
const loading = status === 'loading';
|
|
875
|
-
const fade = useSharedValue(1);
|
|
876
|
-
// Mounted a beat longer than `loading`, so there is something to fade.
|
|
877
|
-
const [mounted, setMounted] = useState(loading);
|
|
878
870
|
|
|
879
|
-
|
|
880
|
-
if (loading) {
|
|
881
|
-
fade.value = 1;
|
|
882
|
-
setMounted(true);
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
if (reducedMotion) {
|
|
886
|
-
setMounted(false);
|
|
887
|
-
return;
|
|
888
|
-
}
|
|
889
|
-
fade.value = withTiming(0, { duration: SKELETON_FADE }, (finished) => {
|
|
890
|
-
if (finished) runOnJS(setMounted)(false);
|
|
891
|
-
});
|
|
892
|
-
}, [loading, reducedMotion, fade]);
|
|
871
|
+
const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');
|
|
893
872
|
|
|
894
873
|
const dots = useMemo(() => {
|
|
895
874
|
// A cheap deterministic scatter: two irrational-ish strides that do not
|