@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,5 +1,6 @@
1
1
  import React, { memo, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Easing,
5
6
  Modal,
@@ -105,6 +106,10 @@ export interface TimePickerProps {
105
106
  menuStyle?: StyleProp<ViewStyle>;
106
107
  }
107
108
 
109
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
110
+ // content text scales all the way.
111
+ const FONT_CAP = 1.35;
112
+
108
113
  const ITEM = 44;
109
114
  const VISIBLE = 5;
110
115
  const PAD = ITEM * Math.floor(VISIBLE / 2);
@@ -166,6 +171,7 @@ export function TimePicker({
166
171
  ...rest
167
172
  }: TimePickerProps) {
168
173
  const [inner, setInner] = useState<TimeValue | null>(defaultValue);
174
+ const reduce = useReduceMotion();
169
175
  const current = value !== undefined ? value : inner;
170
176
  const h12 = useMemo(() => hour12 ?? uses12Hour(locale), [hour12, locale]);
171
177
  const step = Math.max(1, Math.min(60, minuteInterval ?? (variant === 'chips' ? 30 : 1)));
@@ -175,11 +181,11 @@ export function TimePicker({
175
181
  };
176
182
 
177
183
  if (presentation === 'sheet') {
178
- return <TimeField {...rest} variant={variant} hour12={h12} step={step} current={current} commit={commit} />;
184
+ return <TimeField {...rest} variant={variant} hour12={h12} step={step} current={current} commit={commit} reduce={reduce} />;
179
185
  }
180
- if (variant === 'chips') return <SlotGrid {...rest} hour12={h12} step={step} value={current} onPick={commit} />;
181
- if (variant === 'clock') return <Clock hour12={h12} step={step} value={current} onChange={commit} accent={rest.accent} style={rest.style} />;
182
- return <Wheel hour12={h12} step={step} value={current} onChange={commit} accent={rest.accent} style={rest.style} />;
186
+ if (variant === 'chips') return <SlotGrid {...rest} hour12={h12} step={step} value={current} onPick={commit} reduce={reduce} />;
187
+ if (variant === 'clock') return <Clock hour12={h12} step={step} value={current} onChange={commit} accent={rest.accent} style={rest.style} reduce={reduce} />;
188
+ return <Wheel hour12={h12} step={step} value={current} onChange={commit} accent={rest.accent} style={rest.style} reduce={reduce} />;
183
189
  }
184
190
 
185
191
  /* ───────────────────────────── wheel ───────────────────────────── */
@@ -191,9 +197,10 @@ interface WheelProps {
191
197
  onChange: (t: TimeValue) => void;
192
198
  accent?: string;
193
199
  style?: StyleProp<ViewStyle>;
200
+ reduce: boolean;
194
201
  }
195
202
 
196
- function Wheel({ hour12, step, value, onChange, accent, style }: WheelProps) {
203
+ function Wheel({ hour12, step, value, onChange, accent, style, reduce }: WheelProps) {
197
204
  const { colors, radius, font, dark } = useKit();
198
205
  const tint = accent ?? colors.accent;
199
206
  const t = value ?? { hours: 9, minutes: 0 };
@@ -227,13 +234,23 @@ function Wheel({ hour12, step, value, onChange, accent, style }: WheelProps) {
227
234
  style={[styles.band, { top: PAD, height: ITEM, borderRadius: radius.scale(12), backgroundColor: alpha(tint, dark ? 0.16 : 0.11) }]}
228
235
  />
229
236
  <View style={styles.wheelGroup}>
230
- <WheelColumn items={hours} index={hIdx} onIndexChange={(h) => set({ h })} label="Hour" width={64} align="right" />
237
+ <WheelColumn items={hours} index={hIdx} onIndexChange={(h) => set({ h })} label="Hour" width={64} align="right" reduce={reduce} />
231
238
  <View pointerEvents="none" style={styles.colon}>
232
- <Text style={[styles.colonText, font.family ? { fontFamily: font.family } : null, { color: colors.mutedForeground }]}>:</Text>
239
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.colonText, font.family ? { fontFamily: font.family } : null, { color: colors.mutedForeground }]}>:</Text>
233
240
  </View>
234
- <WheelColumn items={minutes} index={mIdx} onIndexChange={(m) => set({ m })} label="Minute" width={64} align="left" />
241
+ <WheelColumn items={minutes} index={mIdx} onIndexChange={(m) => set({ m })} label="Minute" width={64} align="left" reduce={reduce} />
235
242
  {hour12 ? (
236
- <WheelColumn items={periods} index={pIdx} onIndexChange={(p) => set({ p })} label="AM or PM" width={60} align="center" small style={styles.periodCol} />
243
+ <WheelColumn
244
+ items={periods}
245
+ index={pIdx}
246
+ onIndexChange={(p) => set({ p })}
247
+ label="AM or PM"
248
+ width={60}
249
+ align="center"
250
+ small
251
+ style={styles.periodCol}
252
+ reduce={reduce}
253
+ />
237
254
  ) : null}
238
255
  </View>
239
256
  </View>
@@ -249,9 +266,11 @@ interface WheelColumnProps {
249
266
  align: 'left' | 'right' | 'center';
250
267
  small?: boolean;
251
268
  style?: StyleProp<ViewStyle>;
269
+ /** Reduce Motion: programmatic scrolls jump; finger scrolling (and its row tilt) is unchanged. */
270
+ reduce: boolean;
252
271
  }
253
272
 
254
- const WheelColumn = memo(function WheelColumn({ items, index, onIndexChange, label, width, align, small, style }: WheelColumnProps) {
273
+ const WheelColumn = memo(function WheelColumn({ items, index, onIndexChange, label, width, align, small, style, reduce }: WheelColumnProps) {
255
274
  const { colors, font } = useKit();
256
275
  const ff = font.family ? { fontFamily: font.family } : null;
257
276
  const scrollY = useRef(new Animated.Value(index * ITEM)).current; // native, driven by the scroll view
@@ -262,11 +281,14 @@ const WheelColumn = memo(function WheelColumn({ items, index, onIndexChange, lab
262
281
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
263
282
  const cb = useRef(onIndexChange);
264
283
  cb.current = onIndexChange;
284
+ // Read by settle(), which also runs from the scroll listener created once.
285
+ const animated = useRef(!reduce);
286
+ animated.current = !reduce;
265
287
 
266
288
  useEffect(() => {
267
289
  if (index !== settled.current && !dragging.current) {
268
290
  settled.current = index;
269
- ref.current?.scrollTo({ y: index * ITEM, animated: true });
291
+ ref.current?.scrollTo({ y: index * ITEM, animated: animated.current });
270
292
  }
271
293
  }, [index]);
272
294
  useEffect(() => () => {
@@ -276,7 +298,7 @@ const WheelColumn = memo(function WheelColumn({ items, index, onIndexChange, lab
276
298
  const settle = () => {
277
299
  const y = lastY.current;
278
300
  const i = Math.max(0, Math.min(items.length - 1, Math.round(y / ITEM)));
279
- if (Math.abs(y - i * ITEM) > 0.5) ref.current?.scrollTo({ y: i * ITEM, animated: true });
301
+ if (Math.abs(y - i * ITEM) > 0.5) ref.current?.scrollTo({ y: i * ITEM, animated: animated.current });
280
302
  if (i !== settled.current) {
281
303
  settled.current = i;
282
304
  cb.current(i);
@@ -289,7 +311,7 @@ const WheelColumn = memo(function WheelColumn({ items, index, onIndexChange, lab
289
311
  const goTo = (i: number) => {
290
312
  const n = Math.max(0, Math.min(items.length - 1, i));
291
313
  lastY.current = n * ITEM;
292
- ref.current?.scrollTo({ y: n * ITEM, animated: true });
314
+ ref.current?.scrollTo({ y: n * ITEM, animated: !reduce });
293
315
  schedule();
294
316
  };
295
317
 
@@ -360,7 +382,7 @@ const WheelColumn = memo(function WheelColumn({ items, index, onIndexChange, lab
360
382
  },
361
383
  ]}
362
384
  >
363
- <Text numberOfLines={1} style={[styles.itemText, size, ff, { color: colors.foreground, textAlign: align }]}>
385
+ <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[styles.itemText, size, ff, { color: colors.foreground, textAlign: align }]}>
364
386
  {item}
365
387
  </Text>
366
388
  </Animated.View>
@@ -383,6 +405,7 @@ interface ClockProps {
383
405
  onChange: (t: TimeValue) => void;
384
406
  accent?: string;
385
407
  style?: StyleProp<ViewStyle>;
408
+ reduce: boolean;
386
409
  }
387
410
 
388
411
  const DIAL_MAX = 280;
@@ -400,7 +423,7 @@ const handAngle = (t: TimeValue, mode: ClockMode) => (mode === 'hour' ? (t.hours
400
423
  * hours and minutes, an AM / PM toggle, and a face you drag anywhere on. The hand rotates on the
401
424
  * native driver and snaps to hours, then auto-advances to minutes when you let go.
402
425
  */
403
- function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
426
+ function Clock({ hour12, step, value, onChange, accent, style, reduce }: ClockProps) {
404
427
  const { colors, radius, font, dark } = useKit();
405
428
  const ff = font.family ? { fontFamily: font.family } : null;
406
429
  const tint = accent ?? colors.accent;
@@ -426,11 +449,14 @@ function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
426
449
  const target = handAngle(t, mode);
427
450
  const d = ((((target - angle.current) % 360) + 540) % 360) - 180;
428
451
  angle.current += d;
429
- Animated.spring(rot, { toValue: angle.current, useNativeDriver: true, ...motion.snappy }).start();
452
+ if (reduce) rot.setValue(angle.current);
453
+ else Animated.spring(rot, { toValue: angle.current, useNativeDriver: true, ...motion.snappy }).start();
430
454
  }, [t.hours, t.minutes, mode, rot]); // eslint-disable-line react-hooks/exhaustive-deps
431
455
  useEffect(() => {
432
- Animated.timing(modeAnim, { toValue: mode === 'minute' ? 1 : 0, duration: motion.normal - 20, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
433
- }, [mode, modeAnim]);
456
+ // Reduce Motion: the numbers crossfade without the zoom (scale pinned below).
457
+ const duration = reduce ? 150 : motion.normal - 20;
458
+ Animated.timing(modeAnim, { toValue: mode === 'minute' ? 1 : 0, duration, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
459
+ }, [mode, modeAnim, reduce]);
434
460
 
435
461
  // Latest values for the pan responder, which is created once.
436
462
  const live = useRef({ t, mode, hour12, step, onChange, c, outer, inner, touched: value !== null });
@@ -503,7 +529,7 @@ function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
503
529
  const a = (i * Math.PI) / 6;
504
530
  return (
505
531
  <View key={l} style={[styles.num, { left: c + r * Math.sin(a) - KNOB, top: c - r * Math.cos(a) - KNOB }]}>
506
- <Text style={[styles.numText, small && styles.numSmall, ff, { color: small ? colors.mutedForeground : colors.foreground }]}>{l}</Text>
532
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.numText, small && styles.numSmall, ff, { color: small ? colors.mutedForeground : colors.foreground }]}>{l}</Text>
507
533
  </View>
508
534
  );
509
535
  });
@@ -521,7 +547,7 @@ function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
521
547
  { width: segW, height: segH, borderRadius: radius.scale(12), backgroundColor: on ? alpha(tint, dark ? 0.22 : 0.14) : colors.muted, opacity: pressed ? 0.75 : 1 },
522
548
  ]}
523
549
  >
524
- <Text style={[styles.segText, ff, { fontSize: segFont, color: on ? tint : colors.foreground }]}>{text}</Text>
550
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.segText, ff, { fontSize: segFont, color: on ? tint : colors.foreground }]}>{text}</Text>
525
551
  </Pressable>
526
552
  );
527
553
  };
@@ -530,7 +556,7 @@ function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
530
556
  <View style={[styles.clock, style]} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
531
557
  <View style={styles.clockHead}>
532
558
  {segment('hour', hourText, 'Hour')}
533
- <Text style={[styles.clockColon, ff, { fontSize: segFont, color: colors.mutedForeground }]}>:</Text>
559
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.clockColon, ff, { fontSize: segFont, color: colors.mutedForeground }]}>:</Text>
534
560
  {segment('minute', pad2(t.minutes), 'Minute')}
535
561
  {hour12 ? (
536
562
  <View
@@ -552,7 +578,7 @@ function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
552
578
  i === 1 && { borderTopWidth: StyleSheet.hairlineWidth * 2, borderTopColor: colors.border },
553
579
  ]}
554
580
  >
555
- <Text style={[styles.periodText, ff, { color: on ? tint : colors.mutedForeground }]}>{p}</Text>
581
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.periodText, ff, { color: on ? tint : colors.mutedForeground }]}>{p}</Text>
556
582
  </Pressable>
557
583
  );
558
584
  })}
@@ -579,14 +605,17 @@ function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
579
605
  <Animated.View
580
606
  style={[
581
607
  StyleSheet.absoluteFill,
582
- { opacity: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }), transform: [{ scale: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0.88] }) }] },
608
+ {
609
+ opacity: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),
610
+ transform: [{ scale: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [1, reduce ? 1 : 0.88] }) }],
611
+ },
583
612
  ]}
