@expokit/cli 0.4.0 → 0.4.2

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 (102) hide show
  1. package/README.md +17 -0
  2. package/bin/lib.mjs +1 -1
  3. package/bin/liquidglass.mjs +281 -0
  4. package/package.json +17 -3
  5. package/registry/_docs/accordion.json +1 -1
  6. package/registry/_docs/avatar.json +6 -6
  7. package/registry/_docs/badge.json +3 -3
  8. package/registry/_docs/bottom-nav.json +6 -6
  9. package/registry/_docs/bottom-sheet.json +3 -3
  10. package/registry/_docs/bullet.json +6 -0
  11. package/registry/_docs/button-3d.json +2 -2
  12. package/registry/_docs/button.json +2 -2
  13. package/registry/_docs/calendar.json +5 -5
  14. package/registry/_docs/card.json +1 -1
  15. package/registry/_docs/chip-group.json +1 -1
  16. package/registry/_docs/confetti.json +3 -3
  17. package/registry/_docs/date-picker.json +4 -4
  18. package/registry/_docs/drawer.json +4 -4
  19. package/registry/_docs/flip-card.json +4 -4
  20. package/registry/_docs/floating-input.json +1 -1
  21. package/registry/_docs/grain.json +2 -2
  22. package/registry/_docs/isometric-bar.json +6 -0
  23. package/registry/_docs/liquidglass-tab.json +2 -1
  24. package/registry/_docs/otp-input.json +1 -1
  25. package/registry/_docs/payment-card.json +3 -3
  26. package/registry/_docs/pixel-bar.json +6 -0
  27. package/registry/_docs/polar-area.json +6 -0
  28. package/registry/_docs/progress-steps.json +4 -4
  29. package/registry/_docs/progress.json +2 -2
  30. package/registry/_docs/radio-group.json +1 -1
  31. package/registry/_docs/search-bar.json +3 -3
  32. package/registry/_docs/segmented-control.json +1 -1
  33. package/registry/_docs/select.json +6 -6
  34. package/registry/_docs/skeleton.json +1 -1
  35. package/registry/_docs/slider.json +6 -6
  36. package/registry/_docs/spinner.json +2 -2
  37. package/registry/_docs/stateful-button.json +1 -1
  38. package/registry/_docs/stepper.json +3 -3
  39. package/registry/_docs/tabs.json +2 -2
  40. package/registry/_docs/text-animation.json +4 -4
  41. package/registry/_docs/textarea.json +1 -1
  42. package/registry/_docs/tilt-card.json +3 -3
  43. package/registry/_docs/time-picker.json +6 -6
  44. package/registry/_docs/timeline-chart.json +6 -0
  45. package/registry/_docs/timeline.json +4 -4
  46. package/registry/_docs/treemap.json +6 -0
  47. package/registry/_docs/waterfall.json +6 -0
  48. package/registry/accordion/files/src/components/ui/accordion.tsx +31 -3
  49. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +4 -1
  50. package/registry/alert/files/src/components/ui/alert.tsx +5 -2
  51. package/registry/alert/files/src/components/ui/button.tsx +32 -4
  52. package/registry/badge/files/src/components/ui/badge.tsx +2 -2
  53. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -5
  54. package/registry/button/files/src/components/ui/button.tsx +32 -4
  55. package/registry/button-3d/files/src/components/ui/button-3d.tsx +33 -6
  56. package/registry/calendar/files/src/components/ui/calendar.tsx +62 -17
  57. package/registry/card/files/src/components/ui/card.tsx +25 -5
  58. package/registry/checkbox/files/src/components/ui/checkbox.tsx +26 -4
  59. package/registry/chip-group/files/src/components/ui/chip-group.tsx +35 -5
  60. package/registry/coupon/files/src/components/ui/coupon.tsx +45 -21
  61. package/registry/coupon/files/src/components/ui/flip-card.tsx +22 -3
  62. package/registry/coupon/files/src/components/ui/tilt-card.tsx +57 -16
  63. package/registry/date-picker/files/src/components/ui/calendar.tsx +62 -17
  64. package/registry/date-picker/files/src/components/ui/date-picker.tsx +96 -42
  65. package/registry/dialog/files/src/components/ui/dialog.tsx +9 -5
  66. package/registry/drawer/files/src/components/ui/drawer.tsx +8 -4
  67. package/registry/flip-card/files/src/components/ui/flip-card.tsx +22 -3
  68. package/registry/floating-input/files/src/components/ui/floating-input.tsx +75 -26
  69. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +3 -94
  70. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +2 -1
  71. package/registry/liquidglass-tab/files/src/app/_layout.tsx +2 -2
  72. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +2 -1
  73. package/registry/liquidglass-tab/files/src/components/LiquidGlassTabs.tsx +110 -0
  74. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -6
  75. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +12 -4
  76. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +6 -3
  77. package/registry/liquidglass-tab/files/src/lib/tabs.ts +4 -2
  78. package/registry/list-item/files/src/components/ui/list-item.tsx +10 -3
  79. package/registry/otp-input/files/src/components/ui/otp-input.tsx +50 -19
  80. package/registry/password-input/files/src/components/ui/floating-input.tsx +75 -26
  81. package/registry/password-input/files/src/components/ui/password-input.tsx +53 -19
  82. package/registry/payment-card/files/src/components/ui/flip-card.tsx +22 -3
  83. package/registry/payment-card/files/src/components/ui/payment-card.tsx +19 -15
  84. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +57 -16
  85. package/registry/progress/files/src/components/ui/progress.tsx +9 -2
  86. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +11 -4
  87. package/registry/radio-group/files/src/components/ui/radio-group.tsx +39 -9
  88. package/registry/search-bar/files/src/components/ui/search-bar.tsx +70 -17
  89. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +37 -9
  90. package/registry/select/files/src/components/ui/select.tsx +158 -83
  91. package/registry/slider/files/src/components/ui/slider.tsx +53 -25
  92. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
  93. package/registry/stepper/files/src/components/ui/stepper.tsx +76 -26
  94. package/registry/switch/files/src/components/ui/switch.tsx +56 -19
  95. package/registry/tabs/files/src/components/ui/tabs.tsx +7 -4
  96. package/registry/text-animation/files/src/components/ui/text-animation.tsx +7 -2
  97. package/registry/textarea/files/src/components/ui/textarea.tsx +104 -36
  98. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +57 -16
  99. package/registry/time-picker/files/src/components/ui/time-picker.tsx +154 -73
  100. package/registry/timeline/files/src/components/ui/timeline.tsx +9 -4
  101. package/registry/toast/files/src/components/ui/button.tsx +32 -4
  102. 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
