@expokit/cli 0.4.3 → 0.5.0

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 (242) hide show
  1. package/README.md +30 -3
  2. package/bin/expo-kit.js +749 -288
  3. package/bin/lib.mjs +313 -4
  4. package/package.json +1 -1
  5. package/registry/_docs/action-sheet.json +1 -1
  6. package/registry/_docs/alert.json +6 -0
  7. package/registry/_docs/bottom-sheet.json +6 -0
  8. package/registry/_docs/bullet.json +6 -0
  9. package/registry/_docs/dialog.json +1 -1
  10. package/registry/_docs/drawer.json +3 -3
  11. package/registry/_docs/flip-calendar.json +106 -0
  12. package/registry/_docs/glass-search-bar.json +1 -1
  13. package/registry/_docs/grain.json +6 -1
  14. package/registry/_docs/isometric-bar.json +6 -0
  15. package/registry/_docs/liquidglass-tab.json +1 -1
  16. package/registry/_docs/password-input.json +1 -1
  17. package/registry/_docs/paywall.json +144 -0
  18. package/registry/_docs/pixel-bar.json +6 -0
  19. package/registry/_docs/polar-area.json +6 -0
  20. package/registry/_docs/qr-code-button.json +9 -3
  21. package/registry/_docs/ruler-time-picker.json +147 -0
  22. package/registry/_docs/scratch-card.json +7 -1
  23. package/registry/_docs/search-bar.json +1 -1
  24. package/registry/_docs/select.json +2 -2
  25. package/registry/_docs/slide-to-confirm.json +2 -2
  26. package/registry/_docs/spinner.json +1 -1
  27. package/registry/_docs/stateful-button.json +2 -2
  28. package/registry/_docs/swipe-deck.json +1 -1
  29. package/registry/_docs/time-picker.json +2 -2
  30. package/registry/_docs/timeline-chart.json +6 -0
  31. package/registry/_docs/toast.json +15 -3
  32. package/registry/_docs/treemap.json +6 -0
  33. package/registry/_docs/waterfall.json +6 -0
  34. package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
  35. package/registry/accordion/files/src/lib/kit.tsx +462 -12
  36. package/registry/accordion.json +1 -0
  37. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +50 -58
  38. package/registry/action-sheet/files/src/lib/kit.tsx +462 -12
  39. package/registry/action-sheet.json +1 -0
  40. package/registry/alert/files/src/components/ui/alert.tsx +20 -13
  41. package/registry/alert/files/src/lib/kit.tsx +462 -12
  42. package/registry/alert.json +3 -4
  43. package/registry/assistant-app.json +1 -0
  44. package/registry/avatar/files/src/components/ui/avatar.tsx +89 -74
  45. package/registry/avatar/files/src/lib/kit.tsx +462 -12
  46. package/registry/avatar.json +1 -0
  47. package/registry/badge/files/src/components/ui/badge.tsx +36 -36
  48. package/registry/badge/files/src/lib/kit.tsx +462 -12
  49. package/registry/badge.json +1 -0
  50. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +39 -44
  51. package/registry/bottom-nav/files/src/lib/kit.tsx +462 -12
  52. package/registry/bottom-nav.json +5 -0
  53. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +357 -300
  54. package/registry/bottom-sheet/files/src/lib/kit.tsx +462 -12
  55. package/registry/bottom-sheet.json +5 -1
  56. package/registry/bullet.json +1 -0
  57. package/registry/button/files/src/components/ui/button.tsx +23 -34
  58. package/registry/button/files/src/lib/kit.tsx +462 -12
  59. package/registry/button-3d/files/src/components/ui/button-3d.tsx +6 -9
  60. package/registry/button-3d/files/src/lib/kit.tsx +462 -12
  61. package/registry/button-3d.json +1 -0
  62. package/registry/button.json +1 -0
  63. package/registry/calendar/files/src/components/ui/calendar.tsx +77 -79
  64. package/registry/calendar/files/src/lib/kit.tsx +462 -12
  65. package/registry/calendar.json +1 -0
  66. package/registry/card/files/src/components/ui/card.tsx +7 -6
  67. package/registry/card/files/src/lib/kit.tsx +462 -12
  68. package/registry/card-stack.json +1 -0
  69. package/registry/card.json +1 -0
  70. package/registry/carousel.json +1 -0
  71. package/registry/chat.json +7 -0
  72. package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
  73. package/registry/checkbox/files/src/lib/kit.tsx +462 -12
  74. package/registry/checkbox.json +1 -0
  75. package/registry/checkout.json +1 -0
  76. package/registry/chip-group/files/src/components/ui/chip-group.tsx +24 -24
  77. package/registry/chip-group/files/src/lib/kit.tsx +462 -12
  78. package/registry/chip-group.json +1 -0
  79. package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
  80. package/registry/confetti.json +3 -6
  81. package/registry/coupon/files/src/components/ui/coupon.tsx +47 -52
  82. package/registry/coupon/files/src/components/ui/flip-card.tsx +9 -14
  83. package/registry/coupon/files/src/components/ui/tilt-card.tsx +80 -101
  84. package/registry/coupon/files/src/lib/kit.tsx +462 -12
  85. package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
  86. package/registry/coupon.json +1 -0
  87. package/registry/cube-pager.json +1 -0
  88. package/registry/dashboard.json +1 -0
  89. package/registry/date-picker/files/src/components/ui/calendar.tsx +77 -79
  90. package/registry/date-picker/files/src/components/ui/date-picker.tsx +64 -71
  91. package/registry/date-picker/files/src/lib/kit.tsx +462 -12
  92. package/registry/date-picker.json +1 -0
  93. package/registry/dialog/files/src/components/ui/dialog.tsx +66 -68
  94. package/registry/dialog/files/src/lib/kit.tsx +462 -12
  95. package/registry/dialog.json +1 -0
  96. package/registry/donut-shop.json +1 -0
  97. package/registry/drawer/files/src/components/ui/drawer.tsx +76 -88
  98. package/registry/drawer/files/src/lib/kit.tsx +462 -12
  99. package/registry/drawer.json +1 -0
  100. package/registry/fab.json +1 -0
  101. package/registry/fintech-app.json +1 -0
  102. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +632 -0
  103. package/registry/flip-calendar/files/src/lib/kit.tsx +672 -0
  104. package/registry/flip-calendar.json +24 -0
  105. package/registry/flip-card/files/src/components/ui/flip-card.tsx +9 -14
  106. package/registry/flip-card/files/src/lib/kit.tsx +462 -12
  107. package/registry/flip-card.json +1 -0
  108. package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
  109. package/registry/floating-input/files/src/lib/kit.tsx +462 -12
  110. package/registry/floating-input.json +1 -0
  111. package/registry/food-app.json +1 -0
  112. package/registry/gift-card.json +1 -0
  113. package/registry/glass-button.json +5 -0
  114. package/registry/glass-nav-bar.json +1 -0
  115. package/registry/glass-search-bar.json +1 -0
  116. package/registry/glass-segmented.json +1 -0
  117. package/registry/glass-sheet.json +1 -0
  118. package/registry/glass-tab-bar.json +5 -0
  119. package/registry/glass.json +1 -0
  120. package/registry/grain/files/src/components/ui/grain.tsx +16 -14
  121. package/registry/grain.json +8 -6
  122. package/registry/inbox.json +1 -0
  123. package/registry/isometric-bar.json +1 -0
  124. package/registry/kit/files/src/lib/kit.tsx +462 -12
  125. package/registry/kit.json +1 -0
  126. package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
  127. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +29 -16
  128. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +33 -19
  129. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
  130. package/registry/liquidglass-tab.json +1 -0
  131. package/registry/list-item/files/src/components/ui/list-item.tsx +43 -43
  132. package/registry/list-item/files/src/lib/kit.tsx +462 -12
  133. package/registry/list-item.json +1 -0
  134. package/registry/loyalty-card.json +1 -0
  135. package/registry/morphing-button-group.json +1 -0
  136. package/registry/number-ticker.json +1 -0
  137. package/registry/offers.json +1 -0
  138. package/registry/onboarding.json +1 -0
  139. package/registry/orb-reveal.json +1 -0
  140. package/registry/otp-input/files/src/components/ui/otp-input.tsx +49 -30
  141. package/registry/otp-input/files/src/lib/kit.tsx +462 -12
  142. package/registry/otp-input.json +1 -0
  143. package/registry/parallax-layers.json +1 -0
  144. package/registry/parallax-scroll.json +1 -0
  145. package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
  146. package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
  147. package/registry/password-input/files/src/lib/kit.tsx +462 -12
  148. package/registry/password-input.json +1 -0
  149. package/registry/payment-card/files/src/components/ui/flip-card.tsx +9 -14
  150. package/registry/payment-card/files/src/components/ui/payment-card.tsx +26 -20
  151. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +80 -101
  152. package/registry/payment-card/files/src/lib/kit.tsx +462 -12
  153. package/registry/payment-card.json +1 -0
  154. package/registry/paywall.json +23 -0
  155. package/registry/pixel-bar.json +1 -0
  156. package/registry/polar-area.json +1 -0
  157. package/registry/profile.json +1 -0
  158. package/registry/progress/files/src/components/ui/progress.tsx +31 -29
  159. package/registry/progress/files/src/lib/kit.tsx +462 -12
  160. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +21 -21
  161. package/registry/progress-steps/files/src/lib/kit.tsx +462 -12
  162. package/registry/progress-steps.json +1 -0
  163. package/registry/progress.json +1 -0
  164. package/registry/qr-code-button.json +1 -0
  165. package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
  166. package/registry/radio-group/files/src/lib/kit.tsx +462 -12
  167. package/registry/radio-group.json +1 -0
  168. package/registry/rating.json +1 -0
  169. package/registry/ring-carousel.json +1 -0
  170. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +832 -0
  171. package/registry/ruler-time-picker/files/src/lib/kit.tsx +672 -0
  172. package/registry/ruler-time-picker.json +26 -0
  173. package/registry/scratch-card.json +1 -0
  174. package/registry/search-bar/files/src/components/ui/search-bar.tsx +28 -28
  175. package/registry/search-bar/files/src/lib/kit.tsx +462 -12
  176. package/registry/search-bar.json +1 -0
  177. package/registry/search-results.json +4 -0
  178. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +11 -17
  179. package/registry/segmented-control/files/src/lib/kit.tsx +462 -12
  180. package/registry/segmented-control.json +1 -0
  181. package/registry/select/files/src/components/ui/select.tsx +107 -104
  182. package/registry/select/files/src/lib/kit.tsx +462 -12
  183. package/registry/select.json +6 -0
  184. package/registry/settings.json +1 -0
  185. package/registry/sign-in.json +1 -0
  186. package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
  187. package/registry/skeleton/files/src/lib/kit.tsx +462 -12
  188. package/registry/skeleton.json +1 -0
  189. package/registry/slide-to-confirm.json +1 -0
  190. package/registry/slider/files/src/components/ui/slider.tsx +106 -108
  191. package/registry/slider/files/src/lib/kit.tsx +462 -12
  192. package/registry/slider.json +5 -0
  193. package/registry/spin-wheel.json +1 -0
  194. package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
  195. package/registry/spinner/files/src/lib/kit.tsx +462 -12
  196. package/registry/spinner.json +6 -0
  197. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
  198. package/registry/stateful-button/files/src/lib/kit.tsx +462 -12
  199. package/registry/stateful-button.json +5 -0
  200. package/registry/stepper/files/src/components/ui/stepper.tsx +40 -38
  201. package/registry/stepper/files/src/lib/kit.tsx +462 -12
  202. package/registry/stepper.json +1 -0
  203. package/registry/swipe-deck.json +1 -0
  204. package/registry/swipeable-row.json +4 -0
  205. package/registry/switch/files/src/components/ui/switch.tsx +41 -47
  206. package/registry/switch/files/src/lib/kit.tsx +462 -12
  207. package/registry/switch.json +1 -0
  208. package/registry/tabs/files/src/components/ui/tabs.tsx +61 -64
  209. package/registry/tabs/files/src/lib/kit.tsx +462 -12
  210. package/registry/tabs.json +1 -0
  211. package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
  212. package/registry/text-animation/files/src/lib/kit.tsx +462 -12
  213. package/registry/text-animation.json +7 -0
  214. package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
  215. package/registry/textarea/files/src/lib/kit.tsx +462 -12
  216. package/registry/textarea.json +1 -0
  217. package/registry/ticket-card.json +1 -0
  218. package/registry/ticket-shape/files/src/lib/kit.tsx +462 -12
  219. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
  220. package/registry/ticket-shape.json +1 -0
  221. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +80 -101
  222. package/registry/tilt-card/files/src/lib/kit.tsx +462 -12
  223. package/registry/tilt-card.json +1 -0
  224. package/registry/time-picker/files/src/components/ui/time-picker.tsx +136 -131
  225. package/registry/time-picker/files/src/lib/kit.tsx +462 -12
  226. package/registry/time-picker.json +1 -0
  227. package/registry/timeline/files/src/components/ui/avatar.tsx +89 -74
  228. package/registry/timeline/files/src/components/ui/timeline.tsx +49 -57
  229. package/registry/timeline/files/src/lib/kit.tsx +462 -12
  230. package/registry/timeline-chart.json +1 -0
  231. package/registry/timeline.json +1 -0
  232. package/registry/toast/files/src/components/ui/toast.tsx +543 -465
  233. package/registry/toast/files/src/lib/kit.tsx +462 -12
  234. package/registry/toast.json +7 -5
  235. package/registry/treemap.json +1 -0
  236. package/registry/waterfall.json +1 -0
  237. package/registry/wheel-list.json +1 -0
  238. package/schema/components.schema.json +1 -0
  239. package/registry/alert/files/src/components/ui/button.tsx +0 -303
  240. package/registry/confetti/files/src/lib/kit.tsx +0 -222
  241. package/registry/grain/files/src/lib/kit.tsx +0 -222
  242. package/registry/toast/files/src/components/ui/button.tsx +0 -303