584
613
  >
585
614
  {ring(HOUR_OUTER, outer)}
586
615
  {hour12 ? null : ring(HOUR_INNER, inner, true)}
587
616
  </Animated.View>
588
617
  <Animated.View
589
- style={[StyleSheet.absoluteFill, { opacity: modeAnim, transform: [{ scale: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [1.1, 1] }) }] }]}
618
+ style={[StyleSheet.absoluteFill, { opacity: modeAnim, transform: [{ scale: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 1.1, 1] }) }] }]}
590
619
  >
591
620
  {ring(MINUTE_LABELS, outer)}
592
621
  </Animated.View>
@@ -599,6 +628,7 @@ function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
599
628
  </Svg>
600
629
  <View style={[styles.num, { left: c - KNOB, top: c - handR - KNOB }]}>
601
630
  <Animated.Text
631
+ maxFontSizeMultiplier={FONT_CAP}
602
632
  style={[styles.numText, mode === 'hour' && innerHour && styles.numSmall, ff, { color: ink, transform: [{ rotate: counter }] }]}
603
633
  >
604
634
  {knobLabel}
@@ -618,6 +648,7 @@ interface SlotGridProps extends Pick<TimePickerProps, 'startTime' | 'endTime' |
618
648
  step: number;
619
649
  value: TimeValue | null;
