@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,5 +1,6 @@
|
|
|
1
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;
|
|
@@ -270,11 +275,13 @@ export function TiltCard({
|
|
|
270
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',
|
|
@@ -53,6 +53,9 @@ const RING = {
|
|
|
53
53
|
lg: { d: 112, w: 9, font: 24 },
|
|
54
54
|
};
|
|
55
55
|
const WHITE = '#ffffff';
|
|
56
|
+
// Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
|
|
57
|
+
// content text scales all the way.
|
|
58
|
+
const FONT_CAP = 1.35;
|
|
56
59
|
|
|
57
60
|
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
|
58
61
|
|
|
@@ -838,9 +841,13 @@ function Ring({
|
|
|
838
841
|
],
|
|
839
842
|
}}
|
|
840
843
|
>
|
|
841
|
-
<Text style={[styles.ringValue, ff, { color: fg, fontSize: font }]}>
|
|
844
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.ringValue, ff, { color: fg, fontSize: font }]}>
|
|
842
845
|
{format ? format() : <RingPercent fill={fill} initial={ratio} />}
|
|
843
|
-
{format ? null :
|
|
846
|
+
{format ? null : (
|
|
847
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={{ fontSize: font * 0.6, color: alpha(fg, 0.55) }}>
|
|
848
|
+
%
|
|
849
|
+
</Text>
|
|
850
|
+
)}
|
|
844
851
|
</Text>
|
|
845
852
|
</Animated.View>
|
|
846
853
|
{done ? (
|
|
@@ -46,6 +46,9 @@ export interface ProgressStepsProps {
|
|
|
46
46
|
style?: StyleProp<ViewStyle>;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
|
|
50
|
+
// content text scales all the way.
|
|
51
|
+
const FONT_CAP = 1.35;
|
|
49
52
|
const NODE = 28;
|
|
50
53
|
const GAP = 6;
|
|
51
54
|
const LINE = 3;
|
|
@@ -157,7 +160,7 @@ export function ProgressSteps({
|
|
|
157
160
|
>
|
|
158
161
|
{done ? 'All done' : items[shown]!.label}
|
|
159
162
|
</Animated.Text>
|
|
160
|
-
<Text style={[styles.barStep, ff, { color: colors.mutedForeground }]}>
|
|
163
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.barStep, ff, { color: colors.mutedForeground }]}>
|
|
161
164
|
{done ? `${n} of ${n}` : `Step ${shown + 1} of ${n}`}
|
|
162
165
|
</Text>
|
|
163
166
|
</View>
|
|
@@ -318,7 +321,7 @@ function Step({
|
|
|
318
321
|
return (
|
|
319
322
|
<Wrapper {...a11y} {...pressProps} style={styles.hCol}>
|
|
320
323
|
<View style={styles.hTop}>{node}</View>
|
|
321
|
-
<Text numberOfLines={2} style={[styles.hLabel, ff, { color: labelColor }]}>
|
|
324
|
+
<Text maxFontSizeMultiplier={FONT_CAP} numberOfLines={2} style={[styles.hLabel, ff, { color: labelColor }]}>
|
|
322
325
|
{step.label}
|
|
323
326
|
</Text>
|
|
324
327
|
{step.description ? (
|
|
@@ -374,7 +377,9 @@ function Node({
|
|
|
374
377
|
<View
|
|
375
378
|
style={[styles.node, round, { borderWidth: 1.5, borderColor: alpha(colors.foreground, dark ? 0.22 : 0.16) }]}
|
|
376
379
|
>
|
|
377
|
-
<Text style={[styles.num, ff, { color: colors.mutedForeground }]}>
|
|
380
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, ff, { color: colors.mutedForeground }]}>
|
|
381
|
+
{i + 1}
|
|
382
|
+
</Text>
|
|
378
383
|
</View>
|
|
379
384
|
|
|
380
385
|
{/* Current: soft halo, a ring that breathes outwards, accent ring with a tinted core. */}
|
|
@@ -415,7 +420,9 @@ function Node({
|
|
|
415
420
|
]}
|
|
416
421
|
>
|
|
417
422
|
<View style={[StyleSheet.absoluteFill, round, { backgroundColor: alpha(color, 0.12) }]} />
|
|
418
|
-
<Text style={[styles.num, ff, { color }]}>
|
|
423
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, ff, { color }]}>
|
|
424
|
+
{i + 1}
|
|
425
|
+
</Text>
|
|
419
426
|
</Animated.View>
|
|
420
427
|
|
|
421
428
|
{/* Done: solid disc that pops in, then the check draws itself. */}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as Haptics from 'expo-haptics';
|
|
2
2
|
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
-
import { Animated, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { AccessibilityInfo, Animated, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
4
4
|
import Svg, { Path } from 'react-native-svg';
|
|
5
5
|
|
|
6
6
|
import { alpha, motion, readableOn, useKit } from '@/lib/kit';
|
|
@@ -58,6 +58,7 @@ export function RadioGroup({
|
|
|
58
58
|
style,
|
|
59
59
|
}: RadioGroupProps) {
|
|
60
60
|
const { colors, radius } = useKit();
|
|
61
|
+
const reduce = useReduceMotion();
|
|
61
62
|
const tint = accent ?? colors.accent;
|
|
62
63
|
const [inner, setInner] = useState(defaultValue);
|
|
63
64
|
const selected = value ?? inner;
|
|
@@ -95,6 +96,7 @@ export function RadioGroup({
|
|
|
95
96
|
first={i === 0}
|
|
96
97
|
tint={tint}
|
|
97
98
|
disabled={disabled || o.disabled}
|
|
99
|
+
reduce={reduce}
|
|
98
100
|
/>
|
|
99
101
|
))}
|
|
100
102
|
</View>
|
|
@@ -110,9 +112,10 @@ interface RadioItemProps {
|
|
|
110
112
|
first: boolean;
|
|
111
113
|
tint: string;
|
|
112
114
|
disabled?: boolean;
|
|
115
|
+
reduce: boolean;
|
|
113
116
|
}
|
|
114
117
|
|
|
115
|
-
function RadioItem({ option, active, onPress, variant, horizontal, first, tint, disabled }: RadioItemProps) {
|
|
118
|
+
function RadioItem({ option, active, onPress, variant, horizontal, first, tint, disabled, reduce }: RadioItemProps) {
|
|
116
119
|
const { colors, radius, font } = useKit();
|
|
117
120
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
118
121
|
// p: colours + svg (JS driver). dot: scale/opacity pops (native). press: press feedback (native).
|
|
@@ -121,17 +124,24 @@ function RadioItem({ option, active, onPress, variant, horizontal, first, tint,
|
|
|
121
124
|
const dot = useRef(new Animated.Value(active ? 1 : 0)).current;
|
|
122
125
|
const press = useRef(new Animated.Value(1)).current;
|
|
123
126
|
const ripple = useRef(new Animated.Value(1)).current;
|
|
124
|
-
const
|
|
127
|
+
const was = useRef(active);
|
|
125
128
|
|
|
126
129
|
useEffect(() => {
|
|
130
|
+
const picked = active && !was.current;
|
|
131
|
+
was.current = active;
|
|
132
|
+
// Reduce Motion: colours and the check still fade in, the dot appears at once, no ripple.
|
|
133
|
+
if (reduce) {
|
|
134
|
+
Animated.timing(p, { toValue: active ? 1 : 0, duration: 150, useNativeDriver: false }).start();
|
|
135
|
+
dot.setValue(active ? 1 : 0);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
127
138
|
Animated.spring(p, { toValue: active ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
|
|
128
139
|
Animated.spring(dot, { toValue: active ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
|
|
129
|
-
if (
|
|
140
|
+
if (picked) {
|
|
130
141
|
ripple.setValue(0);
|
|
131
142
|
Animated.timing(ripple, { toValue: 1, duration: 560, useNativeDriver: true }).start();
|
|
132
143
|
}
|
|
133
|
-
|
|
134
|
-
}, [active, p, dot, ripple]);
|
|
144
|
+
}, [active, reduce, p, dot, ripple]);
|
|
135
145
|
|
|
136
146
|
const rippleStyle = {
|
|
137
147
|
backgroundColor: alpha(tint, 0.35),
|
|
@@ -139,8 +149,10 @@ function RadioItem({ option, active, onPress, variant, horizontal, first, tint,
|
|
|
139
149
|
transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [1, 2.3] }) }],
|
|
140
150
|
};
|
|
141
151
|
|
|
142
|
-
const pressTo = (to: number) =>
|
|
152
|
+
const pressTo = (to: number) => {
|
|
153
|
+
if (reduce) return press.setValue(1);
|
|
143
154
|
Animated.spring(press, { toValue: to, useNativeDriver: true, ...(to < 1 ? motion.snappy : motion.bouncy) }).start();
|
|
155
|
+
};
|
|
144
156
|
|
|
145
157
|
const fg = colors.foreground;
|
|
146
158
|
const muted = colors.mutedForeground;
|
|
@@ -287,7 +299,7 @@ function RadioItem({ option, active, onPress, variant, horizontal, first, tint,
|
|
|
287
299
|
</Animated.View>
|
|
288
300
|
</Animated.View>
|
|
289
301
|
{horizontal && !option.description ? (
|
|
290
|
-
<Text style={[styles.label, ff, { color: fg }]}>{option.label}</Text>
|
|
302
|
+
<Text style={[styles.label, styles.shrink, ff, { color: fg }]}>{option.label}</Text>
|
|
291
303
|
) : (
|
|
292
304
|
text
|
|
293
305
|
)}
|
|
@@ -295,13 +307,31 @@ function RadioItem({ option, active, onPress, variant, horizontal, first, tint,
|
|
|
295
307
|
);
|
|
296
308
|
}
|
|
297
309
|
|
|
310
|
+
function useReduceMotion() {
|
|
311
|
+
const [reduce, setReduce] = useState(false);
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
let alive = true;
|
|
314
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
315
|
+
.then((r) => alive && setReduce(r))
|
|
316
|
+
.catch(() => {});
|
|
317
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
318
|
+
return () => {
|
|
319
|
+
alive = false;
|
|
320
|
+
sub.remove();
|
|
321
|
+
};
|
|
322
|
+
}, []);
|
|
323
|
+
return reduce;
|
|
324
|
+
}
|
|
325
|
+
|
|
298
326
|
const styles = StyleSheet.create({
|
|
299
327
|
vertical: { flexDirection: 'column' },
|
|
300
328
|
horizontal: { flexDirection: 'row', flexWrap: 'wrap' },
|
|
301
329
|
flex: { flex: 1, minWidth: 0 },
|
|
302
330
|
flexGrow: { flexGrow: 1 },
|
|
303
331
|
row: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
|
|
304
|
-
|
|
332
|
+
// A long label wraps at a large text size instead of running past the edge.
|
|
333
|
+
rowCompact: { alignItems: 'center', flexShrink: 1 },
|
|
334
|
+
shrink: { flexShrink: 1 },
|
|
305
335
|
ring: { width: 22, height: 22, borderRadius: 11, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
|
|
306
336
|
dot: { width: 8, height: 8, borderRadius: 4 },
|
|
307
337
|
ringSlot: { alignItems: 'center', justifyContent: 'center' },
|
|
@@ -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,
|
|
@@ -59,6 +60,10 @@ export interface SearchBarProps extends Omit<TextInputProps, 'style'> {
|
|
|
59
60
|
style?: StyleProp<ViewStyle>;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
|
|
64
|
+
// content text scales all the way.
|
|
65
|
+
const FONT_CAP = 1.35;
|
|
66
|
+
|
|
62
67
|
/** Every dimension comes from one table so the sizes read clearly different. */
|
|
63
68
|
const SIZES: Record<SearchBarSize, { h: number; font: number; icon: number; slot: number; r: number; clear: number; end: number }> = {
|
|
64
69
|
sm: { h: 34, font: 14, icon: 14, slot: 30, r: 9, clear: 16, end: 6 },
|
|
@@ -105,6 +110,7 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
105
110
|
ref
|
|
106
111
|
) {
|
|
107
112
|
const { colors, radius, font } = useKit();
|
|
113
|
+
const reduce = useReduceMotion();
|
|
108
114
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
109
115
|
const s = SIZES[size];
|
|
110
116
|
const tint = accent ?? colors.accent;
|
|
@@ -140,28 +146,47 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
140
146
|
useEffect(() => {
|
|
141
147
|
const to = focused ? 1 : 0;
|
|
142
148
|
Animated.timing(focusJS, { toValue: to, duration: motion.fast, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
|
|
149
|
+
// Reduce Motion: the halo only fades (its scale is pinned below) and the magnifier doesn't pop.
|
|
150
|
+
if (reduce) {
|
|
151
|
+
Animated.timing(halo, { toValue: to, duration: 150, useNativeDriver: true }).start();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
143
154
|
Animated.spring(halo, { toValue: to, useNativeDriver: true, ...motion.snappy }).start();
|
|
144
155
|
if (focused) {
|
|
145
156
|
icon.setValue(0.6);
|
|
146
157
|
Animated.spring(icon, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
|
|
147
158
|
}
|
|
148
|
-
}, [focused, focusJS, halo, icon]);
|
|
159
|
+
}, [focused, focusJS, halo, icon, reduce]);
|
|
149
160
|
|
|
150
161
|
useEffect(() => {
|
|
151
162
|
const to = cancelShown ? 1 : 0;
|
|
163
|
+
// Reduce Motion: the field narrows at once and Cancel fades in place.
|
|
164
|
+
if (reduce) {
|
|
165
|
+
cancelJS.setValue(to);
|
|
166
|
+
Animated.timing(cancelN, { toValue: to, duration: 150, useNativeDriver: true }).start();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
152
169
|
Animated.spring(cancelJS, { toValue: to, useNativeDriver: false, ...motion.snappy }).start();
|
|
153
170
|
Animated.spring(cancelN, { toValue: to, useNativeDriver: true, ...motion.snappy }).start();
|
|
154
|
-
}, [cancelShown, cancelJS, cancelN]);
|
|
171
|
+
}, [cancelShown, cancelJS, cancelN, reduce]);
|
|
155
172
|
|
|
156
173
|
useEffect(() => {
|
|
157
174
|
const to = open ? 1 : 0;
|
|
175
|
+
// Reduce Motion: 'minimal' snaps to its new width; the contents still fade.
|
|
176
|
+
if (reduce) {
|
|
177
|
+
openJS.setValue(to);
|
|
178
|
+
Animated.timing(openN, { toValue: to, duration: 150, useNativeDriver: true }).start();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
158
181
|
Animated.spring(openJS, { toValue: to, useNativeDriver: false, ...motion.gentle }).start();
|
|
159
182
|
Animated.timing(openN, { toValue: to, duration: open ? motion.normal : motion.fast, delay: open ? 80 : 0, useNativeDriver: true }).start();
|
|
160
|
-
}, [open, openJS, openN]);
|
|
183
|
+
}, [open, openJS, openN, reduce]);
|
|
161
184
|
|
|
162
185
|
useEffect(() => {
|
|
163
|
-
|
|
164
|
-
|
|
186
|
+
const to = text && editable && open ? 1 : 0;
|
|
187
|
+
if (reduce) Animated.timing(clear, { toValue: to, duration: 150, useNativeDriver: true }).start();
|
|
188
|
+
else Animated.spring(clear, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
|
|
189
|
+
}, [text, editable, open, clear, reduce]);
|
|
165
190
|
|
|
166
191
|
useEffect(() => {
|
|
167
192
|
Animated.timing(busy, { toValue: loading ? 1 : 0, duration: motion.fast, useNativeDriver: true }).start();
|
|
@@ -230,7 +255,7 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
230
255
|
onLayout={(e) => setRowW(e.nativeEvent.layout.width)}
|
|
231
256
|
style={[styles.row, minimal && { justifyContent: align === 'right' ? 'flex-end' : 'flex-start' }]}
|
|
232
257
|
>
|
|
233
|
-
<Animated.View style={minimal ? { width: fieldW,
|
|
258
|
+
<Animated.View style={minimal ? { width: fieldW, minHeight: s.h } : [styles.flex, { minHeight: s.h }]}>
|
|
234
259
|
<Animated.View
|
|
235
260
|
pointerEvents="none"
|
|
236
261
|
style={[
|
|
@@ -239,7 +264,7 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
239
264
|
borderRadius: r + 4,
|
|
240
265
|
backgroundColor: alpha(tint, 0.18),
|
|
241
266
|
opacity: halo,
|
|
242
|
-
transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }) }],
|
|
267
|
+
transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.96, 1] }) }],
|
|
243
268
|
},
|
|
244
269
|
]}
|
|
245
270
|
/>
|
|
@@ -276,6 +301,7 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
276
301
|
autoCorrect={false}
|
|
277
302
|
accessibilityRole="search"
|
|
278
303
|
accessibilityLabel={props.accessibilityLabel ?? placeholder}
|
|
304
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
279
305
|
{...props}
|
|
280
306
|
autoFocus={autoFocus}
|
|
281
307
|
editable={editable}
|
|
@@ -294,12 +320,12 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
294
320
|
if (minimal && !text) setExpanded(false);
|
|
295
321
|
onBlur?.(e);
|
|
296
322
|
}}
|
|
297
|
-
style={[styles.input, ff, { color: colors.foreground, fontSize: s.font,
|
|
323
|
+
style={[styles.input, ff, { color: colors.foreground, fontSize: s.font, minHeight: s.h - 3 }, webNoOutline]}
|
|
298
324
|
/>
|
|
299
325
|
</Animated.View>
|
|
300
326
|
<Animated.View
|
|
301
327
|
pointerEvents={text && open ? 'auto' : 'none'}
|
|
302
|
-
style={[styles.clear, { width: s.clear + 8, opacity: clear, transform: [{ scale: clear }] }]}
|
|
328
|
+
style={[styles.clear, { width: s.clear + 8, opacity: clear, transform: [{ scale: reduce ? 1 : clear }] }]}
|
|
303
329
|
>
|
|
304
330
|
<Pressable
|
|
305
331
|
hitSlop={12}
|
|
@@ -342,6 +368,7 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
342
368
|
{cancelEnabled ? (
|
|
343
369
|
<>
|
|
344
370
|
<Text
|
|
371
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
345
372
|
pointerEvents="none"
|
|
346
373
|
onLayout={(e) => setCancelW(Math.ceil(e.nativeEvent.layout.width) + 14)}
|
|
347
374
|
style={[styles.cancelText, styles.measure, ff, { fontSize: s.font }]}
|
|
@@ -357,7 +384,7 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
357
384
|
{
|
|
358
385
|
width: cancelW,
|
|
359
386
|
opacity: cancelN,
|
|
360
|
-
transform: [{ translateX: cancelN.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }],
|
|
387
|
+
transform: [{ translateX: cancelN.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 24, 0] }) }],
|
|
361
388
|
},
|
|
362
389
|
]}
|
|
363
390
|
>
|
|
@@ -367,7 +394,7 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
367
394
|
accessibilityLabel={cancelLabel}
|
|
368
395
|
onPress={cancel}
|
|
369
396
|
>
|
|
370
|
-
<Text numberOfLines={1} style={[styles.cancelText, ff, { color: tint, fontSize: s.font }]}>
|
|
397
|
+
<Text maxFontSizeMultiplier={FONT_CAP} numberOfLines={1} style={[styles.cancelText, ff, { color: tint, fontSize: s.font }]}>
|
|
371
398
|
{cancelLabel}
|
|
372
399
|
</Text>
|
|
373
400
|
</Pressable>
|
|
@@ -378,24 +405,33 @@ export const SearchBar = forwardRef<ComponentRef<typeof TextInput>, SearchBarPro
|
|
|
378
405
|
</View>
|
|
379
406
|
|
|
380
407
|
{suggestions ? (
|
|
381
|
-
<SuggestionPanel
|
|
408
|
+
<SuggestionPanel
|
|
409
|
+
open={panelOpen && open && visible.length > 0}
|
|
410
|
+
items={visible}
|
|
411
|
+
query={query}
|
|
412
|
+
tint={tint}
|
|
413
|
+
reduce={reduce}
|
|
414
|
+
onSelect={select}
|
|
415
|
+
/>
|
|
382
416
|
) : null}
|
|
383
417
|
</View>
|
|
384
418
|
);
|
|
385
419
|
});
|
|
386
420
|
|
|
387
|
-
/** Dropdown list under the bar: drops in, rows show an icon, bolded match and a hint. */
|
|
421
|
+
/** Dropdown list under the bar: drops in (fades with Reduce Motion), rows show an icon, bolded match and a hint. */
|
|
388
422
|
function SuggestionPanel({
|
|
389
423
|
open,
|
|
390
424
|
items,
|
|
391
425
|
query,
|
|
392
426
|
tint,
|
|
427
|
+
reduce,
|
|
393
428
|
onSelect,
|
|
394
429
|
}: {
|
|
395
430
|
open: boolean;
|
|
396
431
|
items: SearchSuggestion[];
|
|
397
432
|
query: string;
|
|
398
433
|
tint: string;
|
|
434
|
+
reduce: boolean;
|
|
399
435
|
onSelect: (item: SearchSuggestion) => void;
|
|
400
436
|
}) {
|
|
401
437
|
const { colors, radius, font } = useKit();
|
|
@@ -408,13 +444,14 @@ function SuggestionPanel({
|
|
|
408
444
|
useEffect(() => {
|
|
409
445
|
if (open) {
|
|
410
446
|
setMounted(true);
|
|
411
|
-
Animated.
|
|
447
|
+
if (reduce) Animated.timing(enter, { toValue: 1, duration: 150, useNativeDriver: true }).start();
|
|
448
|
+
else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
412
449
|
} else {
|
|
413
450
|
Animated.timing(enter, { toValue: 0, duration: motion.fast, useNativeDriver: true }).start(({ finished }) => {
|
|
414
451
|
if (finished) setMounted(false);
|
|
415
452
|
});
|
|
416
453
|
}
|
|
417
|
-
}, [open, enter]);
|
|
454
|
+
}, [open, enter, reduce]);
|
|
418
455
|
|
|
419
456
|
if (!mounted) return null;
|
|
420
457
|
return (
|
|
@@ -428,8 +465,8 @@ function SuggestionPanel({
|
|
|
428
465
|
borderRadius: radius.lg,
|
|
429
466
|
opacity: enter,
|
|
430
467
|
transform: [
|
|
431
|
-
{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [-6, 0] }) },
|
|
432
|
-
{ scale: enter.interpolate({ inputRange: [0, 1], outputRange: [0.98, 1] }) },
|
|
468
|
+
{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : -6, 0] }) },
|
|
469
|
+
{ scale: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.98, 1] }) },
|
|
433
470
|
],
|
|
434
471
|
},
|
|
435
472
|
]}
|
|
@@ -498,6 +535,22 @@ function Icon({ name, size, color }: { name: 'search' | 'recent' | 'trending'; s
|
|
|
498
535
|
);
|
|
499
536
|
}
|
|
500
537
|
|
|
538
|
+
function useReduceMotion() {
|
|
539
|
+
const [reduce, setReduce] = useState(false);
|
|
540
|
+
useEffect(() => {
|
|
541
|
+
let alive = true;
|
|
542
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
543
|
+
.then((r) => alive && setReduce(r))
|
|
544
|
+
.catch(() => {});
|
|
545
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
546
|
+
return () => {
|
|
547
|
+
alive = false;
|
|
548
|
+
sub.remove();
|
|
549
|
+
};
|
|
550
|
+
}, []);
|
|
551
|
+
return reduce;
|
|
552
|
+
}
|
|
553
|
+
|
|
501
554
|
const styles = StyleSheet.create({
|
|
502
555
|
root: { alignSelf: 'stretch' },
|
|
503
556
|
disabled: { opacity: 0.5 },
|