@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.
Files changed (110) hide show
  1. package/README.md +14 -0
  2. package/bin/expo-kit.js +86 -24
  3. package/bin/mcp.mjs +8 -1
  4. package/package.json +8 -3
  5. package/registry/_docs/accordion.json +16 -0
  6. package/registry/_docs/action-sheet.json +28 -0
  7. package/registry/_docs/alert.json +28 -0
  8. package/registry/_docs/avatar.json +24 -0
  9. package/registry/_docs/badge.json +35 -4
  10. package/registry/_docs/bottom-nav.json +24 -0
  11. package/registry/_docs/bottom-sheet.json +28 -0
  12. package/registry/_docs/bullet.json +3 -0
  13. package/registry/_docs/button-3d.json +16 -0
  14. package/registry/_docs/button.json +16 -0
  15. package/registry/_docs/calendar.json +16 -0
  16. package/registry/_docs/card-stack.json +16 -0
  17. package/registry/_docs/card.json +20 -0
  18. package/registry/_docs/carousel.json +20 -0
  19. package/registry/_docs/chat.json +184 -0
  20. package/registry/_docs/checkbox.json +16 -0
  21. package/registry/_docs/checkout.json +3 -0
  22. package/registry/_docs/chip-group.json +16 -0
  23. package/registry/_docs/color-swatch.json +16 -0
  24. package/registry/_docs/confetti.json +20 -0
  25. package/registry/_docs/coupon.json +16 -0
  26. package/registry/_docs/cube-pager.json +16 -0
  27. package/registry/_docs/dashboard.json +3 -0
  28. package/registry/_docs/date-picker.json +20 -0
  29. package/registry/_docs/dialog.json +24 -0
  30. package/registry/_docs/drawer.json +28 -0
  31. package/registry/_docs/fab.json +24 -0
  32. package/registry/_docs/flip-card.json +16 -0
  33. package/registry/_docs/floating-input.json +20 -0
  34. package/registry/_docs/gift-card.json +16 -0
  35. package/registry/_docs/glass-button.json +24 -0
  36. package/registry/_docs/glass-nav-bar.json +24 -0
  37. package/registry/_docs/glass-search-bar.json +24 -0
  38. package/registry/_docs/glass-segmented.json +28 -0
  39. package/registry/_docs/glass-sheet.json +28 -0
  40. package/registry/_docs/glass-tab-bar.json +24 -0
  41. package/registry/_docs/glass.json +16 -0
  42. package/registry/_docs/inbox.json +3 -0
  43. package/registry/_docs/isometric-bar.json +3 -0
  44. package/registry/_docs/kit.json +3 -0
  45. package/registry/_docs/kpi-card.json +3 -0
  46. package/registry/_docs/like-button.json +20 -0
  47. package/registry/_docs/liquidglass-tab.json +3 -0
  48. package/registry/_docs/list-item.json +16 -0
  49. package/registry/_docs/loyalty-card.json +16 -0
  50. package/registry/_docs/morph-button.json +20 -0
  51. package/registry/_docs/number-ticker.json +16 -0
  52. package/registry/_docs/offers.json +3 -0
  53. package/registry/_docs/onboarding.json +3 -0
  54. package/registry/_docs/otp-input.json +24 -0
  55. package/registry/_docs/pagination-dots.json +20 -0
  56. package/registry/_docs/parallax-layers.json +20 -0
  57. package/registry/_docs/parallax-scroll.json +24 -0
  58. package/registry/_docs/password-input.json +20 -0
  59. package/registry/_docs/payment-card.json +16 -0
  60. package/registry/_docs/pixel-bar.json +3 -0
  61. package/registry/_docs/polar-area.json +3 -0
  62. package/registry/_docs/profile.json +3 -0
  63. package/registry/_docs/progress-steps.json +24 -0
  64. package/registry/_docs/progress.json +20 -0
  65. package/registry/_docs/radio-group.json +20 -0
  66. package/registry/_docs/rating.json +24 -0
  67. package/registry/_docs/ring-carousel.json +20 -0
  68. package/registry/_docs/scratch-card.json +20 -0
  69. package/registry/_docs/search-bar.json +20 -0
  70. package/registry/_docs/search-results.json +3 -0
  71. package/registry/_docs/segmented-control.json +20 -0
  72. package/registry/_docs/select.json +26 -1
  73. package/registry/_docs/settings.json +3 -0
  74. package/registry/_docs/shimmer-button.json +20 -0
  75. package/registry/_docs/sign-in.json +3 -0
  76. package/registry/_docs/skeleton.json +20 -0
  77. package/registry/_docs/slide-to-confirm.json +24 -0
  78. package/registry/_docs/slider.json +21 -1
  79. package/registry/_docs/spin-wheel.json +24 -0
  80. package/registry/_docs/spinner.json +41 -11
  81. package/registry/_docs/stateful-button.json +184 -0
  82. package/registry/_docs/stepper.json +16 -0
  83. package/registry/_docs/swipe-deck.json +16 -0
  84. package/registry/_docs/swipeable-row.json +24 -1
  85. package/registry/_docs/switch.json +20 -0
  86. package/registry/_docs/tabs.json +24 -0
  87. package/registry/_docs/tag-input.json +20 -0
  88. package/registry/_docs/text-animation.json +141 -0
  89. package/registry/_docs/textarea.json +16 -0
  90. package/registry/_docs/ticket-card.json +20 -0
  91. package/registry/_docs/ticket-shape.json +3 -0
  92. package/registry/_docs/tilt-card.json +16 -0
  93. package/registry/_docs/time-picker.json +16 -0
  94. package/registry/_docs/timeline-chart.json +3 -0
  95. package/registry/_docs/timeline.json +16 -0
  96. package/registry/_docs/toast.json +24 -0
  97. package/registry/_docs/treemap.json +3 -0
  98. package/registry/_docs/waterfall.json +3 -0
  99. package/registry/_docs/wheel-list.json +20 -0
  100. package/registry/badge/files/src/components/ui/badge.tsx +327 -17
  101. package/registry/badge.json +1 -1
  102. package/registry/chat.json +18 -0
  103. package/registry/spinner/files/src/components/ui/spinner.tsx +435 -22
  104. package/registry/spinner.json +1 -1
  105. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
  106. package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
  107. package/registry/stateful-button.json +18 -0
  108. package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
  109. package/registry/text-animation/files/src/lib/kit.tsx +219 -0
  110. 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
+ });