620
650
  onPick: (t: TimeValue) => void;
651
+ reduce: boolean;
621
652
  }
622
653
 
623
654
  function SlotGrid({
@@ -634,6 +665,7 @@ function SlotGrid({
634
665
  step,
635
666
  value,
636
667
  onPick,
668
+ reduce,
637
669
  }: SlotGridProps) {
638
670
  const { colors, font } = useKit();
639
671
  const ff = font.family ? { fontFamily: font.family } : null;
@@ -680,6 +712,7 @@ function SlotGrid({
680
712
  booked={booked.has(n)}
681
713
  disabled={off.has(n)}
682
714
  tint={tint}
715
+ reduce={reduce}
683
716
  onPick={onPick}
684
717
  />
685
718
  </View>
@@ -699,27 +732,29 @@ interface ChipProps {
699
732
  booked: boolean;
700
733
  disabled: boolean;
701
734
  tint: string;
735
+ /** Reduce Motion: no press shrink or select pop; the fill still fades. */
736
+ reduce: boolean;
702
737
  onPick: (t: TimeValue) => void;
703
738
  }
704
739
 
705
- const Chip = memo(function Chip({ minutes, label, selected, booked, disabled, tint, onPick }: ChipProps) {
740
+ const Chip = memo(function Chip({ minutes, label, selected, booked, disabled, tint, reduce, onPick }: ChipProps) {
706
741
  const { colors, radius, font } = useKit();
707
742
  const pop = useRef(new Animated.Value(1)).current; // native: press + select spring
708
743
  const on = useRef(new Animated.Value(selected ? 1 : 0)).current; // native: fill opacity
709
744
  useEffect(() => {
710
745
  Animated.timing(on, { toValue: selected ? 1 : 0, duration: motion.fast, useNativeDriver: true }).start();
711
- if (selected) {
746
+ if (selected && !reduce) {
712
747
  pop.setValue(0.9);
713
748
  Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
714
749
  }
715
- }, [selected, on, pop]);
750
+ }, [selected, on, pop, reduce]);
716
751
  const blocked = booked || disabled;
717
752
 
718
753
  return (
719
754
  <Pressable
720
755
  onPress={() => onPick(fromMin(minutes))}
721
- onPressIn={() => Animated.spring(pop, { toValue: 0.95, useNativeDriver: true, ...motion.snappy }).start()}
722
- onPressOut={() => Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start()}
756
+ onPressIn={reduce ? undefined : () => Animated.spring(pop, { toValue: 0.95, useNativeDriver: true, ...motion.snappy }).start()}
757
+ onPressOut={reduce ? undefined : () => Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start()}
723
758
  disabled={blocked}
724
759
  accessibilityRole="radio"
725
760
  accessibilityLabel={`${label}${booked ? ', booked' : disabled ? ', unavailable' : ''}`}
@@ -740,6 +775,7 @@ const Chip = memo(function Chip({ minutes, label, selected, booked, disabled, ti
740
775
  <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: tint, borderRadius: radius.md - 1, opacity: on }]} />
741
776
  <Text
742
777
  numberOfLines={1}
778
+ maxFontSizeMultiplier={FONT_CAP}
743
779
  style={[
744
780
  styles.chipText,
745
781
  font.family ? { fontFamily: font.family } : null,
@@ -761,6 +797,7 @@ interface TimeFieldProps extends Omit<TimePickerProps, 'value' | 'defaultValue'
761
797
  step: number;
762
798
  current: TimeValue | null;
763
799
  commit: (t: TimeValue) => void;
800
+ reduce: boolean;
764
801
  }
765
802
 
766
803
  function TimeField({
@@ -769,6 +806,7 @@ function TimeField({
769
806
  step,
770
807
  current,
771
808
  commit,
809
+ reduce,
772
810
  label = 'Time',
773
811
  floatingLabel = true,
774
812
  title,
@@ -810,6 +848,9 @@ function TimeField({
810
848
  const [mounted, setMounted] = useState(false);
811
849
  const [draft, setDraft] = useState<TimeValue | null>(current);
812
850
  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
851
+ // Read by the sheet's pan responder, which is created once.
852
+ const reduceRef = useRef(reduce);
853
+ reduceRef.current = reduce;
813
854
 
814
855
  const ok = !error && !!success;
815
856
  const floating = open || !!current;
@@ -822,26 +863,32 @@ function TimeField({
822
863
  const check = useRef(new Animated.Value(ok ? 1 : 0)).current; // native
823
864
 
824
865
  useEffect(() => {
825
- Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
826
- }, [floating, float]);
866
+ if (reduce) float.setValue(floating ? 1 : 0);
867
+ else Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
868
+ }, [floating, float, reduce]);
827
869
  useEffect(() => {
828
870
  Animated.timing(focus, { toValue: open ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
829
- Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
830
- }, [open, focus, halo]);
871
+ // Reduce Motion: the halo only fades (its scale is pinned below).
872
+ if (reduce) Animated.timing(halo, { toValue: open ? 1 : 0, duration: 150, useNativeDriver: true }).start();
873
+ else Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
874
+ }, [open, focus, halo, reduce]);
831
875
  useEffect(() => {
832
- Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
833
- }, [ok, check]);
876
+ if (reduce) Animated.timing(check, { toValue: ok ? 1 : 0, duration: 150, useNativeDriver: true }).start();
877
+ else Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
878
+ }, [ok, check, reduce]);
834
879
  useEffect(() => {
835
- if (!error) return;
880
+ if (!error || reduce) return;
836
881
  shake.setValue(0);
837
882
  Animated.sequence([10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))).start();
838
- }, [error, shake]);
883
+ }, [error, shake, reduce]);
839
884
  useEffect(() => {
840
885
  if (open) {
841
886
  drag.setValue(0);
842
- Animated.spring(show, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();
887
+ // Reduce Motion: the sheet's slide / scale is pinned below, so `show` only fades it in place.
888
+ if (reduce) Animated.timing(show, { toValue: 1, duration: 150, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
889
+ else Animated.spring(show, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();
843
890
  } else if (mounted) {
844
- Animated.timing(show, { toValue: 0, duration: motion.fast + 40, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(
891
+ Animated.timing(show, { toValue: 0, duration: reduce ? 150 : motion.fast + 40, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(
845
892
  ({ finished }) => finished && setMounted(false)
846
893
  );
847
894
  }
@@ -870,19 +917,21 @@ function TimeField({
870
917
  }
871
918
  };
872
919
 
873
- const pan = useMemo(
874
- () =>
875
- PanResponder.create({
876
- onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
877
- onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
878
- onPanResponderRelease: (_, g) => {
879
- if (g.dy > 110 || g.vy > 1.1) setOpen(false);
880
- else Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
881
- },
882
- onPanResponderTerminate: () => Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start(),
883
- }),
884
- [drag]
885
- );
920
+ const pan = useMemo(() => {
921
+ const back = () =>
922
+ reduceRef.current
923
+ ? Animated.timing(drag, { toValue: 0, duration: 150, easing: Easing.bezier(0.32, 0.72, 0, 1), useNativeDriver: true }).start()
924
+ : Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
925
+ return PanResponder.create({
926
+ onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
927
+ onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
928
+ onPanResponderRelease: (_, g) => {
929
+ if (g.dy > 110 || g.vy > 1.1) setOpen(false);
930
+ else back();
931
+ },
932
+ onPanResponderTerminate: back,
933
+ });
934
+ }, [drag]);
886
935
 
887
936
  const active = error ? colors.danger : tint;
888
937
  const rest = error ? colors.danger : ok ? colors.success : ((userBorder as string | undefined) ?? colors.input);
@@ -928,13 +977,14 @@ function TimeField({
928
977
  borderRadius: fieldRadius + 4,
929
978
  borderColor: alpha(active, 0.2),
930
979
  opacity: halo,
931
- transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
980
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.97, 1] }) }],
932
981
  },
933
982
  ]}
934
983
  />
935
- <Animated.View style={[styles.field, { height: sz.h, borderRadius: fieldRadius, backgroundColor: bg }, userField, { borderColor }]}>
984
+ <Animated.View style={[styles.field, { minHeight: sz.h, borderRadius: fieldRadius, backgroundColor: bg }, userField, { borderColor }]}>
936
985
  <Text
937
986
  numberOfLines={1}
987
+ maxFontSizeMultiplier={FONT_CAP}
938
988
  style={[
939
989
  styles.value,
940
990
  ff,
@@ -962,14 +1012,14 @@ function TimeField({
962
1012
  ]}
963
1013
  >
964
1014
  {/* The notch: the label paints the surface over the border, so it matches the rendered width. */}
965
- <Animated.Text numberOfLines={1} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>
1015
+ <Animated.Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>
966
1016
  {label}
967
1017
  </Animated.Text>
968
1018
  </Animated.View>
969
1019
  ) : null}
970
1020
  <View pointerEvents="none" style={[styles.adorn, { right: sz.pad - 2 }]}>
971
1021
  <Animated.View
972
- style={{ display: ok ? 'flex' : 'none', opacity: check, transform: [{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) }] }}
1022
+ style={{ display: ok ? 'flex' : 'none', opacity: check, transform: [{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.3, 1] }) }] }}
973
1023
  >
974
1024
  <Icon name="check" size={sz.icon} color={colors.success} />
975
1025
  </Animated.View>
@@ -977,7 +1027,7 @@ function TimeField({
977
1027
  </View>
978
1028
  </Animated.View>
979
1029
  </Pressable>
980
- {message ? <FieldMessage key={tone + message} text={message} tone={tone} style={messageStyle} /> : null}
1030
+ {message ? <FieldMessage key={tone + message} text={message} tone={tone} reduce={reduce} style={messageStyle} /> : null}
981
1031
 
982
1032
  <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={close}>
983
1033
  <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: show }]}>
@@ -997,11 +1047,15 @@ function TimeField({
997
1047
  ? {
998
1048
  opacity: show,
999
1049
  transform: [
1000
- { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) },
1001
- { translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }), drag) },
1050
+ { scale: show.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.94, 1] }) },
1051
+ { translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 16, 0] }), drag) },
1002
1052
  ],
1003
1053
  }
1004
- : { transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [winH * 0.7, 0] }), drag) }] },
1054
+ : {
1055
+ // Reduce Motion: fade in place instead of sliding up.
1056
+ opacity: reduce ? show : 1,
1057
+ transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : winH * 0.7, 0] }), drag) }],
1058
+ },
1005
1059
  ]}