@@ -0,0 +1,832 @@
1
+ import { createAudioPlayer, type AudioPlayer } from 'expo-audio';
2
+ import { File, Paths } from 'expo-file-system';
3
+ import * as Haptics from 'expo-haptics';
4
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
5
+ import {
6
+ AccessibilityInfo,
7
+ Animated,
8
+ Easing,
9
+ Platform,
10
+ StyleSheet,
11
+ Text,
12
+ View,
13
+ useWindowDimensions,
14
+ type LayoutChangeEvent,
15
+ type StyleProp,
16
+ type TextStyle,
17
+ type ViewStyle,
18
+ } from 'react-native';
19
+
20
+ import { motion, useKit, usePanResponder } from '@/lib/kit';
21
+
22
+ /** A wall-clock time, 24-hour: `{ hours: 0–23, minutes: 0–59 }`. */
23
+ export interface TimeValue {
24
+ hours: number;
25
+ minutes: number;
26
+ }
27
+ /** A calendar day without a time: `{ y, m: 0–11, d: 1–31 }`. */
28
+ export interface Day {
29
+ y: number;
30
+ m: number;
31
+ d: number;
32
+ }
33
+ export type RulerTimePickerSize = 'sm' | 'md' | 'lg';
34
+
35
+ interface CommonProps {
36
+ /** Small line above the value, e.g. 'Reminder time:'. */
37
+ label?: string;
38
+ /** Time: 40 · 52 · 64pt. Date: 34 · 44 · 54pt (default 'md'). */
39
+ size?: RulerTimePickerSize;
40
+ /** Centre needle colour (default theme accent). */
41
+ accent?: string;
42
+ /** A light tap on every step, and a firmer one on each whole hour / new month (default true). */
43
+ haptics?: boolean;
44
+ /** A soft mechanical tick on every step, like a watch crown (default true; plays alongside other audio). */
45
+ sound?: boolean;
46
+ accessibilityLabel?: string;
47
+ style?: StyleProp<ViewStyle>;
48
+ /** The big value text (colour, weight, font family). */
49
+ textStyle?: StyleProp<TextStyle>;
50
+ }
51
+
52
+ export interface RulerTimeProps extends CommonProps {
53
+ /** 'time' (default): one tick per `step` minutes. */
54
+ mode?: 'time';
55
+ /** Controlled 24-hour time. */
56
+ value?: TimeValue;
57
+ /** Initial time when uncontrolled (default 09:00). */
58
+ defaultValue?: TimeValue;
59
+ /** Called on every step the ruler passes, while you drag. */
60
+ onChange?: (time: TimeValue) => void;
61
+ /** Minutes per tick (default 5). */
62
+ step?: number;
63
+ /** 12-hour clock with a rolling am / pm (default true). */
64
+ hour12?: boolean;
65
+ }
66
+
67
+ export interface RulerDateProps extends CommonProps {
68
+ /** 'date': one tick per day, with the 1st of each month marked. */
69
+ mode: 'date';
70
+ /** Controlled date (its time of day is kept on every change). */
71
+ value?: Date;
72
+ /** Initial date when uncontrolled (default today). */
73
+ defaultValue?: Date;
74
+ /** Called on every day the ruler passes, while you drag. */
75
+ onChange?: (date: Date) => void;
76
+ /** Earliest pickable day; the ruler resists past it. */
77
+ minDate?: Date;
78
+ /** Latest pickable day; the ruler resists past it. */
79
+ maxDate?: Date;
80
+ /** 'auto' shows the year only when it isn't this year (default). */
81
+ showYear?: 'auto' | 'always' | 'never';
82
+ /** BCP 47 tag for weekday and month names (default: device). */
83
+ locale?: string;
84
+ }
85
+
86
+ export type RulerTimePickerProps = RulerTimeProps | RulerDateProps;
87
+
88
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape.
89
+ const FONT_CAP = 1.35;
90
+ const DAY = 24 * 60;
91
+ const MS_DAY = 86400000;
92
+ const TIME_SIZES: Record<RulerTimePickerSize, number> = { sm: 40, md: 52, lg: 64 };
93
+ const DATE_SIZES: Record<RulerTimePickerSize, number> = { sm: 34, md: 44, lg: 54 };
94
+ // Ruler geometry: tick pitch (a time step / a day), tallest tick, the needle, the month label row.
95
+ // Wide enough that each 5-minute step takes a deliberate bit of finger travel.
96
+ const TIME_GAP = 16;
97
+ const DATE_GAP = 12;
98
+ const TICK_W = 1.5;
99
+ const TICK_H = 26;
100
+ const NEEDLE_H = 32;
101
+ const RULER_H = 44;
102
+ const LABEL_H = 18;
103
+ // Past a limit the ruler follows the finger at this fraction of the distance.
104
+ const RUBBER = 0.3;
105
+ const ROLL = { duration: 420, easing: Easing.bezier(0.32, 0.72, 0, 1), useNativeDriver: true };
106
+
107
+ const mod = (n: number, m: number) => ((n % m) + m) % m;
108
+ const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
109
+ const pad2 = (n: number) => String(n).padStart(2, '0');
110
+ const toMinutes = (t: TimeValue) => mod(Math.round(t.hours) * 60 + Math.round(t.minutes), DAY);
111
+ const fromMinutes = (m: number): TimeValue => ({ hours: Math.floor(mod(m, DAY) / 60), minutes: mod(m, DAY) % 60 });
112
+
113
+ /** The time `ticks` steps of `step` minutes away from `base` (minutes after midnight), wrapping round the day. */
114
+ export function stepTime(base: number, ticks: number, step: number) {
115
+ return mod(base + ticks * step, DAY);
116
+ }
117
+
118
+ /**
119
+ * `'02:15 am'` / `'14:15'` split into the glyphs the picker rolls. `period` is the key ('am' / 'pm');
120
+ * the spoken label names it with `periods` (default English, pass `useKit().labels` to localise).
121
+ */
122
+ export function timeGlyphs(minutes: number, hour12: boolean, periods: { am: string; pm: string } = { am: 'AM', pm: 'PM' }) {
123
+ const { hours, minutes: m } = fromMinutes(minutes);
124
+ const h = hour12 ? hours % 12 || 12 : hours;
125
+ const digits = pad2(h) + pad2(m);
126
+ return {
127
+ digits: digits.split(''),
128
+ period: hour12 ? (hours < 12 ? ('am' as const) : ('pm' as const)) : null,
129
+ label: `${pad2(h)}:${pad2(m)}${hour12 ? ` ${hours < 12 ? periods.am : periods.pm}` : ''}`,
130
+ };
131
+ }
132
+
133
+ export const dayOf = (date: Date): Day => ({ y: date.getFullYear(), m: date.getMonth(), d: date.getDate() });
134
+ const utc = (t: Day) => Date.UTC(t.y, t.m, t.d);
135
+
136
+ /** The day `n` days after `base` (negative for before); month and year lengths are handled. */
137
+ export function addDays(base: Day, n: number): Day {
138
+ const t = new Date(utc(base) + n * MS_DAY);
139
+ return { y: t.getUTCFullYear(), m: t.getUTCMonth(), d: t.getUTCDate() };
140
+ }
141
+
142
+ /** Whole days from `a` to `b` (negative when `b` is earlier), immune to daylight-saving shifts. */
143
+ export function daysBetween(a: Day, b: Day) {
144
+ return Math.round((utc(b) - utc(a)) / MS_DAY);
145
+ }
146
+
147
+ /** Offsets from `base`, within [from, to], that land on the 1st of a month. */
148
+ export function monthStarts(base: Day, from: number, to: number) {
149
+ const out: number[] = [];
150
+ let i = from - (addDays(base, from).d - 1);
151
+ while (i <= to) {
152
+ if (i >= from) out.push(i);
153
+ const t = addDays(base, i);
154
+ i += daysBetween(t, { y: t.m === 11 ? t.y + 1 : t.y, m: (t.m + 1) % 12, d: 1 });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ const EN_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
160
+ const EN_DAYS_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
161
+ const EN_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
162
+ const EN_MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
163
+ type Names = { days: string[]; daysLong: string[]; months: string[]; monthsLong: string[] };
164
+ const nameCache = new Map<string, Names>();
165
+
166
+ /** Weekday and month names for a locale, falling back to English where Intl isn't available. */
167
+ function namesFor(locale?: string): Names {
168
+ const key = locale ?? '';
169
+ const hit = nameCache.get(key);
170
+ if (hit) return hit;
171
+ let names: Names = { days: EN_DAYS, daysLong: EN_DAYS_LONG, months: EN_MONTHS, monthsLong: EN_MONTHS_LONG };
172
+ try {
173
+ const fmt = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' });
174
+ const [wd, wdl, mo, mol] = [fmt({ weekday: 'short' }), fmt({ weekday: 'long' }), fmt({ month: 'short' }), fmt({ month: 'long' })];
175
+ // 2023-01-01 was a Sunday.
176
+ const day = (i: number) => new Date(Date.UTC(2023, 0, 1 + i));
177
+ const month = (i: number) => new Date(Date.UTC(2023, i, 15));
178
+ names = {
179
+ days: EN_DAYS.map((_, i) => wd.format(day(i)).replace(/\.$/, '')),
180
+ daysLong: EN_DAYS.map((_, i) => wdl.format(day(i))),
181
+ months: EN_MONTHS.map((_, i) => mo.format(month(i)).replace(/\.$/, '')),
182
+ monthsLong: EN_MONTHS.map((_, i) => mol.format(month(i))),
183
+ };
184
+ } catch {
185
+ // Keep the English names.
186
+ }
187
+ nameCache.set(key, names);
188
+ return names;
189
+ }
190
+
191
+ /** `Thu, 29 Oct` (+ year) split into the pieces the picker rolls, plus a spoken label. */
192
+ export function dateParts(t: Day, showYear: 'auto' | 'always' | 'never' = 'auto', locale?: string, thisYear = new Date().getFullYear()) {
193
+ const n = namesFor(locale);
194
+ const wd = new Date(utc(t)).getUTCDay();
195
+ const year = showYear === 'always' || (showYear === 'auto' && t.y !== thisYear) ? String(t.y) : null;
196
+ return {
197
+ weekday: n.days[wd] ?? '',
198
+ day: String(t.d).split(''),
199
+ month: n.months[t.m] ?? '',
200
+ year,
201
+ label: `${n.daysLong[wd]}, ${t.d} ${n.monthsLong[t.m]} ${t.y}`,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * A time or date picker you scrub: drag (or fling) the tick ruler and every piece of the big value
207
+ * rolls to its next one with a motion blur. Ticks swell in a bell around the needle, the bump trails
208
+ * the drag, and it snaps to a step when you let go. `mode="date"` makes each tick a day, marks the
209
+ * 1st of every month and honours min / max dates with a rubber band.
210
+ */
211
+ export function RulerTimePicker(props: RulerTimePickerProps) {
212
+ return props.mode === 'date' ? <DateRuler {...props} /> : <TimeRuler {...props} />;
213
+ }
214
+
215
+ function TimeRuler({ value, defaultValue, onChange, label, step: rawStep = 5, hour12 = true, size = 'md', accent, haptics = true, sound = true, accessibilityLabel, style, textStyle }: RulerTimeProps) {
216
+ const { labels } = useKit();
217
+ const step = Math.max(1, Math.min(60, Math.round(rawStep) || 5));
218
+ const initial = toMinutes(value ?? defaultValue ?? { hours: 9, minutes: 0 });
219
+ // Minutes shown, and which way the last change went (rolls go up for later, down for earlier).
220
+ const [shown, setShown] = useState({ minutes: initial, dir: 1 as 1 | -1 });
221
+ const base = useRef(initial);
222
+ const live = useRef({ onChange, step, shown: initial });
223
+ useLayoutEffect(() => {
224
+ live.current = { onChange, step, shown: shown.minutes };
225
+ });
226
+
227
+ const ruler = useRuler({
228
+ gap: TIME_GAP,
229
+ haptics,
230
+ sound,
231
+ firm: (i) => stepTime(base.current, i, live.current.step) % 60 === 0,
232
+ onStep: (i, dir) => {
233
+ const minutes = stepTime(base.current, i, live.current.step);
234
+ live.current.shown = minutes;
235
+ setShown({ minutes, dir });
236
+ live.current.onChange?.(fromMinutes(minutes));
237
+ },
238
+ });
239
+
240
+ // A controlled value that isn't our own echo re-bases the ruler under the needle.
241
+ const external = value !== undefined ? toMinutes(value) : null;
242
+ useEffect(() => {
243
+ if (external === null || external === live.current.shown) return;
244
+ const forward = mod(external - live.current.shown, DAY) <= DAY / 2;
245
+ base.current = external;
246
+ live.current.shown = external;
247
+ ruler.reset();
248
+ setShown({ minutes: external, dir: forward ? 1 : -1 });
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ }, [external]);
251
+
252
+ const fontSize = TIME_SIZES[size];
253
+ const { box, glyph } = useGlyph(fontSize, textStyle);
254
+ const t = timeGlyphs(shown.minutes, hour12, labels);
255
+ const [h1 = '', h2 = '', m1 = '', m2 = ''] = t.digits;
256
+ const roll = { dir: shown.dir, height: box, style: glyph, reduce: ruler.reduce };
257
+
258
+ return (
259
+ <Frame ruler={ruler} label={label} name={accessibilityLabel ?? label?.replace(/:\s*$/, '') ?? labels.time} spoken={t.label} accent={accent} rulerWidth={fontSize * 5.6} style={style}>
260
+ <View style={[styles.row, { height: box }]}>
261
+ <Roll text={h1} {...roll} />
262
+ <Roll text={h2} {...roll} />
263
+ <Text maxFontSizeMultiplier={FONT_CAP} style={glyph}>:</Text>
264
+ <Roll text={m1} {...roll} />
265
+ <Roll text={m2} {...roll} />
266
+ {t.period ? (
267
+ <View style={{ marginLeft: fontSize * 0.24 }}>
268
+ {/* Shown lowercase, as the design has it ('am' / 'pm'); the text comes from the kit labels. */}
269
+ <Roll text={labels[t.period].toLowerCase()} {...roll} />
270
+ </View>
271
+ ) : null}
272
+ </View>
273
+ </Frame>
274
+ );
275
+ }
276
+
277
+ function DateRuler({ value, defaultValue, onChange, label, minDate, maxDate, showYear = 'auto', locale, size = 'md', accent, haptics = true, sound = true, accessibilityLabel, style, textStyle }: RulerDateProps) {
278
+ const [start] = useState(() => value ?? defaultValue ?? new Date());
279
+ // Time of day handed back with every change, so picking a date never moves a set time.
280
+ const clock = useRef({ h: start.getHours(), min: start.getMinutes() });
281
+ const [shown, setShown] = useState({ day: dayOf(start), index: 0, dir: 1 as 1 | -1 });
282
+ // The day at tick 0; a controlled value from outside re-bases the ruler on it.
283
+ const [base, setBase] = useState(() => dayOf(start));
284
+ const live = useRef({ onChange, key: utc(shown.day) });
285
+ useLayoutEffect(() => {
286
+ live.current = { onChange, key: utc(shown.day) };
287
+ });
288
+
289
+ // Limits as offsets from the base day.
290
+ const lo = minDate ? daysBetween(base, dayOf(minDate)) : -Infinity;
291
+ const hi = maxDate ? Math.max(lo, daysBetween(base, dayOf(maxDate))) : Infinity;
292
+
293
+ const ruler = useRuler({
294
+ gap: DATE_GAP,
295
+ haptics,
296
+ lo,
297
+ hi,
298
+ sound,
299
+ firm: (i) => addDays(base, i).d === 1,
300
+ onStep: (i, dir) => {
301
+ const day = addDays(base, i);
302
+ live.current.key = utc(day);
303
+ setShown({ day, index: i, dir });
304
+ live.current.onChange?.(new Date(day.y, day.m, day.d, clock.current.h, clock.current.min));
305
+ },
306
+ });
307
+
308
+ // A controlled value that isn't our own echo re-bases the ruler under the needle.
309
+ const external = value ? utc(dayOf(value)) : null;
310
+ useEffect(() => {
311
+ if (external === null || external === live.current.key || !value) return;
312
+ const day = dayOf(value);
313
+ clock.current = { h: value.getHours(), min: value.getMinutes() };
314
+ const dir = external > live.current.key ? 1 : -1;
315
+ setBase(day);
316
+ live.current.key = external;
317
+ ruler.reset();
318
+ setShown({ day, index: 0, dir });
319
+ // eslint-disable-next-line react-hooks/exhaustive-deps
320
+ }, [external]);
321
+
322
+ const { colors, font, labels } = useKit();
323
+ const fontSize = DATE_SIZES[size];
324
+ const { box, glyph } = useGlyph(fontSize, textStyle);
325
+ const gap = fontSize * 0.24;
326
+ const p = dateParts(shown.day, showYear, locale);
327
+ const names = namesFor(locale);
328
+ const roll = { dir: shown.dir, height: box, style: glyph, reduce: ruler.reduce };
329
+
330
+ // Month starts near the needle, as offsets from the base day; they slide with `pos` natively.
331
+ const reach = ruler.width ? Math.ceil(ruler.width / 2 / DATE_GAP) + 45 : 0;
332
+ const months = useMemo(() => {
333
+ if (!reach) return [];
334
+ return monthStarts(base, shown.index - reach, shown.index + reach).map((i) => {
335
+ const t = addDays(base, i);
336
+ return { i, name: t.m === 0 ? `${names.months[0] ?? ''} ${String(t.y).slice(2)}` : (names.months[t.m] ?? '') };
337
+ });
338
+ }, [shown.index, reach, names, base]);
339
+
340
+ return (
341
+ <Frame
342
+ ruler={ruler}
343
+ label={label}
344
+ name={accessibilityLabel ?? label?.replace(/:\s*$/, '') ?? labels.date}
345
+ spoken={p.label}
346
+ accent={accent}
347
+ rulerWidth={fontSize * 7}
348
+ extra={LABEL_H}
349
+ marks={<MonthMarks width={ruler.width} pos={ruler.pos} months={months} tick={colors.foreground} text={colors.mutedForeground} family={font.family} />}
350
+ style={style}
351
+ >
352
+ <View style={[styles.row, { height: box }]}>
353
+ <Roll text={p.weekday} {...roll} />
354
+ <Text maxFontSizeMultiplier={FONT_CAP} style={glyph}>,</Text>
355
+ <View style={[styles.row, { marginLeft: gap }]}>
356
+ {p.day.map((c, i) => (
357
+ // Keyed from the right so the units digit stays put when the day gains a digit.
358
+ <Roll key={p.day.length - i} text={c} {...roll} />
359
+ ))}
360
+ </View>
361
+ <View style={{ marginLeft: gap }}>
362
+ <Roll text={p.month} {...roll} />
363
+ </View>
364
+ {p.year ? (
365
+ <View style={{ marginLeft: gap }}>
366
+ <Roll text={p.year} {...roll} />
367
+ </View>
368
+ ) : null}
369
+ </View>
370
+ </Frame>
371
+ );
372
+ }
373
+
374
+ type Ruler = ReturnType<typeof useRuler>;
375
+
376
+ interface RulerOptions {
377
+ /** Points per tick. */
378
+ gap: number;
379
+ haptics: boolean;
380
+ sound: boolean;
381
+ /** Lowest / highest tick (default unbounded). */
382
+ lo?: number;
383
+ hi?: number;
384
+ /** Ticks that get a firmer haptic (a whole hour, the 1st of a month). */
385
+ firm?: (i: number) => boolean;
386
+ /** The tick under the needle changed; `dir` is 1 for later, -1 for earlier. */
387
+ onStep: (i: number, dir: 1 | -1) => void;
388
+ }
389
+
390
+ /**
391
+ * The ruler engine: an unbounded tick position driven by drag, fling and snap, with a trailing
392
+ * "lag" for the bell bump, rubber-banding past optional limits and a haptic on every tick.
393
+ */
394
+ function useRuler({ gap, haptics, sound, lo = -Infinity, hi = Infinity, firm, onStep }: RulerOptions) {
395
+ const reduce = useReduceMotion();
396
+ const tick = useTickSound(sound);
397
+ const [pos] = useState(() => new Animated.Value(0));
398
+ const [lag] = useState(() => new Animated.Value(0));
399
+ const index = useRef(0);
400
+ const posNow = useRef(0);
401
+ const moving = useRef(false);
402
+ const flinging = useRef(false);
403
+ const live = useRef({ gap, haptics, lo, hi, firm, onStep, tick });
404
+ useLayoutEffect(() => {
405
+ live.current = { gap, haptics, lo, hi, firm, onStep, tick };
406
+ });
407
+ const lagMax = gap * 2.5;
408
+
409
+ const snap = (to: number) => {
410
+ moving.current = true;
411
+ Animated.spring(pos, { toValue: to, useNativeDriver: true, ...motion.gentle }).start(({ finished }) => {
412
+ if (finished) moving.current = false;
413
+ });
414
+ };
415
+ const snapRef = useRef(snap);
416
+ useLayoutEffect(() => {
417
+ snapRef.current = snap;
418
+ });
419
+ const settle = (v: number) => clamp(Math.round(v), live.current.lo, live.current.hi);
420
+
421
+ useEffect(() => {
422
+ const id = pos.addListener(({ value: v }) => {
423
+ posNow.current = v;
424
+ const { lo: min, hi: max } = live.current;
425
+ // A fling that runs past a limit is caught and settled on it.
426
+ if (flinging.current && (v < min - 0.5 || v > max + 0.5)) {
427
+ flinging.current = false;
428
+ pos.stopAnimation();
429
+ snapRef.current(clamp(Math.round(v), min, max));
430
+ }
431
+ const i = clamp(Math.round(v), min, max);
432
+ if (i === index.current) return;
433
+ const dir = i > index.current ? 1 : -1;
434
+ index.current = i;
435
+ const firmTick = live.current.firm?.(i) ?? false;
436
+ if (live.current.haptics) {
437
+ Haptics.impactAsync(firmTick ? Haptics.ImpactFeedbackStyle.Medium : Haptics.ImpactFeedbackStyle.Light).catch(() => {});
438
+ }
439
+ live.current.tick(firmTick);
440
+ live.current.onStep(i, dir);
441
+ });
442
+ return () => pos.removeListener(id);
443
+ }, [pos]);
444
+
445
+ // Where the ruler was when the finger came down (g.dx counts from the touch, so the ruler stays
446
+ // under the finger even though a sideways drag is claimed a few points in).
447
+ const drag = useRef(0);
448
+ const unlag = () => Animated.spring(lag, { toValue: 0, useNativeDriver: true, ...motion.gentle }).start();
449
+ const pan = usePanResponder({
450
+ // A touch only claims the ruler straight away to catch a fling; otherwise a sideways drag does.
451
+ onStartShouldSetPanResponder: () => moving.current,
452
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 4 && Math.abs(g.dx) > Math.abs(g.dy),
453
+ onPanResponderTerminationRequest: () => false,
454
+ onPanResponderGrant: (_, g) => {
455
+ flinging.current = false;
456
+ pos.stopAnimation((v) => (posNow.current = v));
457
+ lag.stopAnimation();
458
+ moving.current = true;
459
+ drag.current = posNow.current + g.dx / live.current.gap;
460
+ },
461
+ onPanResponderMove: (_, g) => {
462
+ const { lo: min, hi: max, gap: px } = live.current;
463
+ const raw = drag.current - g.dx / px;
464
+ pos.setValue(raw < min ? min - (min - raw) * RUBBER : raw > max ? max + (raw - max) * RUBBER : raw);
465
+ lag.setValue(clamp(-g.vx * 14, -lagMax, lagMax));
466
+ },
467
+ onPanResponderRelease: (_, g) => {
468
+ unlag();
469
+ const { lo: min, hi: max, gap: px } = live.current;
470
+ const velocity = -g.vx / px; // ticks per ms
471
+ const outside = posNow.current < min || posNow.current > max;
472
+ if (outside || Math.abs(velocity) < 0.03) return snapRef.current(settle(posNow.current));
473
+ flinging.current = true;
474
+ Animated.decay(pos, { velocity, deceleration: 0.993, useNativeDriver: true }).start(({ finished }) => {
475
+ if (!finished || !flinging.current) return;
476
+ flinging.current = false;
477
+ pos.stopAnimation((v) => snapRef.current(settle(v)));
478
+ });
479
+ },
480
+ onPanResponderTerminate: () => {
481
+ unlag();
482
+ snapRef.current(settle(posNow.current));
483
+ },
484
+ });
485
+
486
+ const [width, setWidth] = useState(0);
487
+ return {
488
+ pos,
489
+ lag,
490
+ pan,
491
+ reduce,
492
+ width,
493
+ gap,
494
+ onLayout: (e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width)),
495
+ /** One step for screen-reader increment / decrement, within the limits. */
496
+ nudge: (dir: 1 | -1) => {
497
+ flinging.current = false;
498
+ pos.stopAnimation((v) => snapRef.current(settle(Math.round(v) + dir)));
499
+ },
500
+ /** Puts tick 0 back under the needle without reporting a step (after a re-base). */
501
+ reset: () => {
502
+ pos.stopAnimation();
503
+ moving.current = false;
504
+ flinging.current = false;
505
+ index.current = 0;
506
+ posNow.current = 0;
507
+ pos.setValue(0);
508
+ },
509
+ };
510
+ }
511
+
512
+ /** Big-value text metrics; the style is stable so memoised rolls only re-render on their own change. */
513
+ function useGlyph(fontSize: number, textStyle?: StyleProp<TextStyle>) {
514
+ const { colors, font } = useKit();
515
+ // Text scales with the user's size (capped), so the roll window does too.
516
+ const { fontScale } = useWindowDimensions();
517
+ const lineHeight = Math.round(fontSize * 1.17);
518
+ const box = lineHeight * Math.min(fontScale, FONT_CAP);
519
+ const glyph = useMemo<StyleProp<TextStyle>>(
520
+ () => [
521
+ styles.glyph,
522
+ { fontSize, lineHeight, height: box, color: colors.foreground, fontFamily: font.family, letterSpacing: -fontSize * 0.02 },
523
+ textStyle,
524
+ ],
525
+ [fontSize, lineHeight, box, colors.foreground, font.family, textStyle],
526
+ );
527
+ return { box, glyph };
528
+ }
529
+
530
+ interface FrameProps {
531
+ ruler: Ruler;
532
+ label?: string | undefined;
533
+ name: string;
534
+ spoken: string;
535
+ accent?: string | undefined;
536
+ rulerWidth: number;
537
+ /** Extra height under the ticks (month labels). */
538
+ extra?: number | undefined;
539
+ marks?: React.ReactNode;
540
+ style?: StyleProp<ViewStyle> | undefined;
541
+ children: React.ReactNode;
542
+ }
543
+
544
+ /** Label, the rolling value, and the ruler with its needle; the whole frame takes the drag. */
545
+ function Frame({ ruler, label, name, spoken, accent, rulerWidth, extra = 0, marks, style, children }: FrameProps) {
546
+ const { colors, radius, font } = useKit();
547
+ const { width } = ruler;
548
+ return (
549
+ <View
550
+ accessible
551
+ accessibilityRole="adjustable"
552
+ accessibilityLabel={name}
553
+ accessibilityValue={{ text: spoken }}
554
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
555
+ onAccessibilityAction={(e) => ruler.nudge(e.nativeEvent.actionName === 'increment' ? 1 : -1)}
556
+ style={[styles.root, styles.grab, style]}
557
+ {...ruler.pan}
558
+ >
559
+ {label ? <Text style={[styles.label, { color: colors.foreground, fontFamily: font.family }]}>{label}</Text> : null}
560
+ {children}
561
+ <View style={[styles.ruler, { maxWidth: rulerWidth, height: RULER_H + extra }]} onLayout={ruler.onLayout}>
562
+ {width > 0 ? (
563
+ <>
564
+ <Ticks width={width} gap={ruler.gap} pos={ruler.pos} lag={ruler.lag} color={colors.mutedForeground} />
565
+ {marks}
566
+ </>
567
+ ) : null}
568
+ <View
569
+ pointerEvents="none"
570
+ style={[styles.needle, { backgroundColor: accent ?? colors.accent, borderRadius: radius.capsule(3), left: width / 2 - 1.5 }]}
571
+ />
572
+ </View>
573
+ </View>
574
+ );
575
+ }
576
+
577
+ /** Ticks fade out toward both ends of the ruler. */
578
+ // ReturnType keeps this compiling on React Native 0.86 (generic AnimatedMultiplication) and 0.88+ (not generic).
579
+ function fade(x: Animated.AnimatedInterpolation<number> | ReturnType<typeof Animated.multiply>, half: number, gap: number) {
580
+ return x.interpolate({
581
+ inputRange: [-half, -half * 0.55, -gap * 2, 0, gap * 2, half * 0.55, half],
582
+ outputRange: [0, 0.35, 0.85, 1, 0.85, 0.35, 0],
583
+ extrapolate: 'clamp',
584
+ });
585
+ }
586
+
587
+ interface TicksProps {
588
+ width: number;
589
+ gap: number;
590
+ pos: Animated.Value;
591
+ lag: Animated.Value;
592
+ color: string;
593
+ }
594
+
595
+ /**
596
+ * A fixed pool of ticks that slide by the fractional ruler position and wrap — so the ruler is
597
+ * endless without ever re-rendering. Height swells in a bell round the (lagging) needle; ticks
598
+ * fade out toward both ends. Everything runs on the native driver.
599
+ */
600
+ const Ticks = React.memo(function Ticks({ width, gap, pos, lag, color }: TicksProps) {
601
+ const half = width / 2;
602
+ const slots = Math.ceil(half / gap) + 1;
603
+ const ticks = useMemo(() => {
604
+ const frac = Animated.modulo(pos, 1);
605
+ return Array.from({ length: slots * 2 + 1 }, (_, n) => {
606
+ const k = n - slots;
607
+ const x = Animated.subtract(k, frac).interpolate({ inputRange: [-1, 1], outputRange: [-gap, gap], extrapolate: 'extend' });
608
+ const fromBump = Animated.subtract(x, lag);
609
+ return {
610
+ k,
611
+ x,
612
+ scaleY: fromBump.interpolate({
613
+ inputRange: [-gap * 6, -gap * 3, -gap * 1.5, 0, gap * 1.5, gap * 3, gap * 6],
614
+ outputRange: [0.34, 0.46, 0.78, 1, 0.78, 0.46, 0.34],
615
+ extrapolate: 'clamp',
616
+ }),
617
+ opacity: fade(x, half, gap),
618
+ };
619
+ });
620
+ }, [pos, lag, slots, half, gap]);
621
+
622
+ return (
623
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
624
+ {ticks.map(({ k, x, scaleY, opacity }) => (
625
+ <Animated.View
626
+ key={k}
627
+ style={[styles.tick, { left: half - TICK_W / 2, backgroundColor: color, opacity, transform: [{ translateX: x }, { scaleY }] }]}
628
+ />
629
+ ))}
630
+ </View>
631
+ );
632
+ });
633
+
634
+ interface MonthMarksProps {
635
+ width: number;
636
+ pos: Animated.Value;
637
+ months: { i: number; name: string }[];
638
+ tick: string;
639
+ text: string;
640
+ family?: string | undefined;
641
+ }
642
+
643
+ /** A taller tick and the month's name under the 1st of each month, riding the ruler natively. */
644
+ const MonthMarks = React.memo(function MonthMarks({ width, pos, months, tick, text, family }: MonthMarksProps) {
645
+ const half = width / 2;
646
+ return (
647
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
648
+ {months.map(({ i, name }) => {
649
+ const x = Animated.multiply(Animated.subtract(i, pos), DATE_GAP);
650
+ return (
651
+ <Animated.View key={i} style={[styles.mark, { left: half - 30, opacity: fade(x, half, DATE_GAP), transform: [{ translateX: x }] }]}>
652
+ <View style={[styles.markTick, { backgroundColor: tick }]} />
653
+ <Text maxFontSizeMultiplier={FONT_CAP} numberOfLines={1} style={[styles.markText, { color: text, fontFamily: family }]}>
654
+ {name}
655
+ </Text>
656
+ </Animated.View>
657
+ );
658
+ })}
659
+ </View>
660
+ );
661
+ });
662
+
663
+ interface RollProps {
664
+ text: string;
665
+ dir: 1 | -1;
666
+ height: number;
667
+ style: StyleProp<TextStyle>;
668
+ reduce: boolean;
669
+ }
670
+
671
+ // Ghost copies stacked above and below a glyph fake a vertical motion blur while it rolls: many
672
+ // close, faint copies (a few pt apart) blend into a soft smear instead of visible stripes.
673
+ const GHOSTS: [number, number][] = Array.from({ length: 7 }, (_, i) => {
674
+ const d = (i + 1) * 0.045;
675
+ const a = 0.13 * (1 - i / 7);
676
+ return [
677
+ [-d, a],
678
+ [d, a],
679
+ ] as [number, number][];
680
+ }).flat();
681
+
682
+ /**
683
+ * One piece of the value that rolls to its next one: the old one slides out, the new one in from
684
+ * the other side, both smeared by ghost copies that fade as the roll settles.
685
+ */
686
+ const Roll = React.memo(function Roll({ text, dir, height, style, reduce }: RollProps) {
687
+ const [t] = useState(() => new Animated.Value(1));
688
+ const last = useRef(text);
689
+ const [pair, setPair] = useState({ prev: text, cur: text, dir });
690
+
691
+ useEffect(() => {
692
+ if (text === last.current) return;
693
+ setPair({ prev: last.current, cur: text, dir });
694
+ last.current = text;
695
+ t.stopAnimation();
696
+ if (reduce) return t.setValue(1);
697
+ t.setValue(0);
698
+ Animated.timing(t, { toValue: 1, ...ROLL }).start();
699
+ }, [text, dir, reduce, t]);
700
+
701
+ const blur = t.interpolate({ inputRange: [0, 0.55, 1], outputRange: [1, 0.8, 0] });
702
+ const sharp = t.interpolate({ inputRange: [0, 0.55, 1], outputRange: [0.08, 0.3, 1] });
703
+ const layer = (s: string, y: Animated.AnimatedInterpolation<number>, outgoing: boolean) => (
704
+ <Animated.View style={[outgoing && styles.out, { transform: [{ translateY: y }] }]}>
705
+ {GHOSTS.map(([dy, a], i) => (
706
+ <Animated.Text
707
+ key={i}
708
+ maxFontSizeMultiplier={FONT_CAP}
709
+ numberOfLines={1}
710
+ style={[style, styles.ghost, { opacity: Animated.multiply(blur, a), transform: [{ translateY: dy * height }] }]}
711
+ >
712
+ {s}
713
+ </Animated.Text>
714
+ ))}
715
+ <Animated.Text maxFontSizeMultiplier={FONT_CAP} numberOfLines={1} style={[style, { opacity: sharp }]}>
716
+ {s}
717
+ </Animated.Text>
718
+ </Animated.View>
719
+ );
720
+
721
+ return (
722
+ <View style={{ height, overflow: 'hidden', alignItems: 'center' }}>
723
+ {layer(pair.cur, t.interpolate({ inputRange: [0, 1], outputRange: [pair.dir * height * 0.9, 0] }), false)}
724
+ {pair.prev !== pair.cur
725
+ ? layer(pair.prev, t.interpolate({ inputRange: [0, 1], outputRange: [0, -pair.dir * height * 0.9] }), true)
726
+ : null}
727
+ </View>
728
+ );
729
+ });
730
+
731
+ // A ~20 ms tick synthesised once into the cache as a WAV (a decaying 2.6 kHz ping with a little
732
+ // noise), so the component ships no audio file. A few players take turns so fast scrubs overlap.
733
+ const TICK_RATE = 22050;
734
+ let tickUri: string | null = null;
735
+
736
+ function tickWav() {
737
+ const n = Math.round(TICK_RATE * 0.02);
738
+ const bytes = new Uint8Array(44 + n * 2);
739
+ const v = new DataView(bytes.buffer);
740
+ const text = (at: number, s: string) => [...s].forEach((c, i) => v.setUint8(at + i, c.charCodeAt(0)));
741
+ text(0, 'RIFF');
742
+ v.setUint32(4, 36 + n * 2, true);
743
+ text(8, 'WAVEfmt ');
744
+ v.setUint32(16, 16, true);
745
+ v.setUint16(20, 1, true); // PCM
746
+ v.setUint16(22, 1, true); // mono
747
+ v.setUint32(24, TICK_RATE, true);
748
+ v.setUint32(28, TICK_RATE * 2, true);
749
+ v.setUint16(32, 2, true);
750
+ v.setUint16(34, 16, true);
751
+ text(36, 'data');
752
+ v.setUint32(40, n * 2, true);
753
+ let seed = 7;
754
+ for (let i = 0; i < n; i++) {
755
+ const t = i / TICK_RATE;
756
+ seed = (seed * 16807) % 2147483647;
757
+ const noise = (seed / 2147483647) * 2 - 1;
758
+ const sample = (Math.sin(2 * Math.PI * 2600 * t) * 0.7 + noise * 0.3) * Math.exp(-t * 320);
759
+ v.setInt16(44 + i * 2, Math.round(sample * 0.55 * 32767), true);
760
+ }
761
+ return bytes;
762
+ }
763
+
764
+ function tickSource() {
765
+ if (tickUri) return tickUri;
766
+ const file = new File(Paths.cache, 'expokit-ruler-tick.wav');
767
+ if (!file.exists) file.writeSync(tickWav());
768
+ tickUri = file.uri;
769
+ return tickUri;
770
+ }
771
+
772
+ /** Returns `tick(firm)`: plays the tick (louder when firm), at most one every 25 ms. No-op on web or when off. */
773
+ function useTickSound(enabled: boolean) {
774
+ const pool = useRef<AudioPlayer[]>([]);
775
+ const state = useRef({ next: 0, last: 0 });
776
+ useEffect(() => {
777
+ if (!enabled || Platform.OS === 'web') return;
778
+ try {
779
+ const uri = tickSource();
780
+ pool.current = [0, 1, 2].map(() => createAudioPlayer(uri));
781
+ } catch {
782
+ pool.current = [];
783
+ }
784
+ return () => {
785
+ pool.current.forEach((p) => p.remove());
786
+ pool.current = [];
787
+ };
788
+ }, [enabled]);
789
+ return (firm: boolean) => {
790
+ const now = Date.now();
791
+ if (!pool.current.length || now - state.current.last < 25) return;
792
+ state.current.last = now;
793
+ const player = pool.current[state.current.next++ % pool.current.length];
794
+ if (!player) return;
795
+ player.volume = firm ? 0.9 : 0.5;
796
+ player.seekTo(0).catch(() => {});
797
+ player.play();
798
+ };
799
+ }
800
+
801
+ function useReduceMotion() {
802
+ const [reduce, setReduce] = useState(false);
803
+ useEffect(() => {
804
+ let alive = true;
805
+ AccessibilityInfo.isReduceMotionEnabled()
806
+ .then((r) => alive && setReduce(r))
807
+ .catch(() => {});
808
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
809
+ return () => {
810
+ alive = false;
811
+ sub.remove();
812
+ };
813
+ }, []);
814
+ return reduce;
815
+ }
816
+
817
+ const styles = StyleSheet.create({
818
+ root: { alignItems: 'center', alignSelf: 'stretch', paddingVertical: 8 },
819
+ grab: Platform.OS === 'web' ? ({ userSelect: 'none', cursor: 'grab', touchAction: 'pan-y' } as object) : {},
820
+ label: { fontSize: 15, lineHeight: 20, fontWeight: '500', marginBottom: 2 },
821
+ row: { flexDirection: 'row', alignItems: 'flex-start' },
822
+ glyph: { fontWeight: '600', fontVariant: ['tabular-nums'], includeFontPadding: false, textAlign: 'center' },
823
+ ghost: { position: 'absolute', left: 0, right: 0, top: 0 },
824
+ // The outgoing piece may be wider than the incoming one (Wed → Fri): centred, it spills evenly.
825
+ out: { position: 'absolute', top: 0, left: -200, right: -200, alignItems: 'center' },
826
+ ruler: { width: '100%', marginTop: 14, overflow: 'hidden' },
827
+ tick: { position: 'absolute', top: (RULER_H - TICK_H) / 2, width: TICK_W, height: TICK_H, borderRadius: TICK_W / 2 },
828
+ needle: { position: 'absolute', top: (RULER_H - NEEDLE_H) / 2, width: 3, height: NEEDLE_H },
829
+ mark: { position: 'absolute', top: (RULER_H - TICK_H) / 2 - 2, width: 60, alignItems: 'center' },
830
+ markTick: { width: 2, height: TICK_H + 4, borderRadius: 1, opacity: 0.55 },
831
+ markText: { fontSize: 11, lineHeight: 14, fontWeight: '600', marginTop: (RULER_H - TICK_H) / 2, letterSpacing: 0.2 },
832
+ });