@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
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
StyleSheet,
|
|
7
7
|
Text,
|
|
8
8
|
View,
|
|
9
|
+
useWindowDimensions,
|
|
9
10
|
type LayoutChangeEvent,
|
|
10
11
|
type StyleProp,
|
|
11
12
|
type TextStyle,
|
|
@@ -114,10 +115,10 @@ function nextStop(text: string, from: number) {
|
|
|
114
115
|
|
|
115
116
|
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
|
|
116
117
|
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
117
|
-
/** A soft
|
|
118
|
-
const soft = (t: number) => 1 - Math.exp(-
|
|
119
|
-
/** A firmer settle
|
|
120
|
-
const settle = (t: number) => 1 - Math.exp(-
|
|
118
|
+
/** A soft, critically damped settle (no overshoot), sampled as a curve so it runs on the native driver. */
|
|
119
|
+
const soft = (t: number) => 1 - Math.exp(-7 * t) * (1 + 7 * t);
|
|
120
|
+
/** A firmer settle, also without overshoot. */
|
|
121
|
+
const settle = (t: number) => 1 - Math.exp(-8 * t) * (1 + 8 * t);
|
|
121
122
|
|
|
122
123
|
/**
|
|
123
124
|
* Maps a millisecond clock onto `from → to` between `start` and `start + dur` through `ease`.
|
|
@@ -152,6 +153,7 @@ function useReduceMotion() {
|
|
|
152
153
|
|
|
153
154
|
interface Ctx {
|
|
154
155
|
glyph: StyleProp<TextStyle>;
|
|
156
|
+
/** Line height as rendered (after the user's text size). */
|
|
155
157
|
lh: number;
|
|
156
158
|
space: number;
|
|
157
159
|
justify: ViewStyle['justifyContent'];
|
|
@@ -584,6 +586,9 @@ export function AnimatedText({
|
|
|
584
586
|
}: AnimatedTextProps) {
|
|
585
587
|
const { colors, font, text: type } = useKit();
|
|
586
588
|
const reduce = useReduceMotion();
|
|
589
|
+
// Content text: it scales all the way with the user's text size, so the one-line clip windows,
|
|
590
|
+
// roll distances and word gaps (all derived from the line height) scale with it.
|
|
591
|
+
const { fontScale } = useWindowDimensions();
|
|
587
592
|
const text = String(children ?? '');
|
|
588
593
|
const mode = trigger ?? (variant === 'cascade' || variant === 'typewriter' ? 'change' : 'mount');
|
|
589
594
|
|
|
@@ -603,8 +608,8 @@ export function AnimatedText({
|
|
|
603
608
|
const layout = useMemo(() => tokenize(text), [text]);
|
|
604
609
|
const ctx: Ctx = {
|
|
605
610
|
glyph: [styles.glyph, { fontSize, fontFamily: font.family }, textStyle, { lineHeight: lh, color: tint }],
|
|
606
|
-
lh,
|
|
607
|
-
space: fontSize * 0.28,
|
|
611
|
+
lh: lh * fontScale,
|
|
612
|
+
space: fontSize * 0.28 * fontScale,
|
|
608
613
|
justify,
|
|
609
614
|
reduce,
|
|
610
615
|
live,
|
|
@@ -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,6 @@
|
|
|
1
|
-
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
1
|
+
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ComponentRef } from 'react';
|
|
2
2
|
import {
|
|
3
|
+
AccessibilityInfo,
|
|
3
4
|
Animated,
|
|
4
5
|
Easing,
|
|
5
6
|
Platform,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
Text,
|
|
9
10
|
TextInput,
|
|
10
11
|
View,
|
|
12
|
+
useWindowDimensions,
|
|
11
13
|
type StyleProp,
|
|
12
14
|
type TextInputProps,
|
|
13
15
|
type TextStyle,
|
|
@@ -79,6 +81,9 @@ export const TEXTAREA_SIZES = {
|
|
|
79
81
|
lg: { font: 18, line: 26, floated: 13.5, padX: 18, padY: 16 },
|
|
80
82
|
};
|
|
81
83
|
|
|
84
|
+
// Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
|
|
85
|
+
// content text scales all the way.
|
|
86
|
+
const FONT_CAP = 1.35;
|
|
82
87
|
const BORDER = 1.5;
|
|
83
88
|
const HALO = 4;
|
|
84
89
|
/** Font size of the static label used when `floatingLabel` is false. */
|
|
@@ -97,7 +102,7 @@ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|
|
97
102
|
* optional floating label, a focus halo, a counter that morphs into a progress ring near
|
|
98
103
|
* `maxLength`, and a `toolbar` slot for chat composers.
|
|
99
104
|
*/
|
|
100
|
-
export const Textarea = forwardRef<TextInput
|
|
105
|
+
export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps>(function Textarea(
|
|
101
106
|
{
|
|
102
107
|
label,
|
|
103
108
|
floatingLabel = true,
|
|
@@ -133,6 +138,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
133
138
|
ref
|
|
134
139
|
) {
|
|
135
140
|
const { colors, radius, font } = useKit();
|
|
141
|
+
const reduce = useReduceMotion();
|
|
136
142
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
137
143
|
const S = TEXTAREA_SIZES[size];
|
|
138
144
|
// Height is driven by rows, so the user's `height` is dropped; their border colour replaces the resting one.
|
|
@@ -142,17 +148,25 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
142
148
|
const FONT = typeof userInput.fontSize === 'number' ? userInput.fontSize : S.font;
|
|
143
149
|
const LINE = typeof userInput.lineHeight === 'number' ? userInput.lineHeight : Math.round((FONT * S.line) / S.font);
|
|
144
150
|
const corner = typeof userField.borderRadius === 'number' ? userField.borderRadius : radius.md;
|
|
151
|
+
// The typed text is content, so it scales with the system text size (lineHeight too). Row maths use
|
|
152
|
+
// the rendered line height, so rows, the resting label and the check stay on the lines.
|
|
153
|
+
const { fontScale } = useWindowDimensions();
|
|
154
|
+
const userCap = props.maxFontSizeMultiplier;
|
|
155
|
+
const textScale = props.allowFontScaling === false ? 1 : userCap && userCap >= 1 ? Math.min(fontScale, userCap) : fontScale;
|
|
156
|
+
const ROW = LINE * textScale;
|
|
145
157
|
// The hidden mirror must measure text exactly like the input does.
|
|
146
|
-
const metrics: TextStyle = {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
const metrics: TextStyle = {
|
|
159
|
+
...ff,
|
|
160
|
+
...(userInput.fontFamily !== undefined ? { fontFamily: userInput.fontFamily } : null),
|
|
161
|
+
...(userInput.fontWeight !== undefined ? { fontWeight: userInput.fontWeight } : null),
|
|
162
|
+
...(userInput.fontStyle !== undefined ? { fontStyle: userInput.fontStyle } : null),
|
|
163
|
+
...(userInput.letterSpacing !== undefined ? { letterSpacing: userInput.letterSpacing } : null),
|
|
164
|
+
};
|
|
151
165
|
const tint = accent ?? colors.accent;
|
|
152
166
|
const bg = surface ?? colors.background;
|
|
153
167
|
const filled = variant === 'filled';
|
|
154
|
-
const inputRef = useRef<TextInput
|
|
155
|
-
useImperativeHandle(ref, () => inputRef.current as TextInput);
|
|
168
|
+
const inputRef = useRef<ComponentRef<typeof TextInput>>(null);
|
|
169
|
+
useImperativeHandle(ref, () => inputRef.current as ComponentRef<typeof TextInput>);
|
|
156
170
|
|
|
157
171
|
const [focused, setFocused] = useState(false);
|
|
158
172
|
const [inner, setInner] = useState(defaultValue ?? '');
|
|
@@ -171,11 +185,11 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
171
185
|
|
|
172
186
|
const lo = Math.max(1, minRows);
|
|
173
187
|
const hi = Math.max(lo, maxRows);
|
|
174
|
-
const lines = Math.round((contentH ??
|
|
188
|
+
const lines = Math.round((contentH ?? ROW) / ROW);
|
|
175
189
|
const rows = Math.min(hi, Math.max(lo, lines));
|
|
176
190
|
const padTop = filled && inLabel ? S.padY + 14 : S.padY;
|
|
177
191
|
const padBottom = toolbar ? 4 : filled ? S.padY - 2 : S.padY;
|
|
178
|
-
const height = padTop + rows *
|
|
192
|
+
const height = padTop + rows * ROW + padBottom + (toolbar ? toolbarH ?? 0 : 0) + BORDER * 2;
|
|
179
193
|
|
|
180
194
|
const grow = useRef(new Animated.Value(height)).current; // JS: height
|
|
181
195
|
const focus = useRef(new Animated.Value(0)).current; // JS: colours
|
|
@@ -186,35 +200,40 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
186
200
|
const settled = useRef(false);
|
|
187
201
|
|
|
188
202
|
useEffect(() => {
|
|
189
|
-
// Snap to the first measured size; glide on every change after that.
|
|
190
|
-
if (!settled.current) {
|
|
203
|
+
// Snap to the first measured size (and always with Reduce Motion); glide on every change after that.
|
|
204
|
+
if (!settled.current || reduce) {
|
|
191
205
|
grow.setValue(height);
|
|
192
206
|
if (contentH !== null && toolbarH !== null) settled.current = true;
|
|
193
207
|
return;
|
|
194
208
|
}
|
|
195
209
|
Animated.timing(grow, { toValue: height, duration: 200, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
|
|
196
|
-
}, [height, grow, contentH, toolbarH]);
|
|
210
|
+
}, [height, grow, contentH, toolbarH, reduce]);
|
|
197
211
|
|
|
198
212
|
useEffect(() => {
|
|
199
|
-
|
|
200
|
-
|
|
213
|
+
if (reduce) float.setValue(floating ? 1 : 0);
|
|
214
|
+
else Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
215
|
+
}, [floating, float, reduce]);
|
|
201
216
|
|
|
202
217
|
useEffect(() => {
|
|
203
218
|
Animated.timing(focus, { toValue: isFocused ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
// Reduce Motion: the halo only fades (its scale is pinned below).
|
|
220
|
+
if (reduce) Animated.timing(glow, { toValue: isFocused ? 1 : 0, duration: 150, useNativeDriver: true }).start();
|
|
221
|
+
else Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
222
|
+
}, [isFocused, focus, glow, reduce]);
|
|
206
223
|
|
|
207
224
|
useEffect(() => {
|
|
208
|
-
Animated.
|
|
209
|
-
|
|
225
|
+
if (reduce) Animated.timing(check, { toValue: ok ? 1 : 0, duration: 150, useNativeDriver: true }).start();
|
|
226
|
+
else Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
|
|
227
|
+
}, [ok, check, reduce]);
|
|
210
228
|
|
|
211
229
|
useEffect(() => {
|
|
212
|
-
|
|
230
|
+
// Reduce Motion: no shake; the red border and message still flag the error.
|
|
231
|
+
if (!error || reduce) return;
|
|
213
232
|
shake.setValue(0);
|
|
214
233
|
Animated.sequence(
|
|
215
234
|
[10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))
|
|
216
235
|
).start();
|
|
217
|
-
}, [error, shake]);
|
|
236
|
+
}, [error, shake, reduce]);
|
|
218
237
|
|
|
219
238
|
const setText = (next: string) => {
|
|
220
239
|
if (value === undefined) setInner(next);
|
|
@@ -235,7 +254,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
235
254
|
});
|
|
236
255
|
|
|
237
256
|
// Label rests on the first line; floats onto the border (outlined) or into the top padding (filled).
|
|
238
|
-
const restCenter = padTop +
|
|
257
|
+
const restCenter = padTop + ROW / 2;
|
|
239
258
|
const floatY = filled ? S.padY + 1 - restCenter : -restCenter - BORDER / 2;
|
|
240
259
|
const floatX = filled ? 0 : 12 - S.padX;
|
|
241
260
|
const floatScale = S.floated / S.font;
|
|
@@ -270,7 +289,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
270
289
|
borderRadius: corner + HALO,
|
|
271
290
|
backgroundColor: active,
|
|
272
291
|
opacity: glow.interpolate({ inputRange: [0, 1], outputRange: [0, 0.18], extrapolate: 'clamp' }),
|
|
273
|
-
transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [0.98, 1] }) }],
|
|
292
|
+
transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.98, 1] }) }],
|
|
274
293
|
},
|
|
275
294
|
]}
|
|
276
295
|
/>
|
|
@@ -290,6 +309,8 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
290
309
|
{/* Invisible mirror of the text: its height tells us how many lines to show, on every platform. */}
|
|
291
310
|
<View pointerEvents="none" style={[styles.mirrorBox, { left: S.padX - BORDER, right: S.padX - BORDER + (ok ? 28 : 0) }]}>
|
|
292
311
|
<Text
|
|
312
|
+
allowFontScaling={props.allowFontScaling}
|
|
313
|
+
maxFontSizeMultiplier={props.maxFontSizeMultiplier}
|
|
293
314
|
onLayout={(e) => setContentH(e.nativeEvent.layout.height)}
|
|
294
315
|
style={[styles.mirror, metrics, { fontSize: FONT, lineHeight: LINE }]}
|
|
295
316
|
>
|
|
@@ -348,7 +369,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
348
369
|
top: padTop,
|
|
349
370
|
left: S.padX - BORDER,
|
|
350
371
|
right: S.padX,
|
|
351
|
-
height:
|
|
372
|
+
height: ROW,
|
|
352
373
|
transform: [
|
|
353
374
|
{ translateY: float.interpolate({ inputRange: [0, 1], outputRange: [0, floatY] }) },
|
|
354
375
|
{ translateX: float.interpolate({ inputRange: [0, 1], outputRange: [0, floatX] }) },
|
|
@@ -358,6 +379,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
358
379
|
]}
|
|
359
380
|
>
|
|
360
381
|
<Animated.Text
|
|
382
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
361
383
|
numberOfLines={1}
|
|
362
384
|
style={[
|
|
363
385
|
styles.label,
|
|
@@ -382,12 +404,12 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
382
404
|
style={[
|
|
383
405
|
styles.check,
|
|
384
406
|
{
|
|
385
|
-
top: padTop - BORDER + (
|
|
407
|
+
top: padTop - BORDER + (ROW - checkSize) / 2,
|
|
386
408
|
right: S.padX - BORDER - 4,
|
|
387
409
|
opacity: check.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
388
410
|
transform: [
|
|
389
|
-
{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
|
|
390
|
-
{ rotate: check.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'] }) },
|
|
411
|
+
{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.3, 1] }) },
|
|
412
|
+
{ rotate: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? '0deg' : '-30deg', '0deg'] }) },
|
|
391
413
|
],
|
|
392
414
|
},
|
|
393
415
|
]}
|
|
@@ -403,9 +425,9 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
403
425
|
{message || counting ? (
|
|
404
426
|
<View style={styles.footer}>
|
|
405
427
|
<View style={styles.messageSlot}>
|
|
406
|
-
{message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} /> : null}
|
|
428
|
+
{message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} reduce={reduce} /> : null}
|
|
407
429
|
</View>
|
|
408
|
-
{counting ? <Counter length={text.length} max={maxLength} tint={tint} style={counterStyle} /> : null}
|
|
430
|
+
{counting ? <Counter length={text.length} max={maxLength} tint={tint} style={counterStyle} reduce={reduce} /> : null}
|
|
409
431
|
</View>
|
|
410
432
|
) : null}
|
|
411
433
|
</Animated.View>
|
|
@@ -414,13 +436,27 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
|
|
|
414
436
|
|
|
415
437
|
type MessageTone = 'error' | 'success' | 'helper';
|
|
416
438
|
|
|
417
|
-
/** Helper / error / success line. Error and success slide up and fade in. */
|
|
418
|
-
function FieldMessage({
|
|
439
|
+
/** Helper / error / success line. Error and success slide up and fade in (only fade with Reduce Motion). */
|
|
440
|
+
function FieldMessage({
|
|
441
|
+
text,
|
|
442
|
+
tone,
|
|
443
|
+
color,
|
|
444
|
+
style,
|
|
445
|
+
reduce,
|
|
446
|
+
}: {
|
|
447
|
+
text: string;
|
|
448
|
+
tone: MessageTone;
|
|
449
|
+
color: string;
|
|
450
|
+
style?: StyleProp<TextStyle>;
|
|
451
|
+
reduce: boolean;
|
|
452
|
+
}) {
|
|
419
453
|
const { font } = useKit();
|
|
420
454
|
const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
|
|
421
455
|
useEffect(() => {
|
|
422
|
-
if (tone
|
|
423
|
-
|
|
456
|
+
if (tone === 'helper') return;
|
|
457
|
+
if (reduce) Animated.timing(enter, { toValue: 1, duration: 150, useNativeDriver: true }).start();
|
|
458
|
+
else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
459
|
+
}, [tone, enter, reduce]);
|
|
424
460
|
|
|
425
461
|
return (
|
|
426
462
|
<Animated.View
|
|
@@ -428,7 +464,7 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
|
|
|
428
464
|
styles.message,
|
|
429
465
|
{
|
|
430
466
|
opacity: enter.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
431
|
-
transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }],
|
|
467
|
+
transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }],
|
|
432
468
|
},
|
|
433
469
|
]}
|
|
434
470
|
>
|
|
@@ -456,7 +492,19 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
|
|
|
456
492
|
}
|
|
457
493
|
|
|
458
494
|
/** "42/280", turning into a filling ring with the characters left once you get close. */
|
|
459
|
-
function Counter({
|
|
495
|
+
function Counter({
|
|
496
|
+
length,
|
|
497
|
+
max,
|
|
498
|
+
tint,
|
|
499
|
+
style,
|
|
500
|
+
reduce,
|
|
501
|
+
}: {
|
|
502
|
+
length: number;
|
|
503
|
+
max: number;
|
|
504
|
+
tint: string;
|
|
505
|
+
style?: StyleProp<TextStyle>;
|
|
506
|
+
reduce: boolean;
|
|
507
|
+
}) {
|
|
460
508
|
const { colors, font } = useKit();
|
|
461
509
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
462
510
|
const remaining = max - length;
|
|
@@ -469,19 +517,22 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
|
|
|
469
517
|
const progress = useRef(new Animated.Value(Math.min(1, ratio))).current; // JS (SVG prop)
|
|
470
518
|
|
|
471
519
|
useEffect(() => {
|
|
472
|
-
|
|
473
|
-
|
|
520
|
+
// Reduce Motion: cross-fade only (the ring's scale is pinned below).
|
|
521
|
+
if (reduce) Animated.timing(mode, { toValue: near ? 1 : 0, duration: 150, useNativeDriver: true }).start();
|
|
522
|
+
else Animated.spring(mode, { toValue: near ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
523
|
+
}, [near, mode, reduce]);
|
|
474
524
|
|
|
475
525
|
useEffect(() => {
|
|
476
|
-
|
|
477
|
-
|
|
526
|
+
if (reduce) progress.setValue(Math.min(1, ratio));
|
|
527
|
+
else Animated.spring(progress, { toValue: Math.min(1, ratio), useNativeDriver: false, ...motion.snappy }).start();
|
|
528
|
+
}, [ratio, progress, reduce]);
|
|
478
529
|
|
|
479
530
|
const limitHit = remaining <= 0;
|
|
480
531
|
useEffect(() => {
|
|
481
|
-
if (!limitHit) return;
|
|
532
|
+
if (!limitHit || reduce) return;
|
|
482
533
|
pulse.setValue(1.35);
|
|
483
534
|
Animated.spring(pulse, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
|
|
484
|
-
}, [limitHit, pulse]);
|
|
535
|
+
}, [limitHit, pulse, reduce]);
|
|
485
536
|
|
|
486
537
|
return (
|
|
487
538
|
<View
|
|
@@ -491,6 +542,7 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
|
|
|
491
542
|
accessibilityLabel={remaining >= 0 ? `${remaining} of ${max} characters left` : `${-remaining} characters over the ${max} limit`}
|
|
492
543
|
>
|
|
493
544
|
<Animated.Text
|
|
545
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
494
546
|
style={[
|
|
495
547
|
styles.count,
|
|
496
548
|
ff,
|
|
@@ -504,10 +556,12 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
|
|
|
504
556
|
<Animated.View
|
|
505
557
|
style={[
|
|
506
558
|
styles.ringRow,
|
|
507
|
-
{ opacity: mode, transform: [{ scale: mode.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }) }] },
|
|
559
|
+
{ opacity: mode, transform: [{ scale: mode.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.6, 1] }) }] },
|
|
508
560
|
]}
|
|
509
561
|
>
|
|
510
|
-
<Text style={[styles.count, ff, { color: colors.mutedForeground }, style, ratio >= 0.9 && { color }]}>
|
|
562
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.count, ff, { color: colors.mutedForeground }, style, ratio >= 0.9 && { color }]}>
|
|
563
|
+
{remaining}
|
|
564
|
+
</Text>
|
|
511
565
|
<Animated.View style={{ transform: [{ scale: pulse }] }}>
|
|
512
566
|
<Svg width={RING} height={RING}>
|
|
513
567
|
<Circle cx={RING / 2} cy={RING / 2} r={RING_R} stroke={colors.border} strokeWidth={RING_STROKE} fill="none" />
|
|
@@ -530,6 +584,22 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
|
|
|
530
584
|
);
|
|
531
585
|
}
|
|
532
586
|
|
|
587
|
+
function useReduceMotion() {
|
|
588
|
+
const [reduce, setReduce] = useState(false);
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
let alive = true;
|
|
591
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
592
|
+
.then((r) => alive && setReduce(r))
|
|
593
|
+
.catch(() => {});
|
|
594
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
595
|
+
return () => {
|
|
596
|
+
alive = false;
|
|
597
|
+
sub.remove();
|
|
598
|
+
};
|
|
599
|
+
}, []);
|
|
600
|
+
return reduce;
|
|
601
|
+
}
|
|
602
|
+
|
|
533
603
|
const styles = StyleSheet.create({
|
|
534
604
|
disabled: { opacity: 0.5 },
|
|
535
605
|
halo: { position: 'absolute', top: -HALO, left: -HALO, right: -HALO, bottom: -HALO },
|
|
@@ -547,7 +617,7 @@ const styles = StyleSheet.create({
|
|
|
547
617
|
message: { flexDirection: 'row', alignItems: 'flex-start', gap: 5 },
|
|
548
618
|
messageIcon: { marginTop: 1.5 },
|
|
549
619
|
helper: { flexShrink: 1, fontSize: 12.5, lineHeight: 17 },
|
|
550
|
-
counter: { minWidth: 56,
|
|
620
|
+
counter: { minWidth: 56, minHeight: RING, alignItems: 'flex-end', justifyContent: 'center' },
|
|
551
621
|
count: { fontSize: 12.5, fontVariant: ['tabular-nums'], fontWeight: '400' },
|
|
552
622
|
ringRow: { position: 'absolute', right: 0, flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
553
623
|
});
|
|
@@ -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
|
};
|
|
@@ -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,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',
|