1006
1060
  >
1007
1061
  <View {...pan.panHandlers} style={styles.sheetHeader}>
@@ -1013,17 +1067,17 @@ function TimeField({
1013
1067
  </View>
1014
1068
  <ScrollView style={styles.sheetBody} contentContainerStyle={styles.sheetContent} bounces={false} scrollEnabled={variant === 'chips'}>
1015
1069
  {variant === 'chips' ? (
1016
- <SlotGrid {...chipProps} accent={tint} hour12={hour12} step={step} value={draft} onPick={pick} />
1070
+ <SlotGrid {...chipProps} accent={tint} hour12={hour12} step={step} value={draft} onPick={pick} reduce={reduce} />
1017
1071
  ) : variant === 'clock' ? (
1018
- <Clock hour12={hour12} step={step} value={draft} onChange={pick} accent={tint} />
1072
+ <Clock hour12={hour12} step={step} value={draft} onChange={pick} accent={tint} reduce={reduce} />
1019
1073
  ) : (
1020
- <Wheel hour12={hour12} step={step} value={draft} onChange={pick} accent={tint} />
1074
+ <Wheel hour12={hour12} step={step} value={draft} onChange={pick} accent={tint} reduce={reduce} />
1021
1075
  )}
1022
1076
  </ScrollView>
1023
1077
  {staged ? (
1024
1078
  <View style={[styles.footer, { borderTopColor: colors.border }]}>
1025
1079
  <Pressable onPress={close} accessibilityRole="button" style={({ pressed }) => [styles.footBtn, { borderRadius: radius.md, opacity: pressed ? 0.6 : 1 }]}>
1026
- <Text style={[styles.footText, ff, { color: colors.foreground }]}>Cancel</Text>
1080
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.footText, ff, { color: colors.foreground }]}>Cancel</Text>
1027
1081
  </Pressable>
1028
1082
  <Pressable
1029
1083
  onPress={() => {
@@ -1038,7 +1092,7 @@ function TimeField({
1038
1092
  { backgroundColor: tint, borderRadius: radius.md, opacity: !draft ? 0.4 : pressed ? 0.85 : 1 },
1039
1093
  ]}
1040
1094
  >
1041
- <Text style={[styles.footText, ff, { color: readableOn(tint) }]}>{draft ? `Set ${formatTime(draft, hour12)}` : 'Done'}</Text>
1095
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.footText, ff, { color: readableOn(tint) }]}>{draft ? `Set ${formatTime(draft, hour12)}` : 'Done'}</Text>
1042
1096
  </Pressable>
1043
1097
  </View>
1044
1098
  ) : null}
