@expokit/cli 0.2.0 → 0.3.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/README.md +14 -0
- package/bin/expo-kit.js +86 -24
- package/bin/mcp.mjs +8 -1
- package/package.json +8 -3
- package/registry/_docs/accordion.json +16 -0
- package/registry/_docs/action-sheet.json +28 -0
- package/registry/_docs/alert.json +28 -0
- package/registry/_docs/avatar.json +24 -0
- package/registry/_docs/badge.json +35 -4
- package/registry/_docs/bottom-nav.json +24 -0
- package/registry/_docs/bottom-sheet.json +28 -0
- package/registry/_docs/bullet.json +3 -0
- package/registry/_docs/button-3d.json +16 -0
- package/registry/_docs/button.json +16 -0
- package/registry/_docs/calendar.json +16 -0
- package/registry/_docs/card-stack.json +16 -0
- package/registry/_docs/card.json +20 -0
- package/registry/_docs/carousel.json +20 -0
- package/registry/_docs/chat.json +184 -0
- package/registry/_docs/checkbox.json +16 -0
- package/registry/_docs/checkout.json +3 -0
- package/registry/_docs/chip-group.json +16 -0
- package/registry/_docs/color-swatch.json +16 -0
- package/registry/_docs/confetti.json +20 -0
- package/registry/_docs/coupon.json +16 -0
- package/registry/_docs/cube-pager.json +16 -0
- package/registry/_docs/dashboard.json +3 -0
- package/registry/_docs/date-picker.json +20 -0
- package/registry/_docs/dialog.json +24 -0
- package/registry/_docs/drawer.json +28 -0
- package/registry/_docs/fab.json +24 -0
- package/registry/_docs/flip-card.json +16 -0
- package/registry/_docs/floating-input.json +20 -0
- package/registry/_docs/gift-card.json +16 -0
- package/registry/_docs/glass-button.json +24 -0
- package/registry/_docs/glass-nav-bar.json +24 -0
- package/registry/_docs/glass-search-bar.json +24 -0
- package/registry/_docs/glass-segmented.json +28 -0
- package/registry/_docs/glass-sheet.json +28 -0
- package/registry/_docs/glass-tab-bar.json +24 -0
- package/registry/_docs/glass.json +16 -0
- package/registry/_docs/inbox.json +3 -0
- package/registry/_docs/isometric-bar.json +3 -0
- package/registry/_docs/kit.json +3 -0
- package/registry/_docs/kpi-card.json +3 -0
- package/registry/_docs/like-button.json +20 -0
- package/registry/_docs/liquidglass-tab.json +3 -0
- package/registry/_docs/list-item.json +16 -0
- package/registry/_docs/loyalty-card.json +16 -0
- package/registry/_docs/morph-button.json +20 -0
- package/registry/_docs/number-ticker.json +16 -0
- package/registry/_docs/offers.json +3 -0
- package/registry/_docs/onboarding.json +3 -0
- package/registry/_docs/otp-input.json +24 -0
- package/registry/_docs/pagination-dots.json +20 -0
- package/registry/_docs/parallax-layers.json +20 -0
- package/registry/_docs/parallax-scroll.json +24 -0
- package/registry/_docs/password-input.json +20 -0
- package/registry/_docs/payment-card.json +16 -0
- package/registry/_docs/pixel-bar.json +3 -0
- package/registry/_docs/polar-area.json +3 -0
- package/registry/_docs/profile.json +3 -0
- package/registry/_docs/progress-steps.json +24 -0
- package/registry/_docs/progress.json +20 -0
- package/registry/_docs/radio-group.json +20 -0
- package/registry/_docs/rating.json +24 -0
- package/registry/_docs/ring-carousel.json +20 -0
- package/registry/_docs/scratch-card.json +20 -0
- package/registry/_docs/search-bar.json +20 -0
- package/registry/_docs/search-results.json +3 -0
- package/registry/_docs/segmented-control.json +20 -0
- package/registry/_docs/select.json +26 -1
- package/registry/_docs/settings.json +3 -0
- package/registry/_docs/shimmer-button.json +20 -0
- package/registry/_docs/sign-in.json +3 -0
- package/registry/_docs/skeleton.json +20 -0
- package/registry/_docs/slide-to-confirm.json +24 -0
- package/registry/_docs/slider.json +21 -1
- package/registry/_docs/spin-wheel.json +24 -0
- package/registry/_docs/spinner.json +41 -11
- package/registry/_docs/stateful-button.json +184 -0
- package/registry/_docs/stepper.json +16 -0
- package/registry/_docs/swipe-deck.json +16 -0
- package/registry/_docs/swipeable-row.json +24 -1
- package/registry/_docs/switch.json +20 -0
- package/registry/_docs/tabs.json +24 -0
- package/registry/_docs/tag-input.json +20 -0
- package/registry/_docs/text-animation.json +141 -0
- package/registry/_docs/textarea.json +16 -0
- package/registry/_docs/ticket-card.json +20 -0
- package/registry/_docs/ticket-shape.json +3 -0
- package/registry/_docs/tilt-card.json +16 -0
- package/registry/_docs/time-picker.json +16 -0
- package/registry/_docs/timeline-chart.json +3 -0
- package/registry/_docs/timeline.json +16 -0
- package/registry/_docs/toast.json +24 -0
- package/registry/_docs/treemap.json +3 -0
- package/registry/_docs/waterfall.json +3 -0
- package/registry/_docs/wheel-list.json +20 -0
- package/registry/badge/files/src/components/ui/badge.tsx +327 -17
- package/registry/badge.json +1 -1
- package/registry/chat.json +18 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +435 -22
- package/registry/spinner.json +1 -1
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
- package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
- package/registry/stateful-button.json +18 -0
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
- package/registry/text-animation/files/src/lib/kit.tsx +219 -0
- package/registry/text-animation.json +15 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AccessibilityInfo,
|
|
4
|
+
Animated,
|
|
5
|
+
Easing,
|
|
6
|
+
Pressable,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
View,
|
|
9
|
+
type LayoutChangeEvent,
|
|
10
|
+
type StyleProp,
|
|
11
|
+
type TextStyle,
|
|
12
|
+
type ViewStyle,
|
|
13
|
+
} from 'react-native';
|
|
14
|
+
import Svg, { Circle, Path } from 'react-native-svg';
|
|
15
|
+
import * as Haptics from 'expo-haptics';
|
|
16
|
+
|
|
17
|
+
import { alpha, motion, readableOn, useKit } from '@/lib/kit';
|
|
18
|
+
|
|
19
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
20
|
+
const CHECK = 'M6 12.5l4 4L18.5 8';
|
|
21
|
+
const CHECK_LEN = 18;
|
|
22
|
+
const CROSS = 'M7.5 7.5l9 9M16.5 7.5l-9 9';
|
|
23
|
+
const CROSS_LEN = 26;
|
|
24
|
+
|
|
25
|
+
export type StatefulButtonState = 'idle' | 'loading' | 'success' | 'error';
|
|
26
|
+
export type StatefulButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
|
27
|
+
export type StatefulButtonSize = 'sm' | 'md' | 'lg';
|
|
28
|
+
|
|
29
|
+
export interface StatefulButtonProps {
|
|
30
|
+
/** Label while idle. */
|
|
31
|
+
children: string;
|
|
32
|
+
/** Controlled state. Leave it out and return a Promise from `onPress` to let the button drive itself. */
|
|
33
|
+
state?: StatefulButtonState;
|
|
34
|
+
/** If it returns a Promise and `state` is not set: loading → success / error → idle. */
|
|
35
|
+
onPress?: () => void | Promise<unknown>;
|
|
36
|
+
/** Label while loading (default: the idle label). */
|
|
37
|
+
loadingText?: string;
|
|
38
|
+
/** Label after success (default 'Done'). */
|
|
39
|
+
successText?: string;
|
|
40
|
+
/** Label after an error (default 'Failed'). */
|
|
41
|
+
errorText?: string;
|
|
42
|
+
variant?: StatefulButtonVariant;
|
|
43
|
+
size?: StatefulButtonSize;
|
|
44
|
+
/** Tint for the idle look (default: theme primary / foreground). */
|
|
45
|
+
color?: string;
|
|
46
|
+
/** Uncontrolled only: how long success / error shows before returning to idle, in ms. */
|
|
47
|
+
resetAfter?: number;
|
|
48
|
+
/** Uncontrolled only: minimum spinner time, so fast requests still read as "work happened". */
|
|
49
|
+
minLoading?: number;
|
|
50
|
+
/** Called on every state change (both modes). */
|
|
51
|
+
onStateChange?: (state: StatefulButtonState) => void;
|
|
52
|
+
/** Fully rounded ends. */
|
|
53
|
+
pill?: boolean;
|
|
54
|
+
/** Stretch to the parent's width (no width animation). */
|
|
55
|
+
block?: boolean;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
/** Tap on press, success and error buzzes (expo-haptics). Default true. */
|
|
58
|
+
haptics?: boolean;
|
|
59
|
+
accessibilityLabel?: string;
|
|
60
|
+
style?: StyleProp<ViewStyle>;
|
|
61
|
+
textStyle?: StyleProp<TextStyle>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SIZES: Record<StatefulButtonSize, { h: number; px: number; font: number; icon: number; gap: number }> = {
|
|
65
|
+
sm: { h: 36, px: 14, font: 14, icon: 16, gap: 6 },
|
|
66
|
+
md: { h: 46, px: 18, font: 15.5, icon: 18, gap: 8 },
|
|
67
|
+
lg: { h: 54, px: 22, font: 17, icon: 20, gap: 9 },
|
|
68
|
+
};
|
|
69
|
+
const STAGGER = 22;
|
|
70
|
+
const END = 1e6;
|
|
71
|
+
// Connected scripts (Arabic, Indic, Thai…) roll as one piece instead of letter by letter.
|
|
72
|
+
const CONNECTED = /[-ࣿऀ--ༀ-က-႟ក-יִ-﷿ﹰ-]/;
|
|
73
|
+
|
|
74
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
75
|
+
const isThenable = (v: unknown): v is PromiseLike<unknown> =>
|
|
76
|
+
typeof v === 'object' && v !== null && typeof (v as { then?: unknown }).then === 'function';
|
|
77
|
+
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
|
|
78
|
+
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
79
|
+
/** A firm spring (~4% overshoot), sampled so it runs on the native driver. */
|
|
80
|
+
const settle = (t: number) => 1 - Math.exp(-7 * t) * Math.cos(7 * t);
|
|
81
|
+
|
|
82
|
+
/** Maps a ms clock onto `from → to` over [start, start + dur] through `ease` (pre-sampled for the native driver). */
|
|
83
|
+
function track(clock: Animated.Value, start: number, dur: number, from: number, to: number, ease: (t: number) => number, steps = 14) {
|
|
84
|
+
const inputRange: number[] = [];
|
|
85
|
+
const outputRange: number[] = [];
|
|
86
|
+
for (let k = 0; k <= steps; k++) {
|
|
87
|
+
const t = k / steps;
|
|
88
|
+
inputRange.push(start + dur * t);
|
|
89
|
+
outputRange.push(k === steps ? to : from + (to - from) * ease(t));
|
|
90
|
+
}
|
|
91
|
+
return clock.interpolate({ inputRange, outputRange, extrapolate: 'clamp' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function useReduceMotion() {
|
|
95
|
+
const [reduce, setReduce] = useState(false);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
let alive = true;
|
|
98
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
99
|
+
.then((r) => alive && setReduce(r))
|
|
100
|
+
.catch(() => {});
|
|
101
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
102
|
+
return () => {
|
|
103
|
+
alive = false;
|
|
104
|
+
sub.remove();
|
|
105
|
+
};
|
|
106
|
+
}, []);
|
|
107
|
+
return reduce;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const split = (text: string) => (CONNECTED.test(text) ? [text] : Array.from(text));
|
|
111
|
+
|
|
112
|
+
type Anim = { opacity: Animated.AnimatedInterpolation<number>; translateY: Animated.AnimatedInterpolation<number> | number };
|
|
113
|
+
|
|
114
|
+
interface LettersProps {
|
|
115
|
+
text: string;
|
|
116
|
+
style: StyleProp<TextStyle>;
|
|
117
|
+
lh: number;
|
|
118
|
+
space: number;
|
|
119
|
+
anims?: Anim[];
|
|
120
|
+
onLayout?: (e: LayoutChangeEvent) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A row of letters, each clipped to one line so it can roll in and out. */
|
|
124
|
+
function Letters({ text, style, lh, space, anims, onLayout }: LettersProps) {
|
|
125
|
+
return (
|
|
126
|
+
<View style={styles.letters} onLayout={onLayout}>
|
|
127
|
+
{split(text).map((ch, i) =>
|
|
128
|
+
ch === ' ' ? (
|
|
129
|
+
<View key={i} style={{ width: space, height: lh }} />
|
|
130
|
+
) : (
|
|
131
|
+
<View key={i} style={{ height: lh, overflow: 'hidden' }}>
|
|
132
|
+
<Animated.Text
|
|
133
|
+
numberOfLines={1}
|
|
134
|
+
style={[style, anims?.[i] && { opacity: anims[i].opacity, transform: [{ translateY: anims[i].translateY }] }]}
|
|
135
|
+
>
|
|
136
|
+
{ch}
|
|
137
|
+
</Animated.Text>
|
|
138
|
+
</View>
|
|
139
|
+
),
|
|
140
|
+
)}
|
|
141
|
+
</View>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface Roll {
|
|
146
|
+
text: string;
|
|
147
|
+
color: string;
|
|
148
|
+
prev: { text: string; color: string; width: number } | null;
|
|
149
|
+
id: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Label whose letters roll up and out while the next label rolls in from below (crossfade with reduced motion). */
|
|
153
|
+
function RollLabel({ text, color, style, lh, space, reduce }: { text: string; color: string; style: StyleProp<TextStyle>; lh: number; space: number; reduce: boolean }) {
|
|
154
|
+
const width = useRef(0);
|
|
155
|
+
const [roll, setRoll] = useState<Roll>({ text, color, prev: null, id: 0 });
|
|
156
|
+
if (roll.text !== text) setRoll({ text, color, prev: { text: roll.text, color: roll.color, width: width.current }, id: roll.id + 1 });
|
|
157
|
+
else if (roll.color !== color) setRoll({ ...roll, color });
|
|
158
|
+
|
|
159
|
+
const clockRef = useRef<{ id: number; v: Animated.Value } | null>(null);
|
|
160
|
+
if (!clockRef.current || clockRef.current.id !== roll.id) {
|
|
161
|
+
clockRef.current = { id: roll.id, v: new Animated.Value(roll.id === 0 ? END : 0) };
|
|
162
|
+
}
|
|
163
|
+
const clock = clockRef.current.v;
|
|
164
|
+
|
|
165
|
+
const live = useRef({ next: 0, prev: 0, reduce });
|
|
166
|
+
live.current = { next: split(roll.text).length, prev: roll.prev ? split(roll.prev.text).length : 0, reduce };
|
|
167
|
+
const id = roll.id;
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (id === 0) return;
|
|
170
|
+
const { next, prev, reduce: still } = live.current;
|
|
171
|
+
const total = still ? 280 : Math.max(next, prev) * STAGGER + 60 + 520;
|
|
172
|
+
const anim = Animated.timing(clock, { toValue: total, duration: total, easing: Easing.linear, useNativeDriver: true });
|
|
173
|
+
anim.start(({ finished }) => {
|
|
174
|
+
if (!finished) return;
|
|
175
|
+
clock.setValue(END);
|
|
176
|
+
setRoll((r) => (r.id === id ? { ...r, prev: null } : r));
|
|
177
|
+
});
|
|
178
|
+
return () => anim.stop();
|
|
179
|
+
}, [id, clock]);
|
|
180
|
+
|
|
181
|
+
const n = live.current.next;
|
|
182
|
+
const m = live.current.prev;
|
|
183
|
+
const enter = useMemo<Anim[]>(
|
|
184
|
+
() =>
|
|
185
|
+
Array.from({ length: n }, (_, i) =>
|
|
186
|
+
reduce
|
|
187
|
+
? { opacity: track(clock, 60, 220, 0, 1, easeOut), translateY: 0 }
|
|
188
|
+
: { opacity: track(clock, i * STAGGER + 60, 200, 0, 1, easeOut), translateY: track(clock, i * STAGGER + 60, 520, lh, 0, settle, 18) },
|
|
189
|
+
),
|
|
190
|
+
[n, clock, lh, reduce],
|
|
191
|
+
);
|
|
192
|
+
const exitStagger = Math.min(STAGGER, 120 / Math.max(m, 1));
|
|
193
|
+
const exit = useMemo<Anim[]>(
|
|
194
|
+
() =>
|
|
195
|
+
Array.from({ length: m }, (_, i) =>
|
|
196
|
+
reduce
|
|
197
|
+
? { opacity: track(clock, 0, 180, 1, 0, easeOut), translateY: 0 }
|
|
198
|
+
: // The button narrows as soon as the label changes, so the old label clears out fast (≤ 120ms of
|
|
199
|
+
// stagger whatever its length) instead of trailing letters past the shrinking edge.
|
|
200
|
+
{ opacity: track(clock, i * exitStagger, 110, 1, 0, easeOut), translateY: track(clock, i * exitStagger, 300, 0, -lh, easeInOut) },
|
|
201
|
+
),
|
|
202
|
+
[m, clock, lh, reduce, exitStagger],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const pw = roll.prev?.width ?? 0;
|
|
206
|
+
return (
|
|
207
|
+
<View>
|
|
208
|
+
<Letters
|
|
209
|
+
text={roll.text}
|
|
210
|
+
style={[style, { color: roll.color }]}
|
|
211
|
+
lh={lh}
|
|
212
|
+
space={space}
|
|
213
|
+
anims={enter}
|
|
214
|
+
onLayout={(e) => (width.current = e.nativeEvent.layout.width)}
|
|
215
|
+
/>
|
|
216
|
+
{roll.prev ? (
|
|
217
|
+
<View pointerEvents="none" style={[styles.prev, pw > 0 ? { left: '50%', marginLeft: -pw / 2, width: pw } : { left: 0 }]}>
|
|
218
|
+
<Letters text={roll.prev.text} style={[style, { color: roll.prev.color }]} lh={lh} space={space} anims={exit} />
|
|
219
|
+
</View>
|
|
220
|
+
) : null}
|
|
221
|
+
</View>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Button that walks through idle → loading → success / error. The label rolls letter by letter to
|
|
227
|
+
* the next text, the width springs to fit it, and a spinner, self-drawing check or shaking cross
|
|
228
|
+
* grows in beside it. Controlled with `state`, or self-driving when `onPress` returns a Promise.
|
|
229
|
+
*/
|
|
230
|
+
export function StatefulButton({
|
|
231
|
+
children,
|
|
232
|
+
state,
|
|
233
|
+
onPress,
|
|
234
|
+
loadingText,
|
|
235
|
+
successText = 'Done',
|
|
236
|
+
errorText = 'Failed',
|
|
237
|
+
variant = 'primary',
|
|
238
|
+
size = 'md',
|
|
239
|
+
color,
|
|
240
|
+
resetAfter = 1600,
|
|
241
|
+
minLoading = 500,
|
|
242
|
+
onStateChange,
|
|
243
|
+
pill,
|
|
244
|
+
block,
|
|
245
|
+
disabled,
|
|
246
|
+
haptics = true,
|
|
247
|
+
accessibilityLabel,
|
|
248
|
+
style,
|
|
249
|
+
textStyle,
|
|
250
|
+
}: StatefulButtonProps) {
|
|
251
|
+
const { colors, radius, dark, font } = useKit();
|
|
252
|
+
const reduce = useReduceMotion();
|
|
253
|
+
const s = SIZES[size];
|
|
254
|
+
const fontSize = StyleSheet.flatten(textStyle)?.fontSize ?? s.font;
|
|
255
|
+
const lh = Math.round(fontSize * 1.3);
|
|
256
|
+
const space = fontSize * 0.28;
|
|
257
|
+
|
|
258
|
+
const [inner, setInner] = useState<StatefulButtonState>('idle');
|
|
259
|
+
const current = state ?? inner;
|
|
260
|
+
const busy = useRef(false);
|
|
261
|
+
const mounted = useRef(true);
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
mounted.current = true;
|
|
264
|
+
return () => {
|
|
265
|
+
mounted.current = false;
|
|
266
|
+
};
|
|
267
|
+
}, []);
|
|
268
|
+
|
|
269
|
+
const labels: Record<StatefulButtonState, string> = {
|
|
270
|
+
idle: children,
|
|
271
|
+
loading: loadingText ?? children,
|
|
272
|
+
success: successText,
|
|
273
|
+
error: errorText,
|
|
274
|
+
};
|
|
275
|
+
const label = labels[current];
|
|
276
|
+
|
|
277
|
+
// Looks: idle (also used while loading), success and error. Transparent fills keep their hue so fades stay clean.
|
|
278
|
+
const ok = colors.success;
|
|
279
|
+
const bad = colors.danger;
|
|
280
|
+
const soft = dark ? 0.2 : 0.14;
|
|
281
|
+
const looks: Record<StatefulButtonVariant, [{ bg: string; fg: string; border: string }, { bg: string; fg: string; border: string }, { bg: string; fg: string; border: string }]> = {
|
|
282
|
+
primary: [
|
|
283
|
+
{ bg: color ?? colors.primary, fg: color ? readableOn(color) : colors.primaryForeground, border: alpha(color ?? colors.primary, 0) },
|
|
284
|
+
{ bg: ok, fg: readableOn(ok), border: alpha(ok, 0) },
|
|
285
|
+
{ bg: bad, fg: colors.dangerForeground, border: alpha(bad, 0) },
|
|
286
|
+
],
|
|
287
|
+
secondary: [
|
|
288
|
+
{ bg: color ? alpha(color, soft) : colors.secondary, fg: color ?? colors.secondaryForeground, border: alpha(colors.secondary, 0) },
|
|
289
|
+
{ bg: alpha(ok, soft), fg: ok, border: alpha(ok, 0) },
|
|
290
|
+
{ bg: alpha(bad, soft), fg: bad, border: alpha(bad, 0) },
|
|
291
|
+
],
|
|
292
|
+
outline: [
|
|
293
|
+
{ bg: alpha(color ?? colors.foreground, 0), fg: color ?? colors.foreground, border: color ? alpha(color, 0.5) : colors.border },
|
|
294
|
+
{ bg: alpha(ok, 0), fg: ok, border: alpha(ok, 0.6) },
|
|
295
|
+
{ bg: alpha(bad, 0), fg: bad, border: alpha(bad, 0.6) },
|
|
296
|
+
],
|
|
297
|
+
ghost: [
|
|
298
|
+
{ bg: alpha(color ?? colors.foreground, 0), fg: color ?? colors.foreground, border: alpha(color ?? colors.foreground, 0) },
|
|
299
|
+
{ bg: alpha(ok, 0), fg: ok, border: alpha(ok, 0) },
|
|
300
|
+
{ bg: alpha(bad, 0), fg: bad, border: alpha(bad, 0) },
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
const look = looks[variant];
|
|
304
|
+
const toneIndex = current === 'success' ? 1 : current === 'error' ? 2 : 0;
|
|
305
|
+
const fg = look[toneIndex].fg;
|
|
306
|
+
const bw = variant === 'outline' ? StyleSheet.hairlineWidth * 2 : 0;
|
|
307
|
+
|
|
308
|
+
// JS driver (layout + colour + SVG) — on the surface, the icon slot and the stroke.
|
|
309
|
+
const tone = useRef(new Animated.Value(toneIndex)).current;
|
|
310
|
+
const slot = useRef(new Animated.Value(current === 'idle' ? 0 : s.icon + s.gap)).current;
|
|
311
|
+
const draw = useRef(new Animated.Value(1)).current;
|
|
312
|
+
// Native driver — transforms and opacity on their own wrappers.
|
|
313
|
+
const press = useRef(new Animated.Value(1)).current;
|
|
314
|
+
const shake = useRef(new Animated.Value(0)).current;
|
|
315
|
+
const pop = useRef(new Animated.Value(current === 'idle' ? 0 : 1)).current;
|
|
316
|
+
const spin = useRef(new Animated.Value(0)).current;
|
|
317
|
+
const spinLoop = useRef<Animated.CompositeAnimation | null>(null);
|
|
318
|
+
|
|
319
|
+
// The icon keeps showing the last busy/result state while it shrinks away on the way back to idle.
|
|
320
|
+
const lastIcon = useRef<StatefulButtonState>(current === 'idle' ? 'loading' : current);
|
|
321
|
+
if (current !== 'idle') lastIcon.current = current;
|
|
322
|
+
const icon = lastIcon.current;
|
|
323
|
+
|
|
324
|
+
// Width: measured from hidden copies of every label, sprung on the JS driver.
|
|
325
|
+
const [widths, setWidths] = useState<Record<string, number>>({});
|
|
326
|
+
const measured = widths[label];
|
|
327
|
+
const target = measured === undefined ? undefined : Math.max(s.h, Math.ceil(measured) + s.px * 2 + bw * 2 + (current === 'idle' ? 0 : s.icon + s.gap));
|
|
328
|
+
const widthRef = useRef<Animated.Value | null>(null);
|
|
329
|
+
if (!widthRef.current && target !== undefined) widthRef.current = new Animated.Value(target);
|
|
330
|
+
const width = widthRef.current;
|
|
331
|
+
|
|
332
|
+
const live = useRef({ reduce, haptics, onStateChange, label, slotTo: 0 });
|
|
333
|
+
live.current = { reduce, haptics, onStateChange, label, slotTo: current === 'idle' ? 0 : s.icon + s.gap };
|
|
334
|
+
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
if (!width || target === undefined) return;
|
|
337
|
+
if (live.current.reduce) width.setValue(target);
|
|
338
|
+
else Animated.spring(width, { toValue: target, useNativeDriver: false, ...motion.snappy }).start();
|
|
339
|
+
}, [width, target]);
|
|
340
|
+
|
|
341
|
+
// Everything that happens on a state change.
|
|
342
|
+
const seen = useRef(current);
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (seen.current === current) return;
|
|
345
|
+
seen.current = current;
|
|
346
|
+
const { reduce: still, haptics: buzz, onStateChange: notify, slotTo } = live.current;
|
|
347
|
+
notify?.(current);
|
|
348
|
+
|
|
349
|
+
if (still) slot.setValue(slotTo);
|
|
350
|
+
else Animated.spring(slot, { toValue: slotTo, useNativeDriver: false, ...motion.snappy }).start();
|
|
351
|
+
Animated.timing(tone, { toValue: current === 'success' ? 1 : current === 'error' ? 2 : 0, duration: 220, useNativeDriver: false }).start();
|
|
352
|
+
|
|
353
|
+
if (current === 'idle') {
|
|
354
|
+
Animated.timing(pop, { toValue: 0, duration: 140, useNativeDriver: true }).start();
|
|
355
|
+
} else {
|
|
356
|
+
pop.setValue(still ? 1 : 0.2);
|
|
357
|
+
if (!still) Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
spinLoop.current?.stop();
|
|
361
|
+
spinLoop.current = null;
|
|
362
|
+
if (current === 'loading') {
|
|
363
|
+
spin.setValue(0);
|
|
364
|
+
spinLoop.current = Animated.loop(Animated.timing(spin, { toValue: 1, duration: 800, easing: Easing.linear, useNativeDriver: true }));
|
|
365
|
+
spinLoop.current.start();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (current === 'success' || current === 'error') {
|
|
369
|
+
draw.setValue(still ? 1 : 0);
|
|
370
|
+
if (!still) Animated.timing(draw, { toValue: 1, duration: 380, delay: 90, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
|
|
371
|
+
if (buzz)
|
|
372
|
+
Haptics.notificationAsync(
|
|
373
|
+
current === 'success' ? Haptics.NotificationFeedbackType.Success : Haptics.NotificationFeedbackType.Error
|
|
374
|
+
).catch(() => {});
|
|
375
|
+
}
|
|
376
|
+
if (current === 'error' && !still) {
|
|
377
|
+
shake.setValue(0);
|
|
378
|
+
Animated.sequence([10, -8, 6, -4, 2, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 50, useNativeDriver: true }))).start();
|
|
379
|
+
}
|
|
380
|
+
}, [current, slot, tone, pop, spin, draw, shake]);
|
|
381
|
+
|
|
382
|
+
useEffect(() => () => spinLoop.current?.stop(), []);
|
|
383
|
+
|
|
384
|
+
// Screen readers hear every new label (Saving…, Saved, Following…), not just the first.
|
|
385
|
+
const heard = useRef(label);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (heard.current === label) return;
|
|
388
|
+
heard.current = label;
|
|
389
|
+
AccessibilityInfo.announceForAccessibility(label);
|
|
390
|
+
}, [label]);
|
|
391
|
+
|
|
392
|
+
const isDisabled = !!disabled || current === 'loading';
|
|
393
|
+
|
|
394
|
+
const run = () => {
|
|
395
|
+
if (isDisabled || busy.current) return;
|
|
396
|
+
if (haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
|
397
|
+
const result: unknown = onPress?.();
|
|
398
|
+
if (state !== undefined || !isThenable(result)) return;
|
|
399
|
+
busy.current = true;
|
|
400
|
+
setInner('loading');
|
|
401
|
+
const started = Date.now();
|
|
402
|
+
Promise.resolve(result)
|
|
403
|
+
.then(
|
|
404
|
+
() => false,
|
|
405
|
+
() => true,
|
|
406
|
+
)
|
|
407
|
+
.then(async (failed) => {
|
|
408
|
+
const remaining = minLoading - (Date.now() - started);
|
|
409
|
+
if (remaining > 0) await wait(remaining);
|
|
410
|
+
if (!mounted.current) return;
|
|
411
|
+
setInner(failed ? 'error' : 'success');
|
|
412
|
+
await wait(resetAfter);
|
|
413
|
+
if (!mounted.current) return;
|
|
414
|
+
setInner('idle');
|
|
415
|
+
busy.current = false;
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const onMeasure = (text: string) => (e: LayoutChangeEvent) => {
|
|
420
|
+
const w = e.nativeEvent.layout.width;
|
|
421
|
+
setWidths((prev) => (prev[text] === w ? prev : { ...prev, [text]: w }));
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const textBase: StyleProp<TextStyle> = [styles.label, { fontSize: s.font, fontFamily: font.family }, textStyle, { lineHeight: lh }];
|
|
425
|
+
// Press feedback: a light wash on dark fills, a faint shade on light or transparent ones.
|
|
426
|
+
const wash = variant === 'primary' && readableOn(look[0].bg) === '#ffffff' ? alpha('#ffffff', 0.14) : alpha(colors.foreground, 0.07);
|
|
427
|
+
const corner = pill ? radius.capsule(s.h) : radius.md;
|
|
428
|
+
const hit = Math.max(0, (44 - s.h) / 2);
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<Animated.View style={[{ transform: [{ translateX: shake }, { scale: press }] }, block ? styles.block : styles.inline, style]}>
|
|
432
|
+
<Pressable
|
|
433
|
+
accessibilityRole="button"
|
|
434
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
435
|
+
accessibilityState={{ disabled: isDisabled, busy: current === 'loading' }}
|
|
436
|
+
disabled={isDisabled}
|
|
437
|
+
hitSlop={hit ? { top: hit, bottom: hit } : undefined}
|
|
438
|
+
onPress={run}
|
|
439
|
+
onPressIn={() => Animated.spring(press, { toValue: 0.96, useNativeDriver: true, ...motion.bouncy }).start()}
|
|
440
|
+
onPressOut={() => Animated.spring(press, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start()}
|
|
441
|
+
>
|
|
442
|
+
{({ pressed }) => (
|
|
443
|
+
<Animated.View
|
|
444
|
+
style={[
|
|
445
|
+
styles.surface,
|
|
446
|
+
{
|
|
447
|
+
height: s.h,
|
|
448
|
+
minWidth: s.h,
|
|
449
|
+
paddingHorizontal: s.px,
|
|
450
|
+
borderRadius: corner,
|
|
451
|
+
borderWidth: bw,
|
|
452
|
+
borderColor: tone.interpolate({ inputRange: [0, 1, 2], outputRange: [look[0].border, look[1].border, look[2].border] }),
|
|
453
|
+
backgroundColor: tone.interpolate({ inputRange: [0, 1, 2], outputRange: [look[0].bg, look[1].bg, look[2].bg] }),
|
|
454
|
+
opacity: disabled ? 0.5 : 1,
|
|
455
|
+
},
|
|
456
|
+
!block && width ? { width } : null,
|
|
457
|
+
]}
|
|
458
|
+
>
|
|
459
|
+
{pressed ? <View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: wash }]} /> : null}
|
|
460
|
+
<Animated.View style={[styles.slot, { width: slot, height: s.icon }]}>
|
|
461
|
+
<Animated.View
|
|
462
|
+
style={[
|
|
463
|
+
styles.icon,
|
|
464
|
+
{
|
|
465
|
+
width: s.icon,
|
|
466
|
+
height: s.icon,
|
|
467
|
+
opacity: pop.interpolate({ inputRange: [0, 0.6], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
468
|
+
transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) }],
|
|
469
|
+
},
|
|
470
|
+
]}
|
|
471
|
+
>
|
|
472
|
+
{icon === 'loading' ? (
|
|
473
|
+
<Animated.View style={{ transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }] }}>
|
|
474
|
+
<Svg width={s.icon} height={s.icon} viewBox="0 0 24 24">
|
|
475
|
+
<Circle cx={12} cy={12} r={9} stroke={alpha(fg, 0.25)} strokeWidth={2.75} fill="none" />
|
|
476
|
+
<Circle cx={12} cy={12} r={9} stroke={fg} strokeWidth={2.75} strokeLinecap="round" strokeDasharray="15 60" fill="none" />
|
|
477
|
+
</Svg>
|
|
478
|
+
</Animated.View>
|
|
479
|
+
) : (
|
|
480
|
+
<Svg width={s.icon} height={s.icon} viewBox="0 0 24 24">
|
|
481
|
+
<AnimatedPath
|
|
482
|
+
d={icon === 'error' ? CROSS : CHECK}
|
|
483
|
+
stroke={fg}
|
|
484
|
+
strokeWidth={3}
|
|
485
|
+
strokeLinecap="round"
|
|
486
|
+
strokeLinejoin="round"
|
|
487
|
+
fill="none"
|
|
488
|
+
strokeDasharray={icon === 'error' ? CROSS_LEN : CHECK_LEN}
|
|
489
|
+
strokeDashoffset={
|
|
490
|
+
draw.interpolate({
|
|
491
|
+
inputRange: [0, 1],
|
|
492
|
+
outputRange: [icon === 'error' ? CROSS_LEN : CHECK_LEN, 0],
|
|
493
|
+
extrapolate: 'clamp',
|
|
494
|
+
}) as unknown as number
|
|
495
|
+
}
|
|
496
|
+
/>
|
|
497
|
+
</Svg>
|
|
498
|
+
)}
|
|
499
|
+
</Animated.View>
|
|
500
|
+
</Animated.View>
|
|
501
|
+
<RollLabel text={label} color={fg} style={textBase} lh={lh} space={space} reduce={reduce} />
|
|
502
|
+
</Animated.View>
|
|
503
|
+
)}
|
|
504
|
+
</Pressable>
|
|
505
|
+
|
|
506
|
+
{/* Hidden copies of each label, laid out exactly like the visible one, to know every width up front. */}
|
|
507
|
+
<View pointerEvents="none" style={styles.measure} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
|
|
508
|
+
<View style={styles.measureRow}>
|
|
509
|
+
{Array.from(new Set(Object.values(labels))).map((text) => (
|
|
510
|
+
<View key={text} style={styles.measureItem}>
|
|
511
|
+
<Letters text={text} style={textBase} lh={lh} space={space} onLayout={onMeasure(text)} />
|
|
512
|
+
</View>
|
|
513
|
+
))}
|
|
514
|
+
</View>
|
|
515
|
+
</View>
|
|
516
|
+
</Animated.View>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const styles = StyleSheet.create({
|
|
521
|
+
inline: { alignSelf: 'flex-start' },
|
|
522
|
+
block: { alignSelf: 'stretch' },
|
|
523
|
+
surface: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
|
524
|
+
slot: { justifyContent: 'center' },
|
|
525
|
+
icon: { position: 'absolute', left: 0 },
|
|
526
|
+
letters: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
527
|
+
label: { fontWeight: '400', letterSpacing: -0.1, includeFontPadding: false },
|
|
528
|
+
prev: { position: 'absolute', top: 0 },
|
|
529
|
+
measure: { position: 'absolute', top: 0, left: 0, width: 0, height: 0, overflow: 'hidden', opacity: 0 },
|
|
530
|
+
measureRow: { width: 4000, flexDirection: 'column', alignItems: 'flex-start' },
|
|
531
|
+
measureItem: { flexDirection: 'row' },
|
|
532
|
+
});
|