@expokit/cli 0.2.0 → 0.4.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 +20 -2
- package/bin/expo-kit.js +219 -44
- package/bin/lib.mjs +110 -1
- package/bin/mcp.mjs +48 -19
- package/package.json +9 -4
- 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/assistant-app.json +47 -0
- package/registry/_docs/avatar.json +25 -1
- package/registry/_docs/badge.json +36 -5
- package/registry/_docs/bottom-nav.json +30 -6
- package/registry/_docs/bottom-sheet.json +28 -0
- package/registry/_docs/bullet.json +3 -0
- package/registry/_docs/button-3d.json +18 -2
- package/registry/_docs/button.json +16 -0
- package/registry/_docs/calendar.json +21 -5
- 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 +10 -1
- package/registry/_docs/chip-group.json +16 -0
- package/registry/_docs/confetti.json +23 -3
- 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 +24 -4
- package/registry/_docs/dialog.json +24 -0
- package/registry/_docs/donut-shop.json +88 -0
- package/registry/_docs/drawer.json +32 -4
- package/registry/_docs/fab.json +24 -0
- package/registry/_docs/fintech-app.json +49 -0
- package/registry/_docs/flip-card.json +16 -0
- package/registry/_docs/floating-input.json +25 -5
- package/registry/_docs/food-app.json +47 -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 +17 -1
- package/registry/_docs/grain.json +80 -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/liquidglass-tab.json +3 -0
- package/registry/_docs/list-item.json +16 -0
- package/registry/_docs/loyalty-card.json +16 -0
- package/registry/_docs/morphing-button-group.json +96 -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/orb-reveal.json +132 -0
- package/registry/_docs/otp-input.json +28 -4
- package/registry/_docs/parallax-layers.json +20 -0
- package/registry/_docs/parallax-scroll.json +24 -0
- package/registry/_docs/password-input.json +23 -3
- 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/qr-code-button.json +122 -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 +23 -3
- package/registry/_docs/search-results.json +3 -0
- package/registry/_docs/segmented-control.json +20 -0
- package/registry/_docs/select.json +33 -8
- package/registry/_docs/settings.json +9 -0
- package/registry/_docs/sign-in.json +3 -0
- package/registry/_docs/skeleton.json +22 -2
- package/registry/_docs/slide-to-confirm.json +24 -0
- package/registry/_docs/slider.json +27 -7
- 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 +23 -7
- 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/text-animation.json +141 -0
- package/registry/_docs/textarea.json +18 -2
- package/registry/_docs/ticket-card.json +20 -0
- package/registry/_docs/ticket-shape.json +4 -1
- package/registry/_docs/tilt-card.json +20 -4
- package/registry/_docs/time-picker.json +22 -6
- package/registry/_docs/timeline-chart.json +3 -0
- package/registry/_docs/timeline.json +18 -2
- package/registry/_docs/toast.json +32 -8
- package/registry/_docs/treemap.json +3 -0
- package/registry/_docs/waterfall.json +3 -0
- package/registry/_docs/wheel-list.json +20 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +5 -4
- package/registry/accordion/files/src/lib/kit.tsx +8 -5
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +9 -8
- package/registry/action-sheet/files/src/lib/kit.tsx +8 -5
- package/registry/alert/files/src/components/ui/alert.tsx +7 -4
- package/registry/alert/files/src/components/ui/button.tsx +4 -3
- package/registry/alert/files/src/lib/kit.tsx +8 -5
- package/registry/assistant-app.json +49 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +7 -4
- package/registry/avatar/files/src/lib/kit.tsx +8 -5
- package/registry/badge/files/src/components/ui/badge.tsx +345 -21
- package/registry/badge/files/src/lib/kit.tsx +8 -5
- package/registry/badge.json +1 -1
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +14 -7
- package/registry/bottom-nav/files/src/lib/kit.tsx +8 -5
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -6
- package/registry/bottom-sheet/files/src/lib/kit.tsx +8 -5
- package/registry/button/files/src/components/ui/button.tsx +4 -3
- package/registry/button/files/src/lib/kit.tsx +8 -5
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +3 -2
- package/registry/button-3d/files/src/lib/kit.tsx +8 -5
- package/registry/calendar/files/src/components/ui/calendar.tsx +9 -5
- package/registry/calendar/files/src/lib/kit.tsx +8 -5
- package/registry/card/files/src/components/ui/card.tsx +8 -6
- package/registry/card/files/src/lib/kit.tsx +8 -5
- package/registry/chat.json +18 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +6 -5
- package/registry/checkbox/files/src/lib/kit.tsx +8 -5
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +4 -2
- package/registry/chip-group/files/src/lib/kit.tsx +8 -5
- package/registry/confetti/files/src/lib/kit.tsx +8 -5
- package/registry/coupon/files/src/components/ui/coupon.tsx +23 -20
- package/registry/coupon/files/src/components/ui/flip-card.tsx +1 -1
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +2 -2
- package/registry/coupon/files/src/lib/kit.tsx +8 -5
- package/registry/date-picker/files/src/components/ui/calendar.tsx +9 -5
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +2 -2
- package/registry/date-picker/files/src/lib/kit.tsx +8 -5
- package/registry/dialog/files/src/components/ui/dialog.tsx +12 -6
- package/registry/dialog/files/src/lib/kit.tsx +8 -5
- package/registry/donut-shop.json +18 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +25 -19
- package/registry/drawer/files/src/lib/kit.tsx +8 -5
- package/registry/fintech-app.json +57 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +1 -1
- package/registry/flip-card/files/src/lib/kit.tsx +8 -5
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +16 -6
- package/registry/floating-input/files/src/lib/kit.tsx +8 -5
- package/registry/food-app.json +55 -0
- package/registry/grain/files/src/components/ui/grain.tsx +113 -0
- package/registry/{tag-input → grain}/files/src/lib/kit.tsx +8 -5
- package/registry/grain.json +17 -0
- package/registry/kit/files/src/lib/kit.tsx +8 -5
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -1
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +1 -1
- package/registry/list-item/files/src/components/ui/list-item.tsx +20 -16
- package/registry/list-item/files/src/lib/kit.tsx +8 -5
- package/registry/morphing-button-group.json +18 -0
- package/registry/orb-reveal.json +20 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +8 -6
- package/registry/otp-input/files/src/lib/kit.tsx +8 -5
- package/registry/password-input/files/src/components/ui/floating-input.tsx +16 -6
- package/registry/password-input/files/src/components/ui/password-input.tsx +2 -2
- package/registry/password-input/files/src/lib/kit.tsx +8 -5
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +1 -1
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +27 -14
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +2 -2
- package/registry/payment-card/files/src/lib/kit.tsx +8 -5
- package/registry/progress/files/src/components/ui/progress.tsx +8 -5
- package/registry/progress/files/src/lib/kit.tsx +8 -5
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +15 -11
- package/registry/progress-steps/files/src/lib/kit.tsx +8 -5
- package/registry/qr-code-button.json +18 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +10 -9
- package/registry/radio-group/files/src/lib/kit.tsx +8 -5
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +13 -11
- package/registry/search-bar/files/src/lib/kit.tsx +8 -5
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +4 -2
- package/registry/segmented-control/files/src/lib/kit.tsx +8 -5
- package/registry/select/files/src/components/ui/select.tsx +3 -3
- package/registry/select/files/src/lib/kit.tsx +8 -5
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +4 -4
- package/registry/skeleton/files/src/lib/kit.tsx +8 -5
- package/registry/slider/files/src/components/ui/slider.tsx +5 -4
- package/registry/slider/files/src/lib/kit.tsx +8 -5
- package/registry/spinner/files/src/components/ui/spinner.tsx +452 -34
- package/registry/spinner/files/src/lib/kit.tsx +8 -5
- package/registry/spinner.json +1 -1
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
- package/registry/{pagination-dots → stateful-button}/files/src/lib/kit.tsx +8 -5
- package/registry/stateful-button.json +18 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +8 -4
- package/registry/stepper/files/src/lib/kit.tsx +8 -5
- package/registry/switch/files/src/components/ui/switch.tsx +4 -3
- package/registry/switch/files/src/lib/kit.tsx +8 -5
- package/registry/tabs/files/src/components/ui/tabs.tsx +10 -8
- package/registry/tabs/files/src/lib/kit.tsx +8 -5
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
- package/registry/text-animation/files/src/lib/kit.tsx +222 -0
- package/registry/text-animation.json +15 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +11 -9
- package/registry/textarea/files/src/lib/kit.tsx +8 -5
- package/registry/ticket-shape/files/src/lib/kit.tsx +8 -5
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +2 -2
- package/registry/tilt-card/files/src/lib/kit.tsx +8 -5
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +2 -2
- package/registry/time-picker/files/src/lib/kit.tsx +8 -5
- package/registry/timeline/files/src/components/ui/avatar.tsx +7 -4
- package/registry/timeline/files/src/components/ui/timeline.tsx +12 -10
- package/registry/timeline/files/src/lib/kit.tsx +8 -5
- package/registry/toast/files/src/components/ui/button.tsx +4 -3
- package/registry/toast/files/src/components/ui/toast.tsx +13 -11
- package/registry/toast/files/src/lib/kit.tsx +8 -5
- package/schema/components.schema.json +1 -1
- package/registry/_docs/color-swatch.json +0 -66
- package/registry/_docs/kpi-card.json +0 -11
- package/registry/_docs/like-button.json +0 -72
- package/registry/_docs/morph-button.json +0 -108
- package/registry/_docs/pagination-dots.json +0 -88
- package/registry/_docs/shimmer-button.json +0 -78
- package/registry/_docs/tag-input.json +0 -128
- package/registry/color-swatch.json +0 -17
- package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +0 -45
- package/registry/kpi-card.json +0 -14
- package/registry/like-button.json +0 -18
- package/registry/morph-button.json +0 -18
- package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +0 -262
- package/registry/pagination-dots.json +0 -15
- package/registry/shimmer-button.json +0 -17
- package/registry/tag-input/files/src/components/ui/tag-input.tsx +0 -720
- package/registry/tag-input.json +0 -17
|
@@ -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
|
+
});
|
|
@@ -181,14 +181,17 @@ export function useKit(): Theme {
|
|
|
181
181
|
return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Shared motion presets so the whole kit moves the same way. Every spring is critically damped
|
|
186
|
+
* (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
|
|
187
|
+
*/
|
|
185
188
|
export const motion = {
|
|
186
189
|
/** Toggles, selection indicators. */
|
|
187
|
-
snappy: { damping:
|
|
188
|
-
/** Press feedback, pops. */
|
|
189
|
-
bouncy: { damping:
|
|
190
|
+
snappy: { damping: 30, stiffness: 260, mass: 0.8 },
|
|
191
|
+
/** Press feedback, pops. (Kept under its old name; it no longer bounces.) */
|
|
192
|
+
bouncy: { damping: 26, stiffness: 220, mass: 0.7 },
|
|
190
193
|
/** Sheets, layout changes. */
|
|
191
|
-
gentle: { damping:
|
|
194
|
+
gentle: { damping: 27, stiffness: 160, mass: 1 },
|
|
192
195
|
fast: 160,
|
|
193
196
|
normal: 240,
|
|
194
197
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stateful-button",
|
|
3
|
+
"type": "component",
|
|
4
|
+
"title": "Stateful Button",
|
|
5
|
+
"description": "A button that walks through idle, loading, success and error: the label rolls letter by letter to the next text while the width springs to fit it. A spinner, a self-drawing check or a shaking cross grows in beside the label, and it drives itself when onPress returns a Promise.",
|
|
6
|
+
"tier": "free",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"react-native-svg",
|
|
9
|
+
"expo-haptics"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"kit"
|
|
13
|
+
],
|
|
14
|
+
"sharedFiles": [
|
|
15
|
+
"lib/kit.tsx"
|
|
16
|
+
],
|
|
17
|
+
"filesDir": "stateful-button/files"
|
|
18
|
+
}
|
|
@@ -118,7 +118,8 @@ export function Stepper({
|
|
|
118
118
|
buttonStyle,
|
|
119
119
|
valueStyle,
|
|
120
120
|
}: StepperProps) {
|
|
121
|
-
const { colors, radius } = useKit();
|
|
121
|
+
const { colors, radius, font } = useKit();
|
|
122
|
+
const ff = font.family ? { fontFamily: font.family } : null;
|
|
122
123
|
const variant: StepperVariant = variantProp in LEGACY ? LEGACY[variantProp as StepperLegacyVariant] : (variantProp as StepperVariant);
|
|
123
124
|
const vertical = variant !== 'cart' && (orientation ? orientation === 'vertical' : variantProp === 'vertical');
|
|
124
125
|
const tint = accent ?? colors.accent;
|
|
@@ -199,6 +200,7 @@ export function Stepper({
|
|
|
199
200
|
fontSize,
|
|
200
201
|
lineHeight: Math.round(fontSize * 1.3),
|
|
201
202
|
color: numColor,
|
|
203
|
+
...ff,
|
|
202
204
|
...flatValue,
|
|
203
205
|
};
|
|
204
206
|
|
|
@@ -312,8 +314,8 @@ export function Stepper({
|
|
|
312
314
|
return (
|
|
313
315
|
<View style={[styles.labelled, style]}>
|
|
314
316
|
<View style={styles.texts}>
|
|
315
|
-
{title ? <Text style={[styles.label, { color: colors.foreground }]}>{title}</Text> : null}
|
|
316
|
-
{description ? <Text style={[styles.description, { color: colors.mutedForeground }]}>{description}</Text> : null}
|
|
317
|
+
{title ? <Text style={[styles.label, ff, { color: colors.foreground }]}>{title}</Text> : null}
|
|
318
|
+
{description ? <Text style={[styles.description, ff, { color: colors.mutedForeground }]}>{description}</Text> : null}
|
|
317
319
|
</View>
|
|
318
320
|
{control}
|
|
319
321
|
</View>
|
|
@@ -361,6 +363,8 @@ function CartControl({
|
|
|
361
363
|
onAdd: () => void;
|
|
362
364
|
onAddEnd: () => void;
|
|
363
365
|
} & React.ComponentProps<typeof View>) {
|
|
366
|
+
const { font } = useKit();
|
|
367
|
+
const ff = font.family ? { fontFamily: font.family } : null;
|
|
364
368
|
// Natural widths, measured off-screen so the spring always lands on the real size.
|
|
365
369
|
const [textW, setTextW] = useState(0);
|
|
366
370
|
const [addW, setAddW] = useState(0);
|
|
@@ -438,7 +442,7 @@ function CartControl({
|
|
|
438
442
|
style={({ pressed }) => [styles.add, { height: s.btn, paddingHorizontal: Math.round(s.btn * 0.36) }, pressed && { backgroundColor: alpha(fg, 0.16) }]}
|
|
439
443
|
>
|
|
440
444
|
<Glyph d={ICON.plus} size={s.icon} color={fg} />
|
|
441
|
-
<Text style={[styles.addText, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
|
|
445
|
+
<Text style={[styles.addText, ff, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
|
|
442
446
|
</Pressable>
|
|
443
447
|
</Animated.View>
|
|
444
448
|
</View>
|
|
@@ -181,14 +181,17 @@ export function useKit(): Theme {
|
|
|
181
181
|
return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Shared motion presets so the whole kit moves the same way. Every spring is critically damped
|
|
186
|
+
* (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
|
|
187
|
+
*/
|
|
185
188
|
export const motion = {
|
|
186
189
|
/** Toggles, selection indicators. */
|
|
187
|
-
snappy: { damping:
|
|
188
|
-
/** Press feedback, pops. */
|
|
189
|
-
bouncy: { damping:
|
|
190
|
+
snappy: { damping: 30, stiffness: 260, mass: 0.8 },
|
|
191
|
+
/** Press feedback, pops. (Kept under its old name; it no longer bounces.) */
|
|
192
|
+
bouncy: { damping: 26, stiffness: 220, mass: 0.7 },
|
|
190
193
|
/** Sheets, layout changes. */
|
|
191
|
-
gentle: { damping:
|
|
194
|
+
gentle: { damping: 27, stiffness: 160, mass: 1 },
|
|
192
195
|
fast: 160,
|
|
193
196
|
normal: 240,
|
|
194
197
|
};
|
|
@@ -63,7 +63,8 @@ export function Switch({
|
|
|
63
63
|
accessibilityLabel,
|
|
64
64
|
style,
|
|
65
65
|
}: SwitchProps) {
|
|
66
|
-
const { colors, dark } = useKit();
|
|
66
|
+
const { colors, dark, font } = useKit();
|
|
67
|
+
const ff = font.family ? { fontFamily: font.family } : null;
|
|
67
68
|
const gid = 'sw' + useId().replace(/[^a-zA-Z0-9]/g, '');
|
|
68
69
|
const tint = accent ?? colors.accent;
|
|
69
70
|
const [inner, setInner] = useState(defaultValue);
|
|
@@ -315,8 +316,8 @@ export function Switch({
|
|
|
315
316
|
return (
|
|
316
317
|
<Pressable {...a11y} style={[styles.row, { opacity: disabled ? 0.45 : 1 }, style]}>
|
|
317
318
|
<View style={styles.text}>
|
|
318
|
-
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
|
|
319
|
-
{description ? <Text style={[styles.description, { color: colors.mutedForeground }]}>{description}</Text> : null}
|
|
319
|
+
<Text style={[styles.label, ff, { color: colors.foreground }]}>{label}</Text>
|
|
320
|
+
{description ? <Text style={[styles.description, ff, { color: colors.mutedForeground }]}>{description}</Text> : null}
|
|
320
321
|
</View>
|
|
321
322
|
{control}
|
|
322
323
|
</Pressable>
|