@@ -1050,17 +1104,28 @@ function TimeField({
1050
1104
  }
1051
1105
 
1052
1106
  /** Helper / error / success line that slides in; error and success carry an icon. */
1053
- function FieldMessage({ text, tone, style }: { text: string; tone: 'error' | 'success' | 'helper'; style?: StyleProp<TextStyle> }) {
1107
+ function FieldMessage({
1108
+ text,
1109
+ tone,
1110
+ reduce,
1111
+ style,
1112
+ }: {
1113
+ text: string;
1114
+ tone: 'error' | 'success' | 'helper';
1115
+ reduce: boolean;
1116
+ style?: StyleProp<TextStyle>;
1117
+ }) {
1054
1118
  const { colors, font } = useKit();
1055
1119
  const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
1056
1120
  useEffect(() => {
1057
- Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
1058
- }, [enter]);
1121
+ if (reduce) Animated.timing(enter, { toValue: 1, duration: 150, useNativeDriver: true }).start();
1122
+ else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
1123
+ }, [enter, reduce]);
1059
1124
  const color = tone === 'error' ? colors.danger : tone === 'success' ? colors.success : colors.mutedForeground;
1060
1125
  return (
1061
1126
  <Animated.View
1062
1127
  accessibilityLiveRegion="polite"
1063
- style={[styles.message, { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] }]}
1128
+ style={[styles.message, { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }] }]}
1064
1129
  >
