@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.
Files changed (92) hide show
  1. package/bin/lib.mjs +1 -1
  2. package/package.json +1 -1
  3. package/registry/_docs/accordion.json +1 -1
  4. package/registry/_docs/avatar.json +6 -6
  5. package/registry/_docs/badge.json +3 -3
  6. package/registry/_docs/bottom-nav.json +6 -6
  7. package/registry/_docs/bottom-sheet.json +3 -3
  8. package/registry/_docs/bullet.json +6 -0
  9. package/registry/_docs/button-3d.json +2 -2
  10. package/registry/_docs/button.json +2 -2
  11. package/registry/_docs/calendar.json +5 -5
  12. package/registry/_docs/card.json +1 -1
  13. package/registry/_docs/chip-group.json +1 -1
  14. package/registry/_docs/confetti.json +3 -3
  15. package/registry/_docs/date-picker.json +4 -4
  16. package/registry/_docs/drawer.json +4 -4
  17. package/registry/_docs/flip-card.json +4 -4
  18. package/registry/_docs/floating-input.json +1 -1
  19. package/registry/_docs/grain.json +2 -2
  20. package/registry/_docs/isometric-bar.json +6 -0
  21. package/registry/_docs/liquidglass-tab.json +1 -1
  22. package/registry/_docs/otp-input.json +1 -1
  23. package/registry/_docs/payment-card.json +3 -3
  24. package/registry/_docs/pixel-bar.json +6 -0
  25. package/registry/_docs/polar-area.json +6 -0
  26. package/registry/_docs/progress-steps.json +4 -4
  27. package/registry/_docs/progress.json +2 -2
  28. package/registry/_docs/radio-group.json +1 -1
  29. package/registry/_docs/search-bar.json +3 -3
  30. package/registry/_docs/segmented-control.json +1 -1
  31. package/registry/_docs/select.json +6 -6
  32. package/registry/_docs/skeleton.json +1 -1
  33. package/registry/_docs/slider.json +6 -6
  34. package/registry/_docs/spinner.json +2 -2
  35. package/registry/_docs/stateful-button.json +1 -1
  36. package/registry/_docs/stepper.json +3 -3
  37. package/registry/_docs/tabs.json +2 -2
  38. package/registry/_docs/text-animation.json +4 -4
  39. package/registry/_docs/textarea.json +1 -1
  40. package/registry/_docs/tilt-card.json +3 -3
  41. package/registry/_docs/time-picker.json +6 -6
  42. package/registry/_docs/timeline-chart.json +6 -0
  43. package/registry/_docs/timeline.json +4 -4
  44. package/registry/_docs/treemap.json +6 -0
  45. package/registry/_docs/waterfall.json +6 -0
  46. package/registry/accordion/files/src/components/ui/accordion.tsx +31 -3
  47. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +4 -1
  48. package/registry/alert/files/src/components/ui/alert.tsx +5 -2
  49. package/registry/alert/files/src/components/ui/button.tsx +32 -4
  50. package/registry/badge/files/src/components/ui/badge.tsx +2 -2
  51. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -5
  52. package/registry/button/files/src/components/ui/button.tsx +32 -4
  53. package/registry/button-3d/files/src/components/ui/button-3d.tsx +33 -6
  54. package/registry/calendar/files/src/components/ui/calendar.tsx +62 -17
  55. package/registry/card/files/src/components/ui/card.tsx +25 -5
  56. package/registry/checkbox/files/src/components/ui/checkbox.tsx +26 -4
  57. package/registry/chip-group/files/src/components/ui/chip-group.tsx +35 -5
  58. package/registry/coupon/files/src/components/ui/coupon.tsx +45 -21
  59. package/registry/coupon/files/src/components/ui/flip-card.tsx +22 -3
  60. package/registry/coupon/files/src/components/ui/tilt-card.tsx +57 -16
  61. package/registry/date-picker/files/src/components/ui/calendar.tsx +62 -17
  62. package/registry/date-picker/files/src/components/ui/date-picker.tsx +96 -42
  63. package/registry/dialog/files/src/components/ui/dialog.tsx +9 -5
  64. package/registry/drawer/files/src/components/ui/drawer.tsx +8 -4
  65. package/registry/flip-card/files/src/components/ui/flip-card.tsx +22 -3
  66. package/registry/floating-input/files/src/components/ui/floating-input.tsx +75 -26
  67. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -1
  68. package/registry/list-item/files/src/components/ui/list-item.tsx +10 -3
  69. package/registry/otp-input/files/src/components/ui/otp-input.tsx +50 -19
  70. package/registry/password-input/files/src/components/ui/floating-input.tsx +75 -26
  71. package/registry/password-input/files/src/components/ui/password-input.tsx +53 -19
  72. package/registry/payment-card/files/src/components/ui/flip-card.tsx +22 -3
  73. package/registry/payment-card/files/src/components/ui/payment-card.tsx +19 -15
  74. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +57 -16
  75. package/registry/progress/files/src/components/ui/progress.tsx +9 -2
  76. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +11 -4
  77. package/registry/radio-group/files/src/components/ui/radio-group.tsx +39 -9
  78. package/registry/search-bar/files/src/components/ui/search-bar.tsx +70 -17
  79. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +37 -9
  80. package/registry/select/files/src/components/ui/select.tsx +158 -83
  81. package/registry/slider/files/src/components/ui/slider.tsx +53 -25
  82. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
  83. package/registry/stepper/files/src/components/ui/stepper.tsx +76 -26
  84. package/registry/switch/files/src/components/ui/switch.tsx +56 -19
  85. package/registry/tabs/files/src/components/ui/tabs.tsx +7 -4
  86. package/registry/text-animation/files/src/components/ui/text-animation.tsx +7 -2
  87. package/registry/textarea/files/src/components/ui/textarea.tsx +104 -36
  88. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +57 -16
  89. package/registry/time-picker/files/src/components/ui/time-picker.tsx +154 -73
  90. package/registry/timeline/files/src/components/ui/timeline.tsx +9 -4
  91. package/registry/toast/files/src/components/ui/button.tsx +32 -4
  92. 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
- const Ctx = createContext<TiltState | null>(null);
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
- Animated.spring(press, {
359
- toValue: 1,
360
- ...motion.snappy,
361
- useNativeDriver: true,
362
- }).start();
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: [`${-maxTilt}deg`, `${maxTilt}deg`],
410
+ outputRange: [`${-tilt}deg`, `${tilt}deg`],
386
411
  });
387
412
  const rotateX = ty.interpolate({
388
413
  inputRange: [-1, 1],
389
- outputRange: [`${maxTilt}deg`, `${-maxTilt}deg`],
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 : <Text style={{ fontSize: font * 0.6, color: alpha(fg, 0.55) }}>%</Text>}
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 }]}>{i + 1}</Text>
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 }]}>{i + 1}</Text>
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 mounted = useRef(false);
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 (active && mounted.current) {
140
+ if (picked) {
130
141
  ripple.setValue(0);
131
142
  Animated.timing(ripple, { toValue: 1, duration: 560, useNativeDriver: true }).start();
132
143
  }
133
- mounted.current = true;
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
- rowCompact: { alignItems: 'center' },
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
- Animated.spring(clear, { toValue: text && editable && open ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
164
- }, [text, editable, open, clear]);
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, height: s.h } : [styles.flex, { height: s.h }]}>
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, height: s.h - 3 }, webNoOutline]}
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 open={panelOpen && open && visible.length > 0} items={visible} query={query} tint={tint} onSelect={select} />
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.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
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 },