@expokit/cli 0.4.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/bin/lib.mjs +1 -1
- package/package.json +1 -1
- package/registry/_docs/accordion.json +1 -1
- package/registry/_docs/avatar.json +6 -6
- package/registry/_docs/badge.json +3 -3
- 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 +2 -2
- package/registry/_docs/button.json +2 -2
- package/registry/_docs/calendar.json +5 -5
- package/registry/_docs/card.json +1 -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/drawer.json +4 -4
- package/registry/_docs/flip-card.json +4 -4
- package/registry/_docs/floating-input.json +1 -1
- package/registry/_docs/grain.json +2 -2
- package/registry/_docs/isometric-bar.json +6 -0
- package/registry/_docs/liquidglass-tab.json +1 -1
- package/registry/_docs/otp-input.json +1 -1
- 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/radio-group.json +1 -1
- package/registry/_docs/search-bar.json +3 -3
- package/registry/_docs/segmented-control.json +1 -1
- package/registry/_docs/select.json +6 -6
- package/registry/_docs/skeleton.json +1 -1
- package/registry/_docs/slider.json +6 -6
- package/registry/_docs/spinner.json +2 -2
- package/registry/_docs/stateful-button.json +1 -1
- package/registry/_docs/stepper.json +3 -3
- package/registry/_docs/tabs.json +2 -2
- package/registry/_docs/text-animation.json +4 -4
- package/registry/_docs/textarea.json +1 -1
- package/registry/_docs/tilt-card.json +3 -3
- package/registry/_docs/time-picker.json +6 -6
- package/registry/_docs/timeline-chart.json +6 -0
- package/registry/_docs/timeline.json +4 -4
- package/registry/_docs/treemap.json +6 -0
- package/registry/_docs/waterfall.json +6 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +31 -3
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +4 -1
- package/registry/alert/files/src/components/ui/alert.tsx +5 -2
- package/registry/alert/files/src/components/ui/button.tsx +32 -4
- package/registry/badge/files/src/components/ui/badge.tsx +2 -2
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -5
- package/registry/button/files/src/components/ui/button.tsx +32 -4
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +33 -6
- package/registry/calendar/files/src/components/ui/calendar.tsx +62 -17
- package/registry/card/files/src/components/ui/card.tsx +25 -5
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +26 -4
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +35 -5
- package/registry/coupon/files/src/components/ui/coupon.tsx +45 -21
- package/registry/coupon/files/src/components/ui/flip-card.tsx +22 -3
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +57 -16
- package/registry/date-picker/files/src/components/ui/calendar.tsx +62 -17
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +96 -42
- package/registry/dialog/files/src/components/ui/dialog.tsx +9 -5
- package/registry/drawer/files/src/components/ui/drawer.tsx +8 -4
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +22 -3
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +75 -26
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -1
- package/registry/list-item/files/src/components/ui/list-item.tsx +10 -3
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +50 -19
- package/registry/password-input/files/src/components/ui/floating-input.tsx +75 -26
- package/registry/password-input/files/src/components/ui/password-input.tsx +53 -19
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +22 -3
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +19 -15
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +57 -16
- package/registry/progress/files/src/components/ui/progress.tsx +9 -2
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +11 -4
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +39 -9
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +70 -17
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +37 -9
- package/registry/select/files/src/components/ui/select.tsx +158 -83
- package/registry/slider/files/src/components/ui/slider.tsx +53 -25
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
- package/registry/stepper/files/src/components/ui/stepper.tsx +76 -26
- package/registry/switch/files/src/components/ui/switch.tsx +56 -19
- package/registry/tabs/files/src/components/ui/tabs.tsx +7 -4
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +7 -2
- package/registry/textarea/files/src/components/ui/textarea.tsx +104 -36
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +57 -16
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +154 -73
- package/registry/timeline/files/src/components/ui/timeline.tsx +9 -4
- package/registry/toast/files/src/components/ui/button.tsx +32 -4
- package/registry/toast/files/src/components/ui/toast.tsx +15 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as Haptics from 'expo-haptics';
|
|
2
2
|
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { Animated, PanResponder, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { AccessibilityInfo, Animated, PanResponder, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
4
4
|
import Svg, { Circle, Defs, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
|
|
5
5
|
|
|
6
6
|
import { alpha, motion, readableOn, useKit } from '@/lib/kit';
|
|
@@ -66,6 +66,7 @@ export function Switch({
|
|
|
66
66
|
const { colors, dark, font } = useKit();
|
|
67
67
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
68
68
|
const gid = 'sw' + useId().replace(/[^a-zA-Z0-9]/g, '');
|
|
69
|
+
const reduce = useReduceMotion();
|
|
69
70
|
const tint = accent ?? colors.accent;
|
|
70
71
|
const [inner, setInner] = useState(defaultValue);
|
|
71
72
|
const on = value ?? inner;
|
|
@@ -88,18 +89,21 @@ export function Switch({
|
|
|
88
89
|
const stretch = useRef(new Animated.Value(0)).current;
|
|
89
90
|
const glyph = useRef(new Animated.Value(on ? 1 : 0)).current;
|
|
90
91
|
const halo = useRef(new Animated.Value(0)).current;
|
|
92
|
+
// Reduce Motion: the thumb jumps, so the colours follow this instead and still fade (JS driver).
|
|
93
|
+
const tone = useRef(new Animated.Value(on ? 1 : 0)).current;
|
|
91
94
|
|
|
92
95
|
useEffect(() => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
const to = on ? 1 : 0;
|
|
97
|
+
if (reduce) {
|
|
98
|
+
pos.setValue(to);
|
|
99
|
+
glyph.setValue(to);
|
|
100
|
+
Animated.timing(tone, { toValue: to, duration: 150, useNativeDriver: false }).start();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
tone.setValue(to);
|
|
104
|
+
Animated.spring(pos, { toValue: to, useNativeDriver: false, ...motion.snappy }).start();
|
|
105
|
+
Animated.spring(glyph, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
|
|
106
|
+
}, [on, reduce, pos, glyph, tone]);
|
|
103
107
|
|
|
104
108
|
const toggle = () => {
|
|
105
109
|
if (haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
|
@@ -114,8 +118,24 @@ export function Switch({
|
|
|
114
118
|
// Drag the thumb across: pos follows the finger, release settles on the nearer side.
|
|
115
119
|
// A touch that never moves is a tap and just toggles. On web the wrapping Pressable also
|
|
116
120
|
// gets a click after the gesture, so it ignores presses right after one (`endedAt`).
|
|
117
|
-
const live = useRef({ on, toggle, travel: c1 - c0 });
|
|
118
|
-
live.current = { on, toggle, travel: c1 - c0 };
|
|
121
|
+
const live = useRef({ on, toggle, travel: c1 - c0, reduce });
|
|
122
|
+
live.current = { on, toggle, travel: c1 - c0, reduce };
|
|
123
|
+
|
|
124
|
+
// Halo: 0 hidden · 1 held (glow around the thumb) · 2 released (expands and fades out).
|
|
125
|
+
// Reduce Motion: no squash & stretch, and the halo only fades (its scale is pinned below).
|
|
126
|
+
const press = (to: number) => {
|
|
127
|
+
const still = live.current.reduce;
|
|
128
|
+
if (!still) Animated.spring(stretch, { toValue: to, useNativeDriver: false, ...(to ? motion.snappy : motion.bouncy) }).start();
|
|
129
|
+
if (to) Animated.spring(halo, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
130
|
+
else
|
|
131
|
+
Animated.timing(halo, { toValue: 2, duration: still ? 150 : 420, useNativeDriver: true }).start(
|
|
132
|
+
({ finished }) => finished && halo.setValue(0)
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
const settle = (to: number) => {
|
|
136
|
+
if (live.current.reduce) pos.setValue(to);
|
|
137
|
+
else Animated.spring(pos, { toValue: to, useNativeDriver: false, ...motion.snappy }).start();
|
|
138
|
+
};
|
|
119
139
|
const drag = useRef({ from: 0, moved: false, endedAt: 0 });
|
|
120
140
|
const responder = useMemo(
|
|
121
141
|
() =>
|
|
@@ -138,12 +158,12 @@ export function Switch({
|
|
|
138
158
|
press(0);
|
|
139
159
|
if (!drag.current.moved) return flip();
|
|
140
160
|
const next = clamp01(drag.current.from + g.dx / live.current.travel) > 0.5;
|
|
141
|
-
|
|
161
|
+
settle(next ? 1 : 0);
|
|
142
162
|
if (next !== now) flip();
|
|
143
163
|
},
|
|
144
164
|
onPanResponderTerminate: () => {
|
|
145
165
|
press(0);
|
|
146
|
-
|
|
166
|
+
settle(live.current.on ? 1 : 0);
|
|
147
167
|
},
|
|
148
168
|
}),
|
|
149
169
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -164,12 +184,13 @@ export function Switch({
|
|
|
164
184
|
Animated.multiply(stretch, growH / 2)
|
|
165
185
|
);
|
|
166
186
|
|
|
187
|
+
const paint = reduce ? tone : pos;
|
|
167
188
|
const offTrack = material ? alpha(colors.mutedForeground, 0.12) : alpha(colors.foreground, dark ? 0.16 : 0.1);
|
|
168
|
-
const trackBg =
|
|
169
|
-
const trackBorder =
|
|
189
|
+
const trackBg = paint.interpolate({ inputRange: [0, 1], outputRange: [offTrack, tint], extrapolate: 'clamp' });
|
|
190
|
+
const trackBorder = paint.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, tint], extrapolate: 'clamp' });
|
|
170
191
|
const onFg = readableOn(tint);
|
|
171
192
|
const thumbBg = material
|
|
172
|
-
?
|
|
193
|
+
? paint.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, onFg], extrapolate: 'clamp' })
|
|
173
194
|
: '#ffffff';
|
|
174
195
|
|
|
175
196
|
const iconSize = dOn * 0.62;
|
|
@@ -222,7 +243,7 @@ export function Switch({
|
|
|
222
243
|
borderRadius: haloSize / 2,
|
|
223
244
|
backgroundColor: alpha(on ? tint : colors.mutedForeground, 0.2),
|
|
224
245
|
opacity: halo.interpolate({ inputRange: [0, 1, 2], outputRange: [0, 1, 0] }),
|
|
225
|
-
transform: [{ scale: halo.interpolate({ inputRange: [0, 1, 2], outputRange: [0.5, 1, 1.45] }) }],
|
|
246
|
+
transform: [{ scale: halo.interpolate({ inputRange: [0, 1, 2], outputRange: reduce ? [1, 1, 1] : [0.5, 1, 1.45] }) }],
|
|
226
247
|
},
|
|
227
248
|
]}
|
|
228
249
|
/>
|
|
@@ -324,6 +345,22 @@ export function Switch({
|
|
|
324
345
|
);
|
|
325
346
|
}
|
|
326
347
|
|
|
348
|
+
function useReduceMotion() {
|
|
349
|
+
const [reduce, setReduce] = useState(false);
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
let alive = true;
|
|
352
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
353
|
+
.then((r) => alive && setReduce(r))
|
|
354
|
+
.catch(() => {});
|
|
355
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
356
|
+
return () => {
|
|
357
|
+
alive = false;
|
|
358
|
+
sub.remove();
|
|
359
|
+
};
|
|
360
|
+
}, []);
|
|
361
|
+
return reduce;
|
|
362
|
+
}
|
|
363
|
+
|
|
327
364
|
const styles = StyleSheet.create({
|
|
328
365
|
row: { flexDirection: 'row', alignItems: 'center', gap: 16, minHeight: 44 },
|
|
329
366
|
text: { flex: 1 },
|
|
@@ -66,6 +66,9 @@ type Box = { x: number; w: number };
|
|
|
66
66
|
const LEAD = motion.snappy;
|
|
67
67
|
const TRAIL = { damping: 24, stiffness: 150, mass: 0.9 };
|
|
68
68
|
const UNDERLINE = 3;
|
|
69
|
+
// Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
|
|
70
|
+
// content text scales all the way.
|
|
71
|
+
const FONT_CAP = 1.35;
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* Underline tabs with swipeable content panes. The underline is measured to each label and moves
|
|
@@ -455,7 +458,7 @@ function Tab({ item, active, mode, height, fontSize, tint, reduce, onPress, onLa
|
|
|
455
458
|
hitSlop={slop ? { top: slop, bottom: slop } : undefined}
|
|
456
459
|
style={[
|
|
457
460
|
styles.tab,
|
|
458
|
-
{ height, paddingHorizontal: mode === 'fill' ? 8 : 14, opacity: item.disabled ? 0.4 : 1 },
|
|
461
|
+
{ minHeight: height, paddingHorizontal: mode === 'fill' ? 8 : 14, opacity: item.disabled ? 0.4 : 1 },
|
|
459
462
|
mode === 'fill' && styles.fill,
|
|
460
463
|
mode === 'auto' && styles.grow,
|
|
461
464
|
]}
|
|
@@ -463,7 +466,7 @@ function Tab({ item, active, mode, height, fontSize, tint, reduce, onPress, onLa
|
|
|
463
466
|
{/* Measured: the underline spans exactly this (icon + label). */}
|
|
464
467
|
<Animated.View onLayout={onLabelLayout} style={[styles.labelRow, { transform: [{ scale: press }] }]}>
|
|
465
468
|
{icon}
|
|
466
|
-
<Animated.Text numberOfLines={1} style={[styles.label, ff, { color, fontSize }]}>
|
|
469
|
+
<Animated.Text maxFontSizeMultiplier={FONT_CAP} numberOfLines={1} style={[styles.label, ff, { color, fontSize }]}>
|
|
467
470
|
{item.label}
|
|
468
471
|
</Animated.Text>
|
|
469
472
|
</Animated.View>
|
|
@@ -498,7 +501,7 @@ function TabBadge({ value, active, tint, reduce }: { value: number | string; act
|
|
|
498
501
|
},
|
|
499
502
|
]}
|
|
500
503
|
>
|
|
501
|
-
<Animated.Text style={[styles.badgeText, ff, { color: active ? readableOn(tint) : colors.mutedForeground }]}>{text}</Animated.Text>
|
|
504
|
+
<Animated.Text maxFontSizeMultiplier={FONT_CAP} style={[styles.badgeText, ff, { color: active ? readableOn(tint) : colors.mutedForeground }]}>{text}</Animated.Text>
|
|
502
505
|
</Animated.View>
|
|
503
506
|
);
|
|
504
507
|
}
|
|
@@ -533,7 +536,7 @@ const styles = StyleSheet.create({
|
|
|
533
536
|
labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
534
537
|
label: { fontWeight: '400', letterSpacing: -0.1 },
|
|
535
538
|
underline: { position: 'absolute', bottom: 0, height: UNDERLINE },
|
|
536
|
-
badge: { minWidth: 18,
|
|
539
|
+
badge: { minWidth: 18, minHeight: 18, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' },
|
|
537
540
|
badgeText: { fontSize: 11, fontWeight: '400', fontVariant: ['tabular-nums'] },
|
|
538
541
|
viewport: { overflow: 'hidden' },
|
|
539
542
|
pager: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
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. */
|
|
@@ -133,6 +138,7 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
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,6 +148,12 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
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
158
|
const metrics: TextStyle = {
|
|
147
159
|
...ff,
|
|
@@ -173,11 +185,11 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
173
185
|
|
|
174
186
|
const lo = Math.max(1, minRows);
|
|
175
187
|
const hi = Math.max(lo, maxRows);
|
|
176
|
-
const lines = Math.round((contentH ??
|
|
188
|
+
const lines = Math.round((contentH ?? ROW) / ROW);
|
|
177
189
|
const rows = Math.min(hi, Math.max(lo, lines));
|
|
178
190
|
const padTop = filled && inLabel ? S.padY + 14 : S.padY;
|
|
179
191
|
const padBottom = toolbar ? 4 : filled ? S.padY - 2 : S.padY;
|
|
180
|
-
const height = padTop + rows *
|
|
192
|
+
const height = padTop + rows * ROW + padBottom + (toolbar ? toolbarH ?? 0 : 0) + BORDER * 2;
|
|
181
193
|
|
|
182
194
|
const grow = useRef(new Animated.Value(height)).current; // JS: height
|
|
183
195
|
const focus = useRef(new Animated.Value(0)).current; // JS: colours
|
|
@@ -188,35 +200,40 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
188
200
|
const settled = useRef(false);
|
|
189
201
|
|
|
190
202
|
useEffect(() => {
|
|
191
|
-
// Snap to the first measured size; glide on every change after that.
|
|
192
|
-
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) {
|
|
193
205
|
grow.setValue(height);
|
|
194
206
|
if (contentH !== null && toolbarH !== null) settled.current = true;
|
|
195
207
|
return;
|
|
196
208
|
}
|
|
197
209
|
Animated.timing(grow, { toValue: height, duration: 200, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
|
|
198
|
-
}, [height, grow, contentH, toolbarH]);
|
|
210
|
+
}, [height, grow, contentH, toolbarH, reduce]);
|
|
199
211
|
|
|
200
212
|
useEffect(() => {
|
|
201
|
-
|
|
202
|
-
|
|
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]);
|
|
203
216
|
|
|
204
217
|
useEffect(() => {
|
|
205
218
|
Animated.timing(focus, { toValue: isFocused ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
|
|
206
|
-
|
|
207
|
-
|
|
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]);
|
|
208
223
|
|
|
209
224
|
useEffect(() => {
|
|
210
|
-
Animated.
|
|
211
|
-
|
|
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]);
|
|
212
228
|
|
|
213
229
|
useEffect(() => {
|
|
214
|
-
|
|
230
|
+
// Reduce Motion: no shake; the red border and message still flag the error.
|
|
231
|
+
if (!error || reduce) return;
|
|
215
232
|
shake.setValue(0);
|
|
216
233
|
Animated.sequence(
|
|
217
234
|
[10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))
|
|
218
235
|
).start();
|
|
219
|
-
}, [error, shake]);
|
|
236
|
+
}, [error, shake, reduce]);
|
|
220
237
|
|
|
221
238
|
const setText = (next: string) => {
|
|
222
239
|
if (value === undefined) setInner(next);
|
|
@@ -237,7 +254,7 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
237
254
|
});
|
|
238
255
|
|
|
239
256
|
// Label rests on the first line; floats onto the border (outlined) or into the top padding (filled).
|
|
240
|
-
const restCenter = padTop +
|
|
257
|
+
const restCenter = padTop + ROW / 2;
|
|
241
258
|
const floatY = filled ? S.padY + 1 - restCenter : -restCenter - BORDER / 2;
|
|
242
259
|
const floatX = filled ? 0 : 12 - S.padX;
|
|
243
260
|
const floatScale = S.floated / S.font;
|
|
@@ -272,7 +289,7 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
272
289
|
borderRadius: corner + HALO,
|
|
273
290
|
backgroundColor: active,
|
|
274
291
|
opacity: glow.interpolate({ inputRange: [0, 1], outputRange: [0, 0.18], extrapolate: 'clamp' }),
|
|
275
|
-
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] }) }],
|
|
276
293
|
},
|
|
277
294
|
]}
|
|
278
295
|
/>
|
|
@@ -292,6 +309,8 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
292
309
|
{/* Invisible mirror of the text: its height tells us how many lines to show, on every platform. */}
|
|
293
310
|
<View pointerEvents="none" style={[styles.mirrorBox, { left: S.padX - BORDER, right: S.padX - BORDER + (ok ? 28 : 0) }]}>
|
|
294
311
|
<Text
|
|
312
|
+
allowFontScaling={props.allowFontScaling}
|
|
313
|
+
maxFontSizeMultiplier={props.maxFontSizeMultiplier}
|
|
295
314
|
onLayout={(e) => setContentH(e.nativeEvent.layout.height)}
|
|
296
315
|
style={[styles.mirror, metrics, { fontSize: FONT, lineHeight: LINE }]}
|
|
297
316
|
>
|
|
@@ -350,7 +369,7 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
350
369
|
top: padTop,
|
|
351
370
|
left: S.padX - BORDER,
|
|
352
371
|
right: S.padX,
|
|
353
|
-
height:
|
|
372
|
+
height: ROW,
|
|
354
373
|
transform: [
|
|
355
374
|
{ translateY: float.interpolate({ inputRange: [0, 1], outputRange: [0, floatY] }) },
|
|
356
375
|
{ translateX: float.interpolate({ inputRange: [0, 1], outputRange: [0, floatX] }) },
|
|
@@ -360,6 +379,7 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
360
379
|
]}
|
|
361
380
|
>
|
|
362
381
|
<Animated.Text
|
|
382
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
363
383
|
numberOfLines={1}
|
|
364
384
|
style={[
|
|
365
385
|
styles.label,
|
|
@@ -384,12 +404,12 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
384
404
|
style={[
|
|
385
405
|
styles.check,
|
|
386
406
|
{
|
|
387
|
-
top: padTop - BORDER + (
|
|
407
|
+
top: padTop - BORDER + (ROW - checkSize) / 2,
|
|
388
408
|
right: S.padX - BORDER - 4,
|
|
389
409
|
opacity: check.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
390
410
|
transform: [
|
|
391
|
-
{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
|
|
392
|
-
{ 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'] }) },
|
|
393
413
|
],
|
|
394
414
|
},
|
|
395
415
|
]}
|
|
@@ -405,9 +425,9 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
405
425
|
{message || counting ? (
|
|
406
426
|
<View style={styles.footer}>
|
|
407
427
|
<View style={styles.messageSlot}>
|
|
408
|
-
{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}
|
|
409
429
|
</View>
|
|
410
|
-
{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}
|
|
411
431
|
</View>
|
|
412
432
|
) : null}
|
|
413
433
|
</Animated.View>
|
|
@@ -416,13 +436,27 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
416
436
|
|
|
417
437
|
type MessageTone = 'error' | 'success' | 'helper';
|
|
418
438
|
|
|
419
|
-
/** Helper / error / success line. Error and success slide up and fade in. */
|
|
420
|
-
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
|
+
}) {
|
|
421
453
|
const { font } = useKit();
|
|
422
454
|
const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
|
|
423
455
|
useEffect(() => {
|
|
424
|
-
if (tone
|
|
425
|
-
|
|
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]);
|
|
426
460
|
|
|
427
461
|
return (
|
|
428
462
|
<Animated.View
|
|
@@ -430,7 +464,7 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
|
|
|
430
464
|
styles.message,
|
|
431
465
|
{
|
|
432
466
|
opacity: enter.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
433
|
-
transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }],
|
|
467
|
+
transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }],
|
|
434
468
|
},
|
|
435
469
|
]}
|
|
436
470
|
>
|
|
@@ -458,7 +492,19 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
|
|
|
458
492
|
}
|
|
459
493
|
|
|
460
494
|
/** "42/280", turning into a filling ring with the characters left once you get close. */
|
|
461
|
-
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
|
+
}) {
|
|
462
508
|
const { colors, font } = useKit();
|
|
463
509
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
464
510
|
const remaining = max - length;
|
|
@@ -471,19 +517,22 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
|
|
|
471
517
|
const progress = useRef(new Animated.Value(Math.min(1, ratio))).current; // JS (SVG prop)
|
|
472
518
|
|
|
473
519
|
useEffect(() => {
|
|
474
|
-
|
|
475
|
-
|
|
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]);
|
|
476
524
|
|
|
477
525
|
useEffect(() => {
|
|
478
|
-
|
|
479
|
-
|
|
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]);
|
|
480
529
|
|
|
481
530
|
const limitHit = remaining <= 0;
|
|
482
531
|
useEffect(() => {
|
|
483
|
-
if (!limitHit) return;
|
|
532
|
+
if (!limitHit || reduce) return;
|
|
484
533
|
pulse.setValue(1.35);
|
|
485
534
|
Animated.spring(pulse, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
|
|
486
|
-
}, [limitHit, pulse]);
|
|
535
|
+
}, [limitHit, pulse, reduce]);
|
|
487
536
|
|
|
488
537
|
return (
|
|
489
538
|
<View
|
|
@@ -493,6 +542,7 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
|
|
|
493
542
|
accessibilityLabel={remaining >= 0 ? `${remaining} of ${max} characters left` : `${-remaining} characters over the ${max} limit`}
|
|
494
543
|
>
|
|
495
544
|
<Animated.Text
|
|
545
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
496
546
|
style={[
|
|
497
547
|
styles.count,
|
|
498
548
|
ff,
|
|
@@ -506,10 +556,12 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
|
|
|
506
556
|
<Animated.View
|
|
507
557
|
style={[
|
|
508
558
|
styles.ringRow,
|
|
509
|
-
{ 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] }) }] },
|
|
510
560
|
]}
|
|
511
561
|
>
|
|
512
|
-
<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>
|
|
513
565
|
<Animated.View style={{ transform: [{ scale: pulse }] }}>
|
|
514
566
|
<Svg width={RING} height={RING}>
|
|
515
567
|
<Circle cx={RING / 2} cy={RING / 2} r={RING_R} stroke={colors.border} strokeWidth={RING_STROKE} fill="none" />
|
|
@@ -532,6 +584,22 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
|
|
|
532
584
|
);
|
|
533
585
|
}
|
|
534
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
|
+
|
|
535
603
|
const styles = StyleSheet.create({
|
|
536
604
|
disabled: { opacity: 0.5 },
|
|
537
605
|
halo: { position: 'absolute', top: -HALO, left: -HALO, right: -HALO, bottom: -HALO },
|
|
@@ -549,7 +617,7 @@ const styles = StyleSheet.create({
|
|
|
549
617
|
message: { flexDirection: 'row', alignItems: 'flex-start', gap: 5 },
|
|
550
618
|
messageIcon: { marginTop: 1.5 },
|
|
551
619
|
helper: { flexShrink: 1, fontSize: 12.5, lineHeight: 17 },
|
|
552
|
-
counter: { minWidth: 56,
|
|
620
|
+
counter: { minWidth: 56, minHeight: RING, alignItems: 'flex-end', justifyContent: 'center' },
|
|
553
621
|
count: { fontSize: 12.5, fontVariant: ['tabular-nums'], fontWeight: '400' },
|
|
554
622
|
ringRow: { position: 'absolute', right: 0, flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
555
623
|
});
|