1065
1130
  {tone !== 'helper' ? <Icon name={tone === 'error' ? 'alert' : 'checkCircle'} size={14} color={color} /> : null}
1066
1131
  <Text style={[styles.helper, font.family ? { fontFamily: font.family } : null, { color }, style, tone !== 'helper' && { color }]}>{text}</Text>
@@ -1095,6 +1160,22 @@ function Icon({ name, size, color }: { name: 'clock' | 'check' | 'checkCircle' |
1095
1160
  );
1096
1161
  }
1097
1162
 
1163
+ function useReduceMotion() {
1164
+ const [reduce, setReduce] = useState(false);
1165
+ useEffect(() => {
1166
+ let alive = true;
1167
+ AccessibilityInfo.isReduceMotionEnabled()
1168
+ .then((r) => alive && setReduce(r))
1169
+ .catch(() => {});
1170
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
1171
+ return () => {
1172
+ alive = false;
1173
+ sub.remove();
1174
+ };
1175
+ }, []);
1176
+ return reduce;
1177
+ }
1178
+
1098
1179
  const styles = StyleSheet.create({
1099
1180
  wheel: { flexDirection: 'row', justifyContent: 'center', alignSelf: 'stretch', overflow: 'hidden' },
1100
1181
  band: { position: 'absolute', left: 8, right: 8 },
@@ -1119,12 +1200,12 @@ const styles = StyleSheet.create({
1119
1200
  numSmall: { fontSize: 13 },
1120
1201
  slots: { alignSelf: 'stretch', gap: 14 },
1121
1202
  slotGroup: { gap: 6 },
1122
- groupHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', paddingHorizontal: 2 },
1123
- groupTitle: { fontSize: 13.5, fontWeight: '400' },
1203
+ groupHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, paddingHorizontal: 2 },
1204
+ groupTitle: { flexShrink: 1, fontSize: 13.5, fontWeight: '400' },
1124
1205
  groupCount: { fontSize: 12.5, fontWeight: '400' },
1125
1206
  grid: { flexDirection: 'row', flexWrap: 'wrap', marginHorizontal: -4 },
1126
1207
  chipCell: { padding: 4 },
1127
- chip: { height: 44, borderWidth: 1, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
1208
+ chip: { minHeight: 44, borderWidth: 1, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
1128
1209
  chipText: { fontSize: 14.5, fontWeight: '400', fontVariant: ['tabular-nums'] },
1129
1210
  struck: { textDecorationLine: 'line-through' },
1130
1211
  field: { borderWidth: 1.5, justifyContent: 'center' },
@@ -1153,7 +1234,7 @@ const styles = StyleSheet.create({
1153
1234
  sheetBody: { flexGrow: 0, flexShrink: 1 },
1154
1235
  sheetContent: { paddingHorizontal: 16, paddingBottom: 12 },
1155
1236
  footer: { flexDirection: 'row', gap: 8, paddingTop: 12, paddingHorizontal: 16, borderTopWidth: StyleSheet.hairlineWidth },
1156
- footBtn: { height: 46, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
1237
+ footBtn: { minHeight: 46, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
1157
1238
  footMain: { flex: 1 },
1158
1239
  footText: { fontSize: 15, fontWeight: '400' },
1159
1240
  });
@@ -93,6 +93,10 @@ export interface TimelineProps {
93
93
  style?: StyleProp<ViewStyle>;
94
94
  }
95
95
 
96
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
97
+ // content text scales all the way.
98
+ const FONT_CAP = 1.35;
99
+
96
100
  const ENTER_STAGGER = 70;
97
101
  const FILL_START = 160;
98
102
  /** Where the fill point sits in the viewport at the top of the scroll (it slides to the bottom by the end). */
@@ -479,7 +483,7 @@ function Row({
479
483
  key={`t-${t}`}
480
484
  style={[styles.tag, { borderRadius: radius.capsule(22), backgroundColor: alpha(tint, dark ? 0.2 : 0.1) }]}
481
485
  >
482
- <Text style={[styles.tagText, ff, { color: tint }]}>{t}</Text>
486
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.tagText, ff, { color: tint }]}>{t}</Text>
483
487
  </View>
484
488
  ))}
485
489
  {item.links?.map((l) => (
@@ -941,7 +945,7 @@ function Photo({ uri, delay, style, more }: { uri: string; delay: number; style:
941
945
  />
942
946
  {more > 0 ? (
943
947
  <View style={[StyleSheet.absoluteFill, styles.more]}>
944
- <Text style={[styles.moreText, ff]}>+{more}</Text>
948
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.moreText, ff]}>+{more}</Text>
945
949
  </View>
946
950
  ) : null}
947
951
  </View>
@@ -1039,10 +1043,11 @@ const styles = StyleSheet.create({
1039
1043
  mini: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
1040
1044
  bodyWrap: { flex: 1, minWidth: 0 },
1041
1045
  card: { borderWidth: 1 },
1042
- titleRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
1046
+ // Wraps only when the time no longer fits beside the title (large text sizes): it drops below.
1047
+ titleRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-start', gap: 10 },
1043
1048
  compactRow: { alignItems: 'center' },
1044
1049
  title: { flex: 1, fontWeight: '400', letterSpacing: -0.2 },
1045
- time: { fontSize: 12.5, fontWeight: '400', fontVariant: ['tabular-nums'] },
1050
+ time: { flexShrink: 1, fontSize: 12.5, fontWeight: '400', fontVariant: ['tabular-nums'] },
1046
1051
  desc: { fontSize: 14, fontWeight: '400', lineHeight: 20, marginTop: 3 },
1047
1052
  gallery: { marginTop: 12, width: '100%', overflow: 'hidden', borderWidth: 1 },
1048
1053
  grid: { flexDirection: 'row', gap: 3 },