@expokit/cli 0.3.0 → 0.4.1
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 +8 -4
- package/bin/expo-kit.js +137 -24
- package/bin/lib.mjs +110 -1
- package/bin/mcp.mjs +40 -18
- package/package.json +2 -2
- package/registry/_docs/accordion.json +1 -1
- package/registry/_docs/assistant-app.json +47 -0
- package/registry/_docs/avatar.json +6 -6
- package/registry/_docs/badge.json +4 -4
- package/registry/_docs/bottom-nav.json +6 -6
- package/registry/_docs/bottom-sheet.json +3 -3
- package/registry/_docs/bullet.json +6 -0
- package/registry/_docs/button-3d.json +3 -3
- package/registry/_docs/button.json +2 -2
- package/registry/_docs/calendar.json +5 -5
- package/registry/_docs/card.json +1 -1
- package/registry/_docs/checkout.json +7 -1
- package/registry/_docs/chip-group.json +1 -1
- package/registry/_docs/confetti.json +3 -3
- package/registry/_docs/date-picker.json +4 -4
- package/registry/_docs/donut-shop.json +88 -0
- package/registry/_docs/drawer.json +4 -4
- package/registry/_docs/fintech-app.json +49 -0
- package/registry/_docs/flip-card.json +4 -4
- package/registry/_docs/floating-input.json +5 -5
- package/registry/_docs/food-app.json +47 -0
- package/registry/_docs/glass.json +1 -1
- package/registry/_docs/grain.json +80 -0
- package/registry/_docs/isometric-bar.json +6 -0
- package/registry/_docs/liquidglass-tab.json +1 -1
- package/registry/_docs/morphing-button-group.json +96 -0
- package/registry/_docs/orb-reveal.json +132 -0
- package/registry/_docs/otp-input.json +4 -4
- package/registry/_docs/password-input.json +3 -3
- package/registry/_docs/payment-card.json +3 -3
- package/registry/_docs/pixel-bar.json +6 -0
- package/registry/_docs/polar-area.json +6 -0
- package/registry/_docs/progress-steps.json +4 -4
- package/registry/_docs/progress.json +2 -2
- package/registry/_docs/qr-code-button.json +122 -0
- package/registry/_docs/radio-group.json +1 -1
- package/registry/_docs/search-bar.json +4 -4
- package/registry/_docs/segmented-control.json +1 -1
- package/registry/_docs/select.json +7 -7
- package/registry/_docs/settings.json +6 -0
- package/registry/_docs/skeleton.json +3 -3
- package/registry/_docs/slider.json +6 -6
- package/registry/_docs/spinner.json +3 -3
- package/registry/_docs/stateful-button.json +1 -1
- package/registry/_docs/stepper.json +7 -7
- package/registry/_docs/tabs.json +2 -2
- package/registry/_docs/text-animation.json +4 -4
- package/registry/_docs/textarea.json +3 -3
- package/registry/_docs/ticket-shape.json +1 -1
- package/registry/_docs/tilt-card.json +4 -4
- package/registry/_docs/time-picker.json +6 -6
- package/registry/_docs/timeline-chart.json +6 -0
- package/registry/_docs/timeline.json +6 -6
- package/registry/_docs/toast.json +8 -8
- package/registry/_docs/treemap.json +6 -0
- package/registry/_docs/waterfall.json +6 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +36 -7
- package/registry/accordion/files/src/lib/kit.tsx +8 -5
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +12 -8
- package/registry/action-sheet/files/src/lib/kit.tsx +8 -5
- package/registry/alert/files/src/components/ui/alert.tsx +11 -5
- package/registry/alert/files/src/components/ui/button.tsx +35 -6
- 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 +23 -9
- package/registry/badge/files/src/lib/kit.tsx +8 -5
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +21 -8
- 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 +35 -6
- package/registry/button/files/src/lib/kit.tsx +8 -5
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +35 -7
- package/registry/button-3d/files/src/lib/kit.tsx +8 -5
- package/registry/calendar/files/src/components/ui/calendar.tsx +68 -19
- package/registry/calendar/files/src/lib/kit.tsx +8 -5
- package/registry/card/files/src/components/ui/card.tsx +32 -10
- package/registry/card/files/src/lib/kit.tsx +8 -5
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +31 -8
- package/registry/checkbox/files/src/lib/kit.tsx +8 -5
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +39 -7
- 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 +55 -28
- package/registry/coupon/files/src/components/ui/flip-card.tsx +23 -4
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +59 -18
- package/registry/coupon/files/src/lib/kit.tsx +8 -5
- package/registry/date-picker/files/src/components/ui/calendar.tsx +68 -19
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +98 -44
- package/registry/date-picker/files/src/lib/kit.tsx +8 -5
- package/registry/dialog/files/src/components/ui/dialog.tsx +19 -9
- 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 +31 -21
- 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 +23 -4
- package/registry/flip-card/files/src/lib/kit.tsx +8 -5
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +91 -32
- 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 +2 -2
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +1 -1
- package/registry/list-item/files/src/components/ui/list-item.tsx +28 -17
- 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 +57 -24
- package/registry/otp-input/files/src/lib/kit.tsx +8 -5
- package/registry/password-input/files/src/components/ui/floating-input.tsx +91 -32
- package/registry/password-input/files/src/components/ui/password-input.tsx +55 -21
- package/registry/password-input/files/src/lib/kit.tsx +8 -5
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +23 -4
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +35 -18
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +59 -18
- package/registry/payment-card/files/src/lib/kit.tsx +8 -5
- package/registry/progress/files/src/components/ui/progress.tsx +16 -6
- package/registry/progress/files/src/lib/kit.tsx +8 -5
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +22 -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 +48 -17
- package/registry/radio-group/files/src/lib/kit.tsx +8 -5
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +81 -26
- package/registry/search-bar/files/src/lib/kit.tsx +8 -5
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +40 -10
- package/registry/segmented-control/files/src/lib/kit.tsx +8 -5
- package/registry/select/files/src/components/ui/select.tsx +161 -86
- 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 +56 -27
- package/registry/slider/files/src/lib/kit.tsx +8 -5
- package/registry/spinner/files/src/components/ui/spinner.tsx +17 -12
- package/registry/spinner/files/src/lib/kit.tsx +8 -5
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
- package/registry/stateful-button/files/src/lib/kit.tsx +8 -5
- package/registry/stepper/files/src/components/ui/stepper.tsx +83 -29
- package/registry/stepper/files/src/lib/kit.tsx +8 -5
- package/registry/switch/files/src/components/ui/switch.tsx +60 -22
- package/registry/switch/files/src/lib/kit.tsx +8 -5
- package/registry/tabs/files/src/components/ui/tabs.tsx +15 -10
- package/registry/tabs/files/src/lib/kit.tsx +8 -5
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +11 -6
- package/registry/text-animation/files/src/lib/kit.tsx +8 -5
- package/registry/textarea/files/src/components/ui/textarea.tsx +115 -45
- 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 +59 -18
- package/registry/tilt-card/files/src/lib/kit.tsx +8 -5
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +156 -75
- 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 +19 -12
- package/registry/timeline/files/src/lib/kit.tsx +8 -5
- package/registry/toast/files/src/components/ui/button.tsx +35 -6
- package/registry/toast/files/src/components/ui/toast.tsx +25 -14
- package/registry/toast/files/src/lib/kit.tsx +8 -5
- package/schema/components.schema.json +1 -1
- package/registry/_docs/color-swatch.json +0 -82
- package/registry/_docs/kpi-card.json +0 -14
- package/registry/_docs/like-button.json +0 -92
- package/registry/_docs/morph-button.json +0 -128
- package/registry/_docs/pagination-dots.json +0 -108
- package/registry/_docs/shimmer-button.json +0 -98
- package/registry/_docs/tag-input.json +0 -148
- 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/files/src/lib/kit.tsx +0 -219
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { Animated, Pressable, StyleSheet, Text, View, type TextInput } from 'react-native';
|
|
1
|
+
import React, { forwardRef, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
|
|
2
|
+
import { AccessibilityInfo, Animated, Pressable, StyleSheet, Text, View, type TextInput } from 'react-native';
|
|
3
3
|
import Svg, { Circle, Path } from 'react-native-svg';
|
|
4
4
|
|
|
5
5
|
import { FloatingInput, type FloatingInputProps } from '@/components/ui/floating-input';
|
|
@@ -34,6 +34,10 @@ export interface PasswordInputProps extends Omit<FloatingInputProps, 'secureText
|
|
|
34
34
|
mismatchMessage?: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
|
|
38
|
+
// content text scales all the way.
|
|
39
|
+
const FONT_CAP = 1.35;
|
|
40
|
+
|
|
37
41
|
const LEVEL_LABELS = ['Too short', 'Weak', 'Okay', 'Strong', 'Very strong'];
|
|
38
42
|
const ICON = { sm: 18, md: 22, lg: 24 };
|
|
39
43
|
|
|
@@ -50,7 +54,7 @@ export function scorePassword(pw: string) {
|
|
|
50
54
|
* Floating-label password field with an eye / eye-off toggle that cross-fades, a spring-filled
|
|
51
55
|
* strength meter, an optional requirements checklist and a confirm-password mode.
|
|
52
56
|
*/
|
|
53
|
-
export const PasswordInput = forwardRef<TextInput
|
|
57
|
+
export const PasswordInput = forwardRef<ComponentRef<typeof TextInput>, PasswordInputProps>(function PasswordInput(
|
|
54
58
|
{
|
|
55
59
|
strength = true,
|
|
56
60
|
score = scorePassword,
|
|
@@ -70,6 +74,7 @@ export const PasswordInput = forwardRef<TextInput, PasswordInputProps>(function
|
|
|
70
74
|
ref
|
|
71
75
|
) {
|
|
72
76
|
const { colors } = useKit();
|
|
77
|
+
const reduce = useReduceMotion();
|
|
73
78
|
const [hidden, setHidden] = useState(true);
|
|
74
79
|
const [inner, setInner] = useState(defaultValue ?? '');
|
|
75
80
|
const text = value ?? inner;
|
|
@@ -84,17 +89,21 @@ export const PasswordInput = forwardRef<TextInput, PasswordInputProps>(function
|
|
|
84
89
|
|
|
85
90
|
const eye = useRef(new Animated.Value(hidden ? 0 : 1)).current; // native: 1 = visible
|
|
86
91
|
useEffect(() => {
|
|
87
|
-
Animated.
|
|
88
|
-
|
|
92
|
+
if (reduce) Animated.timing(eye, { toValue: hidden ? 0 : 1, duration: 150, useNativeDriver: true }).start();
|
|
93
|
+
else Animated.spring(eye, { toValue: hidden ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
94
|
+
}, [hidden, eye, reduce]);
|
|
89
95
|
|
|
90
96
|
const iconColor = iconColorProp ?? colors.mutedForeground;
|
|
91
97
|
const iconSize = ICON[size];
|
|
92
98
|
const fade = (on: 0 | 1) => ({
|
|
93
99
|
opacity: eye.interpolate({ inputRange: [0, 1], outputRange: on ? [0, 1] : [1, 0], extrapolate: 'clamp' }),
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
// Reduce Motion: a plain cross-fade, no spin or scale.
|
|
101
|
+
transform: reduce
|
|
102
|
+
? []
|
|
103
|
+
: [
|
|
104
|
+
{ scale: eye.interpolate({ inputRange: [0, 1], outputRange: on ? [0.6, 1] : [1, 0.6] }) },
|
|
105
|
+
{ rotate: eye.interpolate({ inputRange: [0, 1], outputRange: on ? ['-45deg', '0deg'] : ['0deg', '45deg'] }) },
|
|
106
|
+
],
|
|
98
107
|
});
|
|
99
108
|
|
|
100
109
|
return (
|
|
@@ -137,8 +146,8 @@ export const PasswordInput = forwardRef<TextInput, PasswordInputProps>(function
|
|
|
137
146
|
</Pressable>
|
|
138
147
|
}
|
|
139
148
|
/>
|
|
140
|
-
{strength && !confirming && text.length > 0 ? <Meter level={level} /> : null}
|
|
141
|
-
{rules && !confirming ? <Checklist rules={rules} value={text} /> : null}
|
|
149
|
+
{strength && !confirming && text.length > 0 ? <Meter level={level} reduce={reduce} /> : null}
|
|
150
|
+
{rules && !confirming ? <Checklist rules={rules} value={text} reduce={reduce} /> : null}
|
|
142
151
|
</View>
|
|
143
152
|
);
|
|
144
153
|
});
|
|
@@ -151,7 +160,7 @@ const EYE_OFF =
|
|
|
151
160
|
'M2 2l20 20';
|
|
152
161
|
|
|
153
162
|
/** Four segments that spring-fill left to right; the colour slides with the score. */
|
|
154
|
-
function Meter({ level }: { level: number }) {
|
|
163
|
+
function Meter({ level, reduce }: { level: number; reduce: boolean }) {
|
|
155
164
|
const { colors, dark, font } = useKit();
|
|
156
165
|
const palette = [colors.mutedForeground, colors.danger, colors.warning, colors.success, colors.success];
|
|
157
166
|
const fills = useRef([0, 1, 2, 3].map(() => new Animated.Value(0))).current; // native: scaleX
|
|
@@ -159,16 +168,24 @@ function Meter({ level }: { level: number }) {
|
|
|
159
168
|
const enter = useRef(new Animated.Value(0)).current; // native
|
|
160
169
|
|
|
161
170
|
useEffect(() => {
|
|
162
|
-
|
|
171
|
+
if (reduce) enter.setValue(1);
|
|
172
|
+
else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
174
|
}, [enter]);
|
|
164
175
|
|
|
165
176
|
useEffect(() => {
|
|
177
|
+
if (reduce) {
|
|
178
|
+
// Segments appear filled at once; the colour still fades.
|
|
179
|
+
fills.forEach((a, i) => a.setValue(i < level ? 1 : 0));
|
|
180
|
+
Animated.timing(tone, { toValue: level, duration: 150, useNativeDriver: false }).start();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
166
183
|
Animated.stagger(
|
|
167
184
|
50,
|
|
168
185
|
fills.map((a, i) => Animated.spring(a, { toValue: i < level ? 1 : 0, useNativeDriver: true, ...motion.snappy }))
|
|
169
186
|
).start();
|
|
170
187
|
Animated.timing(tone, { toValue: level, duration: 220, useNativeDriver: false }).start();
|
|
171
|
-
}, [level, fills, tone]);
|
|
188
|
+
}, [level, fills, tone, reduce]);
|
|
172
189
|
|
|
173
190
|
const color = tone.interpolate({ inputRange: [0, 1, 2, 3, 4], outputRange: palette });
|
|
174
191
|
const track = dark ? colors.muted : colors.border;
|
|
@@ -199,27 +216,28 @@ function Meter({ level }: { level: number }) {
|
|
|
199
216
|
</View>
|
|
200
217
|
))}
|
|
201
218
|
</View>
|
|
202
|
-
<Text style={[styles.level, font.family ? { fontFamily: font.family } : null, { color: palette[level] }]}>{LEVEL_LABELS[level]}</Text>
|
|
219
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.level, font.family ? { fontFamily: font.family } : null, { color: palette[level] }]}>{LEVEL_LABELS[level]}</Text>
|
|
203
220
|
</Animated.View>
|
|
204
221
|
);
|
|
205
222
|
}
|
|
206
223
|
|
|
207
|
-
function Checklist({ rules, value }: { rules: PasswordRequirement[]; value: string }) {
|
|
224
|
+
function Checklist({ rules, value, reduce }: { rules: PasswordRequirement[]; value: string; reduce: boolean }) {
|
|
208
225
|
return (
|
|
209
226
|
<View style={styles.checklist} accessibilityRole="list">
|
|
210
227
|
{rules.map((r) => (
|
|
211
|
-
<Requirement key={r.label} label={r.label} met={r.test(value)} />
|
|
228
|
+
<Requirement key={r.label} label={r.label} met={r.test(value)} reduce={reduce} />
|
|
212
229
|
))}
|
|
213
230
|
</View>
|
|
214
231
|
);
|
|
215
232
|
}
|
|
216
233
|
|
|
217
|
-
function Requirement({ label, met }: { label: string; met: boolean }) {
|
|
234
|
+
function Requirement({ label, met, reduce }: { label: string; met: boolean; reduce: boolean }) {
|
|
218
235
|
const { colors, dark, font } = useKit();
|
|
219
236
|
const tick = useRef(new Animated.Value(met ? 1 : 0)).current; // native
|
|
220
237
|
useEffect(() => {
|
|
221
|
-
Animated.
|
|
222
|
-
|
|
238
|
+
if (reduce) Animated.timing(tick, { toValue: met ? 1 : 0, duration: 150, useNativeDriver: true }).start();
|
|
239
|
+
else Animated.spring(tick, { toValue: met ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
|
|
240
|
+
}, [met, tick, reduce]);
|
|
223
241
|
|
|
224
242
|
return (
|
|
225
243
|
<View
|
|
@@ -237,7 +255,7 @@ function Requirement({ label, met }: { label: string; met: boolean }) {
|
|
|
237
255
|
StyleSheet.absoluteFill,
|
|
238
256
|
{
|
|
239
257
|
opacity: tick.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
240
|
-
transform: [{ scale: tick.interpolate({ inputRange: [0, 1], outputRange: [0.2, 1] }) }],
|
|
258
|
+
transform: reduce ? [] : [{ scale: tick.interpolate({ inputRange: [0, 1], outputRange: [0.2, 1] }) }],
|
|
241
259
|
},
|
|
242
260
|
]}
|
|
243
261
|
>
|
|
@@ -258,6 +276,22 @@ function Requirement({ label, met }: { label: string; met: boolean }) {
|
|
|
258
276
|
);
|
|
259
277
|
}
|
|
260
278
|
|
|
279
|
+
function useReduceMotion() {
|
|
280
|
+
const [reduce, setReduce] = useState(false);
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
let alive = true;
|
|
283
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
284
|
+
.then((r) => alive && setReduce(r))
|
|
285
|
+
.catch(() => {});
|
|
286
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
287
|
+
return () => {
|
|
288
|
+
alive = false;
|
|
289
|
+
sub.remove();
|
|
290
|
+
};
|
|
291
|
+
}, []);
|
|
292
|
+
return reduce;
|
|
293
|
+
}
|
|
294
|
+
|
|
261
295
|
const styles = StyleSheet.create({
|
|
262
296
|
meter: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 10, marginHorizontal: 4 },
|
|
263
297
|
bars: { flex: 1, flexDirection: 'row', gap: 6 },
|
|
@@ -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
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { Animated, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
import { AccessibilityInfo, Animated, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { motion } from '@/lib/kit';
|
|
5
5
|
|
|
@@ -48,12 +48,15 @@ const SQUEEZE = {
|
|
|
48
48
|
* The flip as an animated 0 → 1 value, springing like FlipCard's own, plus `shadowScaleX` — pass
|
|
49
49
|
* `progress` to the FlipCard and `shadowScaleX` to a TiltCard wrapped round it, so the tilt
|
|
50
50
|
* shadow narrows as the card turns edge-on instead of showing as a dark box behind it.
|
|
51
|
+
* With Reduce Motion on, the card turns over at once (no 3D turn).
|
|
51
52
|
*/
|
|
52
53
|
export function useFlipProgress(flipped: boolean) {
|
|
54
|
+
const reduce = useReduceMotion();
|
|
53
55
|
const progress = useRef(new Animated.Value(flipped ? 1 : 0)).current;
|
|
54
56
|
useEffect(() => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
if (reduce) progress.setValue(flipped ? 1 : 0);
|
|
58
|
+
else Animated.spring(progress, { toValue: flipped ? 1 : 0, ...motion.gentle, useNativeDriver: true }).start();
|
|
59
|
+
}, [flipped, progress, reduce]);
|
|
57
60
|
const shadowScaleX = useMemo(() => progress.interpolate(SQUEEZE), [progress]);
|
|
58
61
|
return { progress, shadowScaleX };
|
|
59
62
|
}
|
|
@@ -121,7 +124,7 @@ export function FlipCard({
|
|
|
121
124
|
borderRadius: radius,
|
|
122
125
|
opacity,
|
|
123
126
|
transform: [{ perspective }, { [axis]: turn } as never, { scale: lift }],
|
|
124
|
-
}) as Animated.
|
|
127
|
+
}) as Animated.WithAnimatedValue<ViewStyle>;
|
|
125
128
|
|
|
126
129
|
// The shadow squeezes along the turning axis as the card goes edge-on (see SQUEEZE) and fades a little.
|
|
127
130
|
const squeeze = p.interpolate(SQUEEZE);
|
|
@@ -185,6 +188,22 @@ export function FlipCard({
|
|
|
185
188
|
);
|
|
186
189
|
}
|
|
187
190
|
|
|
191
|
+
function useReduceMotion() {
|
|
192
|
+
const [reduce, setReduce] = useState(false);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
let alive = true;
|
|
195
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
196
|
+
.then((r) => alive && setReduce(r))
|
|
197
|
+
.catch(() => {});
|
|
198
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
199
|
+
return () => {
|
|
200
|
+
alive = false;
|
|
201
|
+
sub.remove();
|
|
202
|
+
};
|
|
203
|
+
}, []);
|
|
204
|
+
return reduce;
|
|
205
|
+
}
|
|
206
|
+
|
|
188
207
|
const styles = StyleSheet.create({
|
|
189
208
|
face: { overflow: 'hidden', backfaceVisibility: 'hidden' },
|
|
190
209
|
back: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
|
|
@@ -13,6 +13,7 @@ import Svg, { Circle, ClipPath, Defs, G, Line, LinearGradient, Path, Rect, Stop
|
|
|
13
13
|
|
|
14
14
|
import { FlipCard, useFlipProgress } from '@/components/ui/flip-card';
|
|
15
15
|
import { TiltCard } from '@/components/ui/tilt-card';
|
|
16
|
+
import { useKit } from '@/lib/kit';
|
|
16
17
|
|
|
17
18
|
export type PaymentCardBrand = 'visa' | 'mastercard' | 'amex' | 'rupay' | 'none';
|
|
18
19
|
|
|
@@ -30,8 +31,8 @@ export interface PaymentCardProps {
|
|
|
30
31
|
bank?: string;
|
|
31
32
|
/** Small label top-right, e.g. "Debit", "World Elite". */
|
|
32
33
|
label?: string;
|
|
33
|
-
/** Card art — a photo or texture (`require('./card.jpg')` or `{ uri }`). Default: brushed graphite. */
|
|
34
|
-
image?: ImageSourcePropType;
|
|
34
|
+
/** Card art — a photo or texture (`require('./card.jpg')` or `{ uri }`). Or any element drawn as the art, e.g. `<GrainGradient palette=\"ocean\" style={{ flex: 1 }} />`. Default: brushed graphite. */
|
|
35
|
+
image?: ImageSourcePropType | React.ReactElement;
|
|
35
36
|
/** `light` text for dark art (default), `dark` text for pale art. */
|
|
36
37
|
tone?: 'light' | 'dark';
|
|
37
38
|
/** EMV chip finish. */
|
|
@@ -70,6 +71,10 @@ export interface PaymentCardLabels {
|
|
|
70
71
|
accessibility?: string;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// Everything is printed on a fixed-shape card, so all its text is control text: it stops growing at iOS's largest
|
|
75
|
+
// standard text size (xxxL ≈ 1.35×) so the card keeps its shape.
|
|
76
|
+
const FONT_CAP = 1.35;
|
|
77
|
+
|
|
73
78
|
/**
|
|
74
79
|
* "4821 7590 1234 0317" → groups of 4 (Amex 4-6-5, 14-digit Diners 4-6-4, 19-digit cards get a
|
|
75
80
|
* fifth group). Masking hides every digit but the last four, whatever the length. Keeps • placeholders.
|
|
@@ -110,19 +115,19 @@ function BrandMark({ brand, ink }: { brand: PaymentCardBrand; ink: string }) {
|
|
|
110
115
|
);
|
|
111
116
|
}
|
|
112
117
|
if (brand === 'visa') {
|
|
113
|
-
return <Text style={[styles.visa, { color: ink }]}>VISA</Text>;
|
|
118
|
+
return <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.visa, { color: ink }]}>VISA</Text>;
|
|
114
119
|
}
|
|
115
120
|
if (brand === 'amex') {
|
|
116
121
|
return (
|
|
117
122
|
<View style={[styles.amex, { borderColor: ink }]}>
|
|
118
|
-
<Text style={{ color: ink, fontSize: 9, fontWeight: '800', letterSpacing: 0.6, lineHeight: 10 }}>AMERICAN</Text>
|
|
119
|
-
<Text style={{ color: ink, fontSize: 9, fontWeight: '800', letterSpacing: 0.6, lineHeight: 10 }}>EXPRESS</Text>
|
|
123
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={{ color: ink, fontSize: 9, fontWeight: '800', letterSpacing: 0.6, lineHeight: 10 }}>AMERICAN</Text>
|
|
124
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={{ color: ink, fontSize: 9, fontWeight: '800', letterSpacing: 0.6, lineHeight: 10 }}>EXPRESS</Text>
|
|
120
125
|
</View>
|
|
121
126
|
);
|
|
122
127
|
}
|
|
123
128
|
return (
|
|
124
129
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
|
|
125
|
-
<Text style={{ color: ink, fontSize: 18, fontWeight: '800', fontStyle: 'italic', letterSpacing: -0.3 }}>RuPay</Text>
|
|
130
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={{ color: ink, fontSize: 18, fontWeight: '800', fontStyle: 'italic', letterSpacing: -0.3 }}>RuPay</Text>
|
|
126
131
|
<Svg width={14} height={16} viewBox="0 0 14 16">
|
|
127
132
|
<Path d="M2 1 L10 8 L2 15 Z" fill="#F47920" />
|
|
128
133
|
<Path d="M6 1 L14 8 L6 15 Z" fill="#0B8E36" />
|
|
@@ -222,6 +227,8 @@ export function PaymentCard({
|
|
|
222
227
|
labels,
|
|
223
228
|
style,
|
|
224
229
|
}: PaymentCardProps) {
|
|
230
|
+
const { font } = useKit();
|
|
231
|
+
const ff = font.family ? { fontFamily: font.family } : null;
|
|
225
232
|
const controlled = flippedProp !== undefined;
|
|
226
233
|
const [own, setOwn] = useState(defaultFlipped);
|
|
227
234
|
// A frozen card always shows its front (where the frost and the label are).
|
|
@@ -253,7 +260,17 @@ export function PaymentCard({
|
|
|
253
260
|
|
|
254
261
|
const art = (darken: number) => (
|
|
255
262
|
<>
|
|
256
|
-
{image ?
|
|
263
|
+
{image ? (
|
|
264
|
+
React.isValidElement(image) ? (
|
|
265
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
266
|
+
{image}
|
|
267
|
+
</View>
|
|
268
|
+
) : (
|
|
269
|
+
<Image alt="" source={image} style={StyleSheet.absoluteFill} resizeMode="cover" accessibilityIgnoresInvertColors />
|
|
270
|
+
)
|
|
271
|
+
) : (
|
|
272
|
+
<Graphite />
|
|
273
|
+
)}
|
|
257
274
|
{darken > 0 ? <View style={[StyleSheet.absoluteFill, { backgroundColor: `rgba(0,0,0,${darken})` }]} /> : null}
|
|
258
275
|
</>
|
|
259
276
|
);
|
|
@@ -265,28 +282,28 @@ export function PaymentCard({
|
|
|
265
282
|
{tone === 'light' ? <Shade strength={image ? 0.38 : 0.2} /> : null}
|
|
266
283
|
<View style={styles.inner}>
|
|
267
284
|
<View style={styles.row}>
|
|
268
|
-
<Text style={[{ color: ink, fontSize: 16, fontWeight: '700', letterSpacing: -0.2 }, emboss]}>{bank ?? ''}</Text>
|
|
269
|
-
{label ? <Text style={[{ color: soft, fontSize: 11, fontWeight: '600', letterSpacing: 1 }, emboss]}>{label.toUpperCase()}</Text> : null}
|
|
285
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[{ flexShrink: 1, color: ink, fontSize: 16, fontWeight: '700', letterSpacing: -0.2 }, ff, emboss]}>{bank ?? ''}</Text>
|
|
286
|
+
{label ? <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: soft, fontSize: 11, fontWeight: '600', letterSpacing: 1 }, ff, emboss]}>{label.toUpperCase()}</Text> : null}
|
|
270
287
|
</View>
|
|
271
288
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
|
272
289
|
<Chip finish={chip} />
|
|
273
290
|
<Contactless ink={ink} />
|
|
274
291
|
</View>
|
|
275
292
|
<View style={{ gap: 10 }}>
|
|
276
|
-
<Text style={[{ color: ink, fontSize: 19, fontWeight: '500', letterSpacing: 2, fontVariant: ['tabular-nums'] }, emboss]}>
|
|
293
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize: 19, fontWeight: '500', letterSpacing: 2, fontVariant: ['tabular-nums'] }, ff, emboss]}>
|
|
277
294
|
{formatNumber(number, brand, masked)}
|
|
278
295
|
</Text>
|
|
279
296
|
<View style={[styles.row, { alignItems: 'flex-end' }]}>
|
|
280
297
|
<View style={{ flexDirection: 'row', gap: 20, flexShrink: 1 }}>
|
|
281
298
|
<View style={{ flexShrink: 1 }}>
|
|
282
|
-
<Text style={[styles.tiny, { color: soft }]}>{text.cardholder}</Text>
|
|
283
|
-
<Text style={[{ color: ink, fontSize: 13, fontWeight: '600', letterSpacing: 0.8 }, emboss]} numberOfLines={1}>
|
|
299
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.tiny, ff, { color: soft }]}>{text.cardholder}</Text>
|
|
300
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize: 13, fontWeight: '600', letterSpacing: 0.8 }, ff, emboss]} numberOfLines={1}>
|
|
284
301
|
{name.toUpperCase()}
|
|
285
302
|
</Text>
|
|
286
303
|
</View>
|
|
287
304
|
<View>
|
|
288
|
-
<Text style={[styles.tiny, { color: soft }]}>{text.validThru}</Text>
|
|
289
|
-
<Text style={[{ color: ink, fontSize: 13, fontWeight: '600', fontVariant: ['tabular-nums'] }, emboss]}>{expiry}</Text>
|
|
305
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.tiny, ff, { color: soft }]}>{text.validThru}</Text>
|
|
306
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize: 13, fontWeight: '600', fontVariant: ['tabular-nums'] }, ff, emboss]}>{expiry}</Text>
|
|
290
307
|
</View>
|
|
291
308
|
</View>
|
|
292
309
|
<BrandMark brand={brand} ink={ink} />
|
|
@@ -296,7 +313,7 @@ export function PaymentCard({
|
|
|
296
313
|
{frozen ? (
|
|
297
314
|
<View style={[StyleSheet.absoluteFill, styles.center, { backgroundColor: 'rgba(214,232,255,0.28)' }]}>
|
|
298
315
|
<View style={styles.frozenPill}>
|
|
299
|
-
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '700', letterSpacing: 0.4 }}>❄︎ {text.frozen}</Text>
|
|
316
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: '#ffffff', fontSize: 13, fontWeight: '700', letterSpacing: 0.4 }, ff]}>❄︎ {text.frozen}</Text>
|
|
300
317
|
</View>
|
|
301
318
|
</View>
|
|
302
319
|
) : null}
|
|
@@ -317,15 +334,15 @@ export function PaymentCard({
|
|
|
317
334
|
</Svg>
|
|
318
335
|
</View>
|
|
319
336
|
<View style={styles.cvv}>
|
|
320
|
-
<Text style={{ color: '#111', fontSize: 15, fontWeight: '700', fontStyle: 'italic', letterSpacing: 1.5, fontVariant: ['tabular-nums'] }}>{cvv ?? '•••'}</Text>
|
|
337
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: '#111', fontSize: 15, fontWeight: '700', fontStyle: 'italic', letterSpacing: 1.5, fontVariant: ['tabular-nums'] }, ff]}>{cvv ?? '•••'}</Text>
|
|
321
338
|
</View>
|
|
322
339
|
</View>
|
|
323
340
|
<View style={styles.backFoot}>
|
|
324
341
|
<View style={{ flex: 1, gap: 3 }}>
|
|
325
|
-
<Text style={{ color: 'rgba(255,255,255,0.78)', fontSize: 9, lineHeight: 12 }}>
|
|
342
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: 'rgba(255,255,255,0.78)', fontSize: 9, lineHeight: 12 }, ff]}>
|
|
326
343
|
{fill(text.legal)}
|
|
327
344
|
</Text>
|
|
328
|
-
<Text style={{ color: 'rgba(255,255,255,0.9)', fontSize: 10, fontWeight: '600' }}>{fill(text.back)}</Text>
|
|
345
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: 'rgba(255,255,255,0.9)', fontSize: 10, fontWeight: '600' }, ff]}>{fill(text.back)}</Text>
|
|
329
346
|
</View>
|
|
330
347
|
{/* Security hologram. */}
|
|
331
348
|
<Svg width={40} height={30} viewBox="0 0 40 30">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
|
|
2
2
|
import {
|
|
3
|
+
AccessibilityInfo,
|
|
3
4
|
Animated,
|
|
4
5
|
Easing,
|
|
5
6
|
Image,
|
|
@@ -92,7 +93,8 @@ export interface TiltState {
|
|
|
92
93
|
height: number;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
// `reduce` (Reduce Motion) rides along privately so layers stay flat too; useTilt still returns a TiltState.
|
|
97
|
+
const Ctx = createContext<(TiltState & { reduce: boolean }) | null>(null);
|
|
96
98
|
|
|
97
99
|
/** The surrounding TiltCard's live tilt (null outside one) — for your own layers and effects. */
|
|
98
100
|
export function useTilt() {
|
|
@@ -105,6 +107,8 @@ const PAINT = 96;
|
|
|
105
107
|
|
|
106
108
|
// Six colour cycles across the paint, so a couple of bands cross the card like real foil. Cool
|
|
107
109
|
// pinks, violets and cyans stay clean when blended over dark cards (yellow and green turn olive).
|
|
110
|
+
const SMOOTH = Easing.bezier(0.32, 0.72, 0, 1);
|
|
111
|
+
|
|
108
112
|
const HOLO = Array.from({ length: 37 }, (_, i) => ['#ff6bd6', '#b38cff', '#6fb6ff', '#5ff0ff', '#a7ffe0', '#ffc2ef'][i % 6]);
|
|
109
113
|
|
|
110
114
|
function GlarePaint({ variant }: { variant: 'soft' | 'sharp' }) {
|
|
@@ -256,6 +260,7 @@ export function TiltCard({
|
|
|
256
260
|
accessibilityLabel,
|
|
257
261
|
style,
|
|
258
262
|
}: TiltCardProps) {
|
|
263
|
+
const reduce = useReduceMotion();
|
|
259
264
|
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
260
265
|
const sizeRef = useRef(size);
|
|
261
266
|
sizeRef.current = size;
|
|
@@ -267,14 +272,16 @@ export function TiltCard({
|
|
|
267
272
|
const touching = useRef(false);
|
|
268
273
|
const origin = useRef({ x: 0, y: 0 });
|
|
269
274
|
const moved = useRef(false);
|
|
270
|
-
const viewRef = useRef<View
|
|
275
|
+
const viewRef = useRef<ComponentRef<typeof View>>(null);
|
|
271
276
|
const disabledRef = useRef(disabled);
|
|
272
277
|
disabledRef.current = disabled;
|
|
278
|
+
const reduceRef = useRef(reduce);
|
|
279
|
+
reduceRef.current = reduce;
|
|
273
280
|
|
|
274
281
|
// Idle sway: a slow figure-eight while nobody is touching.
|
|
275
282
|
const idleLoop = useRef<Animated.CompositeAnimation | null>(null);
|
|
276
283
|
const startIdle = () => {
|
|
277
|
-
if (!idle) return;
|
|
284
|
+
if (!idle || reduce) return;
|
|
278
285
|
const ease = Easing.inOut(Easing.sin);
|
|
279
286
|
const leg = (v: Animated.Value, to: number, ms: number) =>
|
|
280
287
|
Animated.timing(v, {
|
|
@@ -292,10 +299,15 @@ export function TiltCard({
|
|
|
292
299
|
idleLoop.current.start();
|
|
293
300
|
};
|
|
294
301
|
useEffect(() => {
|
|
302
|
+
// Reduce Motion: no sway; settle where it stopped back at rest.
|
|
303
|
+
if (reduce && !touching.current) {
|
|
304
|
+
tx.setValue(0);
|
|
305
|
+
ty.setValue(0);
|
|
306
|
+
}
|
|
295
307
|
startIdle();
|
|
296
308
|
return () => idleLoop.current?.stop();
|
|
297
309
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
298
|
-
}, [idle]);
|
|
310
|
+
}, [idle, reduce]);
|
|
299
311
|
|
|
300
312
|
const aim = (pageX: number, pageY: number) => {
|
|
301
313
|
const { w, h } = sizeRef.current;
|
|
@@ -307,6 +319,12 @@ export function TiltCard({
|
|
|
307
319
|
const release = (tapped: boolean) => {
|
|
308
320
|
touching.current = false;
|
|
309
321
|
if (tapped && !moved.current) onPressRef.current?.();
|
|
322
|
+
if (reduce) {
|
|
323
|
+
// The card never leaves flat, so only the light has to settle: a short ease, no spring.
|
|
324
|
+
const settle = (v: Animated.Value) => Animated.timing(v, { toValue: 0, duration: 150, easing: SMOOTH, useNativeDriver: true });
|
|
325
|
+
Animated.parallel([settle(tx), settle(ty), settle(press)]).start();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
310
328
|
Animated.parallel([
|
|
311
329
|
Animated.spring(tx, {
|
|
312
330
|
toValue: 0,
|
|
@@ -355,11 +373,15 @@ export function TiltCard({
|
|
|
355
373
|
if (typeof px === 'number' && typeof py === 'number') origin.current = { x: px, y: py };
|
|
356
374
|
});
|
|
357
375
|
aim(pageX, pageY);
|
|
358
|
-
|
|
359
|
-
toValue: 1,
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
376
|
+
if (reduceRef.current) {
|
|
377
|
+
Animated.timing(press, { toValue: 1, duration: 150, easing: SMOOTH, useNativeDriver: true }).start();
|
|
378
|
+
} else {
|
|
379
|
+
Animated.spring(press, {
|
|
380
|
+
toValue: 1,
|
|
381
|
+
...motion.snappy,
|
|
382
|
+
useNativeDriver: true,
|
|
383
|
+
}).start();
|
|
384
|
+
}
|
|
363
385
|
},
|
|
364
386
|
onPanResponderMove: (e, g) => {
|
|
365
387
|
if (Math.abs(g.dx) + Math.abs(g.dy) > 6) moved.current = true;
|
|
@@ -380,21 +402,24 @@ export function TiltCard({
|
|
|
380
402
|
if (width !== size.w || height !== size.h) setSize({ w: width, h: height });
|
|
381
403
|
};
|
|
382
404
|
|
|
405
|
+
// Reduce Motion keeps the card flat and still: no tilt, lift or parallax. The reflection (light,
|
|
406
|
+
// not movement of the content) still follows the finger.
|
|
407
|
+
const tilt = reduce ? 0 : maxTilt;
|
|
383
408
|
const rotateY = tx.interpolate({
|
|
384
409
|
inputRange: [-1, 1],
|
|
385
|
-
outputRange: [`${-
|
|
410
|
+
outputRange: [`${-tilt}deg`, `${tilt}deg`],
|
|
386
411
|
});
|
|
387
412
|
const rotateX = ty.interpolate({
|
|
388
413
|
inputRange: [-1, 1],
|
|
389
|
-
outputRange: [`${
|
|
414
|
+
outputRange: [`${tilt}deg`, `${-tilt}deg`],
|
|
390
415
|
});
|
|
391
416
|
const scale = press.interpolate({
|
|
392
417
|
inputRange: [0, 1],
|
|
393
|
-
outputRange: [1, pressScale],
|
|
418
|
+
outputRange: [1, reduce ? 1 : pressScale],
|
|
394
419
|
});
|
|
395
420
|
|
|
396
|
-
const ctx = useMemo(() => ({ tx, ty, press, width: size.w, height: size.h }), [tx, ty, press, size.w, size.h]);
|
|
397
|
-
const drift = Math.min(10, size.w * 0.03);
|
|
421
|
+
const ctx = useMemo(() => ({ tx, ty, press, width: size.w, height: size.h, reduce }), [tx, ty, press, size.w, size.h, reduce]);
|
|
422
|
+
const drift = reduce ? 0 : Math.min(10, size.w * 0.03);
|
|
398
423
|
// The plate sits well inside the outline — far enough that it never shows through a notch or a
|
|
399
424
|
// see-through corner, even at full tilt; the shadow offset does the downward shift.
|
|
400
425
|
const body = shadowRect ?? { x: 0, y: 0, width: size.w, height: size.h };
|
|
@@ -433,7 +458,7 @@ export function TiltCard({
|
|
|
433
458
|
{
|
|
434
459
|
scale: press.interpolate({
|
|
435
460
|
inputRange: [0, 1],
|
|
436
|
-
outputRange: [1, 1.035],
|
|
461
|
+
outputRange: [1, reduce ? 1 : 1.035],
|
|
437
462
|
}),
|
|
438
463
|
},
|
|
439
464
|
{ scaleX: shadowScaleX ?? 1 },
|
|
@@ -516,7 +541,7 @@ export function TiltCard({
|
|
|
516
541
|
export function TiltLayer({ children, depth = 0.5, style }: TiltLayerProps) {
|
|
517
542
|
const ctx = useContext(Ctx);
|
|
518
543
|
if (!ctx) throw new Error('TiltLayer must be inside TiltCard');
|
|
519
|
-
const d = depth * 14;
|
|
544
|
+
const d = ctx.reduce ? 0 : depth * 14;
|
|
520
545
|
return (
|
|
521
546
|
<Animated.View
|
|
522
547
|
pointerEvents="box-none"
|
|
@@ -539,7 +564,7 @@ export function TiltLayer({ children, depth = 0.5, style }: TiltLayerProps) {
|
|
|
539
564
|
{
|
|
540
565
|
scale: ctx.press.interpolate({
|
|
541
566
|
inputRange: [0, 1],
|
|
542
|
-
outputRange: [1, 1 + Math.max(0, depth) * 0.035],
|
|
567
|
+
outputRange: [1, ctx.reduce ? 1 : 1 + Math.max(0, depth) * 0.035],
|
|
543
568
|
}),
|
|
544
569
|
},
|
|
545
570
|
],
|
|
@@ -616,6 +641,22 @@ export function TiltFoil({ children, radius = 8, base = '#d7dbe2', style }: Tilt
|
|
|
616
641
|
);
|
|
617
642
|
}
|
|
618
643
|
|
|
644
|
+
function useReduceMotion() {
|
|
645
|
+
const [reduce, setReduce] = useState(false);
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
let alive = true;
|
|
648
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
649
|
+
.then((r) => alive && setReduce(r))
|
|
650
|
+
.catch(() => {});
|
|
651
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
652
|
+
return () => {
|
|
653
|
+
alive = false;
|
|
654
|
+
sub.remove();
|
|
655
|
+
};
|
|
656
|
+
}, []);
|
|
657
|
+
return reduce;
|
|
658
|
+
}
|
|
659
|
+
|
|
619
660
|
const styles = StyleSheet.create({
|
|
620
661
|
plate: {
|
|
621
662
|
position: 'absolute',
|
|
@@ -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
|
};
|