- Animated.spring(pos, { toValue: on ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
94
- Animated.spring(glyph, { toValue: on ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
95
- }, [on, pos, glyph]);
96
-
97
- // Halo: 0 hidden · 1 held (glow around the thumb) · 2 released (expands and fades out).
98
- const press = (to: number) => {
99
- Animated.spring(stretch, { toValue: to, useNativeDriver: false, ...(to ? motion.snappy : motion.bouncy) }).start();
100
- if (to) Animated.spring(halo, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
101
- else Animated.timing(halo, { toValue: 2, duration: 420, useNativeDriver: true }).start(({ finished }) => finished && halo.setValue(0));
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
- Animated.spring(pos, { toValue: next ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
161
+ settle(next ? 1 : 0);
142
162
  if (next !== now) flip();
143
163
  },
144
164
  onPanResponderTerminate: () => {
145
165
  press(0);
146
- Animated.spring(pos, { toValue: live.current.on ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
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 = pos.interpolate({ inputRange: [0, 1], outputRange: [offTrack, tint], extrapolate: 'clamp' });
169
- const trackBorder = pos.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, tint], extrapolate: 'clamp' });
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
- ? pos.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, onFg], extrapolate: 'clamp' })
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, height: 18, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' },
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 ?? LINE) / LINE);
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 * LINE + padBottom + (toolbar ? toolbarH ?? 0 : 0) + BORDER * 2;
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
- Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
202
- }, [floating, float]);
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
- Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
207
- }, [isFocused, focus, glow]);
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.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
211
- }, [ok, check]);
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
- if (!error) return;
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 + LINE / 2;
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: LINE,
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 + (LINE - checkSize) / 2,
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({ text, tone, color, style }: { text: string; tone: MessageTone; color: string; style?: StyleProp<TextStyle> }) {
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 !== 'helper') Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
425
- }, [tone, enter]);
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({ length, max, tint, style }: { length: number; max: number; tint: string; style?: StyleProp<TextStyle> }) {
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
- Animated.spring(mode, { toValue: near ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
475
- }, [near, mode]);
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
- Animated.spring(progress, { toValue: Math.min(1, ratio), useNativeDriver: false, ...motion.snappy }).start();
479
- }, [ratio, progress]);
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 }]}>{remaining}</Text>
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, height: RING, alignItems: 'flex-end', justifyContent: 'center' },
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
  });