panelui-native 0.35.2 → 0.36.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 (39) hide show
  1. package/lib/module/components/input/index.js +135 -32
  2. package/lib/module/components/input/index.js.map +1 -1
  3. package/lib/module/components/otp-input/index.js +4 -1
  4. package/lib/module/components/otp-input/index.js.map +1 -1
  5. package/lib/module/components/panelside/index.js +1 -1
  6. package/lib/module/components/panelside/index.js.map +1 -1
  7. package/lib/module/components/textarea/index.js +3 -1
  8. package/lib/module/components/textarea/index.js.map +1 -1
  9. package/lib/module/components/time-picker/index.js +801 -0
  10. package/lib/module/components/time-picker/index.js.map +1 -0
  11. package/lib/module/icons/index.js +59 -0
  12. package/lib/module/icons/index.js.map +1 -1
  13. package/lib/module/index.js +3 -1
  14. package/lib/module/index.js.map +1 -1
  15. package/lib/module/utils/time.js +180 -0
  16. package/lib/module/utils/time.js.map +1 -0
  17. package/lib/typescript/src/components/input/index.d.ts +92 -0
  18. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  19. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -1
  20. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -1
  21. package/lib/typescript/src/components/time-picker/index.d.ts +104 -0
  22. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -0
  23. package/lib/typescript/src/icons/index.d.ts +4 -0
  24. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  25. package/lib/typescript/src/index.d.ts +3 -1
  26. package/lib/typescript/src/index.d.ts.map +1 -1
  27. package/lib/typescript/src/utils/time.d.ts +88 -0
  28. package/lib/typescript/src/utils/time.d.ts.map +1 -0
  29. package/package.json +1 -1
  30. package/src/components/input/index.tsx +171 -29
  31. package/src/components/otp-input/index.tsx +4 -1
  32. package/src/components/panelside/index.tsx +1 -1
  33. package/src/components/textarea/index.tsx +3 -1
  34. package/src/components/time-picker/index.tsx +971 -0
  35. package/src/icons/index.tsx +47 -0
  36. package/src/index.ts +25 -0
  37. package/src/theme/use-theme.ts +1 -1
  38. package/src/utils/time.ts +192 -0
  39. package/theme.css +32 -0
@@ -0,0 +1,971 @@
1
+ /**
2
+ * TimePicker — a time of day behind a trigger, in one of three faces.
3
+ *
4
+ * ```tsx
5
+ * const [time, setTime] = useState<TimeValue>();
6
+ *
7
+ * <TimePicker value={time} onValueChange={setTime} />
8
+ * ```
9
+ *
10
+ * The value is `{ hour, minute }` on a 24-hour clock, whatever the face shows.
11
+ * A 12-hour display is a rendering choice; storing 7pm as `{ hour: 7 }` plus a
12
+ * meridiem flag would put the flag into every comparison downstream.
13
+ *
14
+ * ## Three layouts, because "pick a time" is three different tasks
15
+ *
16
+ * - **`wheel`** — hour, minute and meridiem as snapping columns. The precise
17
+ * one: any minute in the day is two or three flicks away. The default.
18
+ * - **`clock`** — a face beside a list of times at a fixed step. For picking a
19
+ * *slot* rather than a time — a booking, a reminder, an appointment — where
20
+ * the face answers "is that morning or evening?" faster than reading digits.
21
+ * - **`ruler`** — one large readout over a swipeable scale. The coarse one, and
22
+ * the only one that reads at arm's length, so it is the one for a sheet with
23
+ * a thumb on it.
24
+ *
25
+ * All three produce the same value and take the same props. Swapping between
26
+ * them is a one-word change, which is the point of them being one component.
27
+ *
28
+ * ## Presentation is separate from layout
29
+ *
30
+ * `popover`, `dialog` and `bottom-sheet` wrap the same panel; `inline` renders
31
+ * it bare, for composing into a Frame or a form. Popover and sheet hand off to
32
+ * `Popover`, which already owns both — but `Popover` has no dialog form, so
33
+ * the switch lives here rather than being pushed down into it. A picker is
34
+ * also the wrong place to widen a general-purpose overlay: the three shapes
35
+ * differ in how they are *dismissed*, not in what they contain.
36
+ *
37
+ * ## Scroll offset, not gesture maths
38
+ *
39
+ * The wheel, the clock's list and the ruler are all scroll views with
40
+ * `snapToOffsets`, and the selection is `Math.round(offset / itemSize)`. That
41
+ * buys momentum, deceleration, edge bounce and platform-correct fling physics
42
+ * for nothing, and none of it would be worth rebuilding on a pan gesture. The
43
+ * per-item fade and the ruler's readout follow the offset on the UI thread, so
44
+ * scrolling never round-trips through React — only the settled value does.
45
+ */
46
+ import {
47
+ useCallback,
48
+ useEffect,
49
+ useMemo,
50
+ useRef,
51
+ useState,
52
+ type ReactElement,
53
+ type ReactNode,
54
+ } from 'react';
55
+ import {
56
+ ScrollView,
57
+ View,
58
+ type NativeScrollEvent,
59
+ type NativeSyntheticEvent,
60
+ } from 'react-native';
61
+ import Animated, {
62
+ interpolate,
63
+ useAnimatedProps,
64
+ useAnimatedScrollHandler,
65
+ useAnimatedStyle,
66
+ useSharedValue,
67
+ withSpring,
68
+ type SharedValue,
69
+ } from 'react-native-reanimated';
70
+ import Svg, { Circle, Line } from 'react-native-svg';
71
+ import { useCSSVariable } from 'uniwind';
72
+ import { tv } from 'tailwind-variants';
73
+ import { ClockIcon } from '../../icons';
74
+ import { Text } from '../../primitives/text';
75
+ import { cn } from '../../utils/cn';
76
+ import { selectionTick } from '../../utils/haptics';
77
+ import {
78
+ clampTime,
79
+ displayHour,
80
+ formatTime,
81
+ hourFromDisplay,
82
+ isSameTime,
83
+ meridiemLabels,
84
+ meridiemOf,
85
+ padTwo,
86
+ roundToStep,
87
+ timeToMinutes,
88
+ timesOfDay,
89
+ type HourCycle,
90
+ type TimeValue,
91
+ } from '../../utils/time';
92
+ import { Button } from '../button';
93
+ import { Dialog } from '../dialog';
94
+ import { Popover } from '../popover';
95
+
96
+ export type { HourCycle, TimeValue };
97
+
98
+ /** Which face the panel draws. */
99
+ export type TimePickerLayout = 'wheel' | 'clock' | 'ruler';
100
+
101
+ /** How the panel gets onto the screen. */
102
+ export type TimePickerPresentation = 'popover' | 'dialog' | 'bottom-sheet' | 'inline';
103
+
104
+ /** What a closed picker reads when nothing has been chosen. */
105
+ const DEFAULT_PLACEHOLDER = 'Choose a time';
106
+
107
+ /** Row height in every scrolling column. Big enough to hit, small enough to see five. */
108
+ const ROW_HEIGHT = 44;
109
+ /** Rows visible in a column. Odd, so one of them is the centre. */
110
+ const VISIBLE_ROWS = 5;
111
+ const COLUMN_HEIGHT = ROW_HEIGHT * VISIBLE_ROWS;
112
+
113
+ /** Distance between two ruler ticks. */
114
+ const TICK_SPACING = 12;
115
+
116
+ /** Settles the clock hands after a pick. Slow enough to be followed by eye. */
117
+ const HAND_SPRING = { damping: 16, stiffness: 140, mass: 0.7 } as const;
118
+
119
+ const timePickerVariants = tv({
120
+ slots: {
121
+ panel: 'gap-3',
122
+ /* The columns and the highlight are stacked, so the pill is drawn once by
123
+ the container rather than once per column — three separately positioned
124
+ pills cannot be kept flush with each other across a rounding boundary. */
125
+ wheel: 'relative flex-row items-stretch justify-center',
126
+ highlight: 'absolute inset-x-0 rounded-xl bg-muted',
127
+ column: 'flex-1',
128
+ row: 'items-center justify-center',
129
+ rowLabel: 'text-lg tabular-nums text-foreground',
130
+ rowLabelSelected: 'font-semibold text-primary',
131
+ readout: 'text-center text-4xl font-semibold tabular-nums text-foreground',
132
+ clock: 'flex-row items-center gap-4',
133
+ footer: 'flex-row justify-end gap-2',
134
+ },
135
+ });
136
+
137
+ /* -------------------------------------------------------------------------- */
138
+ /* One snapping column */
139
+ /* -------------------------------------------------------------------------- */
140
+
141
+ interface ColumnProps<T> {
142
+ items: readonly T[];
143
+ /** Index of the selected item. Drives the scroll position. */
144
+ index: number;
145
+ onIndexChange: (index: number) => void;
146
+ label: (item: T) => string;
147
+ disabled?: boolean;
148
+ accessibilityLabel: string;
149
+ className?: string;
150
+ }
151
+
152
+ /**
153
+ * A vertical list that comes to rest on a whole row.
154
+ *
155
+ * `snapToOffsets` rather than `snapToInterval`: the two behave the same on a
156
+ * short flick, but an interval lets a hard fling coast past several rows and
157
+ * land between two of them on Android. Explicit offsets plus
158
+ * `disableIntervalMomentum` pin every rest position to a row.
159
+ *
160
+ * Half a column of padding at each end is what lets the first and last items
161
+ * reach the centre — without it, midnight can be scrolled to but never
162
+ * selected, since the list runs out before it gets there.
163
+ */
164
+ function Column<T>({
165
+ items,
166
+ index,
167
+ onIndexChange,
168
+ label,
169
+ disabled,
170
+ accessibilityLabel,
171
+ className,
172
+ }: ColumnProps<T>) {
173
+ const ref = useRef<Animated.ScrollView>(null);
174
+ const offset = useSharedValue(index * ROW_HEIGHT);
175
+ /*
176
+ * The index the list is resting on, tracked separately from the `index`
177
+ * prop. Scrolling writes to it, and the effect below only re-scrolls when
178
+ * the prop disagrees — otherwise every settle would push the list back to
179
+ * where it already is, cancelling the user's own momentum.
180
+ */
181
+ const resting = useRef(index);
182
+
183
+ const snapToOffsets = useMemo(
184
+ () => items.map((_, i) => i * ROW_HEIGHT),
185
+ [items]
186
+ );
187
+
188
+ const handler = useAnimatedScrollHandler((event) => {
189
+ offset.value = event.contentOffset.y;
190
+ });
191
+
192
+ useEffect(() => {
193
+ if (index === resting.current) return;
194
+ resting.current = index;
195
+ ref.current?.scrollTo({ y: index * ROW_HEIGHT, animated: true });
196
+ }, [index]);
197
+
198
+ const settle = useCallback(
199
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
200
+ const next = Math.round(event.nativeEvent.contentOffset.y / ROW_HEIGHT);
201
+ const clamped = Math.min(Math.max(next, 0), items.length - 1);
202
+ if (clamped === resting.current) return;
203
+ resting.current = clamped;
204
+ selectionTick();
205
+ onIndexChange(clamped);
206
+ },
207
+ [items.length, onIndexChange]
208
+ );
209
+
210
+ const selectedItem = items[index];
211
+
212
+ return (
213
+ <Animated.ScrollView
214
+ ref={ref}
215
+ className={className}
216
+ style={{ height: COLUMN_HEIGHT }}
217
+ contentContainerStyle={{ paddingVertical: (COLUMN_HEIGHT - ROW_HEIGHT) / 2 }}
218
+ contentOffset={{ x: 0, y: index * ROW_HEIGHT }}
219
+ onScroll={handler}
220
+ scrollEventThrottle={16}
221
+ scrollEnabled={!disabled}
222
+ showsVerticalScrollIndicator={false}
223
+ snapToOffsets={snapToOffsets}
224
+ disableIntervalMomentum
225
+ decelerationRate="fast"
226
+ /* Both, because only one of them fires: a flick ends in momentum, and a
227
+ slow drag released on a snap point ends without any. Missing the drag
228
+ case leaves a column that looks settled and has reported nothing. */
229
+ onMomentumScrollEnd={settle}
230
+ onScrollEndDrag={settle}
231
+ /* The rows fade rather than unmount, so a recycled row would pop in at
232
+ full opacity mid-scroll. A day of minutes is 60 views; keeping them
233
+ all is cheaper than the flicker. */
234
+ removeClippedSubviews={false}
235
+ accessibilityRole="adjustable"
236
+ accessibilityLabel={accessibilityLabel}
237
+ accessibilityValue={selectedItem ? { text: label(selectedItem) } : undefined}
238
+ >
239
+ {items.map((item, i) => (
240
+ <ColumnRow key={i} offset={offset} index={i} selected={i === index}>
241
+ {label(item)}
242
+ </ColumnRow>
243
+ ))}
244
+ </Animated.ScrollView>
245
+ );
246
+ }
247
+
248
+ /**
249
+ * One row, faded by how far it is from the centre.
250
+ *
251
+ * The fade is a function of the live scroll offset rather than of the selected
252
+ * index, so it tracks the finger instead of stepping once per settle — which
253
+ * is the difference between a wheel and a list that changes colour.
254
+ */
255
+ function ColumnRow({
256
+ offset,
257
+ index,
258
+ selected,
259
+ children,
260
+ }: {
261
+ offset: SharedValue<number>;
262
+ index: number;
263
+ selected: boolean;
264
+ children: string;
265
+ }) {
266
+ const slots = timePickerVariants();
267
+
268
+ const style = useAnimatedStyle(() => {
269
+ const distance = Math.abs(offset.value / ROW_HEIGHT - index);
270
+ return {
271
+ opacity: interpolate(distance, [0, 1, 2], [1, 0.55, 0.2], 'clamp'),
272
+ transform: [{ scale: interpolate(distance, [0, 1], [1, 0.88], 'clamp') }],
273
+ };
274
+ });
275
+
276
+ return (
277
+ <Animated.View
278
+ style={[style, { height: ROW_HEIGHT }]}
279
+ className={slots.row()}
280
+ >
281
+ <Text className={cn(slots.rowLabel(), selected && slots.rowLabelSelected())}>
282
+ {children}
283
+ </Text>
284
+ </Animated.View>
285
+ );
286
+ }
287
+
288
+ /* -------------------------------------------------------------------------- */
289
+ /* wheel */
290
+ /* -------------------------------------------------------------------------- */
291
+
292
+ interface FaceProps {
293
+ value: TimeValue;
294
+ onValueChange: (value: TimeValue) => void;
295
+ hourCycle: HourCycle;
296
+ minuteStep: number;
297
+ locale?: string;
298
+ disabled?: boolean;
299
+ }
300
+
301
+ function WheelFace({
302
+ value,
303
+ onValueChange,
304
+ hourCycle,
305
+ minuteStep,
306
+ locale,
307
+ disabled,
308
+ }: FaceProps) {
309
+ const slots = timePickerVariants();
310
+ const [am, pm] = useMemo(() => meridiemLabels(locale), [locale]);
311
+
312
+ const hours = useMemo(
313
+ () =>
314
+ hourCycle === 24
315
+ ? Array.from({ length: 24 }, (_, i) => i)
316
+ : Array.from({ length: 12 }, (_, i) => i + 1),
317
+ [hourCycle]
318
+ );
319
+ const minutes = useMemo(
320
+ () => Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep),
321
+ [minuteStep]
322
+ );
323
+ const meridiems = useMemo(() => [am, pm], [am, pm]);
324
+
325
+ const shownHour = displayHour(value.hour, hourCycle);
326
+ const hourIndex = Math.max(0, hours.indexOf(shownHour));
327
+ /*
328
+ * Nearest rather than exact. A minute that is not on the step — from a
329
+ * default value, or from `minuteStep` changing under a chosen time — has no
330
+ * row of its own, and `indexOf` returning -1 would park the column at
331
+ * midnight rather than at the closest minute it can actually offer.
332
+ */
333
+ const minuteIndex = Math.min(
334
+ minutes.length - 1,
335
+ Math.round(value.minute / minuteStep)
336
+ );
337
+ const meridiemIndex = meridiemOf(value.hour) === 'am' ? 0 : 1;
338
+
339
+ const setHour = useCallback(
340
+ (index: number) => {
341
+ const displayed = hours[index] ?? 0;
342
+ onValueChange({
343
+ ...value,
344
+ hour: hourFromDisplay(displayed, meridiemOf(value.hour), hourCycle),
345
+ });
346
+ },
347
+ [hours, hourCycle, onValueChange, value]
348
+ );
349
+
350
+ const setMinute = useCallback(
351
+ (index: number) => onValueChange({ ...value, minute: minutes[index] ?? 0 }),
352
+ [minutes, onValueChange, value]
353
+ );
354
+
355
+ const setMeridiem = useCallback(
356
+ (index: number) => {
357
+ const next = index === 0 ? 'am' : 'pm';
358
+ if (next === meridiemOf(value.hour)) return;
359
+ onValueChange({
360
+ ...value,
361
+ hour: next === 'pm' ? value.hour + 12 : value.hour - 12,
362
+ });
363
+ },
364
+ [onValueChange, value]
365
+ );
366
+
367
+ return (
368
+ <View className={slots.wheel()} style={{ height: COLUMN_HEIGHT }}>
369
+ {/* Behind the columns, and the only thing marking the selection — so it
370
+ stays exactly one row tall however many columns there happen to be. */}
371
+ <View
372
+ pointerEvents="none"
373
+ className={slots.highlight()}
374
+ style={{ top: (COLUMN_HEIGHT - ROW_HEIGHT) / 2, height: ROW_HEIGHT }}
375
+ />
376
+ <Column
377
+ items={hours}
378
+ index={hourIndex}
379
+ onIndexChange={setHour}
380
+ label={(hour) => (hourCycle === 24 ? padTwo(hour) : String(hour))}
381
+ disabled={disabled}
382
+ accessibilityLabel="Hour"
383
+ className={slots.column()}
384
+ />
385
+ <Column
386
+ items={minutes}
387
+ index={minuteIndex}
388
+ onIndexChange={setMinute}
389
+ label={padTwo}
390
+ disabled={disabled}
391
+ accessibilityLabel="Minute"
392
+ className={slots.column()}
393
+ />
394
+ {hourCycle === 12 ? (
395
+ <Column
396
+ items={meridiems}
397
+ index={meridiemIndex}
398
+ onIndexChange={setMeridiem}
399
+ label={(entry) => entry}
400
+ disabled={disabled}
401
+ accessibilityLabel="AM or PM"
402
+ className={slots.column()}
403
+ />
404
+ ) : null}
405
+ </View>
406
+ );
407
+ }
408
+
409
+ /* -------------------------------------------------------------------------- */
410
+ /* clock */
411
+ /* -------------------------------------------------------------------------- */
412
+
413
+ const FACE_SIZE = 132;
414
+ const FACE_CENTRE = FACE_SIZE / 2;
415
+
416
+ const AnimatedLine = Animated.createAnimatedComponent(Line);
417
+
418
+ /**
419
+ * An analog face whose hands follow the selection.
420
+ *
421
+ * The hands are animated rather than snapped because the face's whole job is
422
+ * to say *when in the day* the highlighted row is, and a hand that jumps gives
423
+ * that away one row at a time. Sweeping, you read the shape of the movement
424
+ * and stop looking at the digits.
425
+ */
426
+ function ClockFace({ value }: { value: TimeValue }) {
427
+ // `useCSSVariable` is typed for every kind of token, so a colour has to be
428
+ // narrowed before SVG will take it.
429
+ const rawTick = useCSSVariable('--color-muted-foreground');
430
+ const rawHand = useCSSVariable('--color-foreground');
431
+ const rawDial = useCSSVariable('--color-muted');
432
+ const tick = typeof rawTick === 'string' ? rawTick : undefined;
433
+ const hand = typeof rawHand === 'string' ? rawHand : undefined;
434
+ const dial = typeof rawDial === 'string' ? rawDial : undefined;
435
+
436
+ /*
437
+ * Total minutes, not the hour and minute separately. At five to twelve the
438
+ * hour hand is nearly at twelve, and driving it from `hour` alone leaves it
439
+ * pointing at eleven until the minute hand completes the turn.
440
+ */
441
+ const minutes = timeToMinutes(value);
442
+ const progress = useSharedValue(minutes);
443
+
444
+ useEffect(() => {
445
+ progress.value = withSpring(minutes, HAND_SPRING);
446
+ }, [minutes, progress]);
447
+
448
+ /*
449
+ * `animatedProps` rather than a transform: an SVG line has no transform
450
+ * origin of its own, so rotating it turns it about the origin of the whole
451
+ * canvas rather than about the pin at the centre of the dial. Moving the end
452
+ * point is the same maths without the frame-of-reference problem.
453
+ *
454
+ * The hands run on the *whole* turn, so the wrap at twelve and at the hour
455
+ * sweeps back rather than jumping — which is what a real hand does.
456
+ */
457
+ const hourHand = useAnimatedProps(() => handEnd((progress.value % 720) / 720, 34));
458
+ const minuteHand = useAnimatedProps(() => handEnd((progress.value % 60) / 60, 48));
459
+
460
+ return (
461
+ <Svg width={FACE_SIZE} height={FACE_SIZE}>
462
+ <Circle cx={FACE_CENTRE} cy={FACE_CENTRE} r={FACE_CENTRE - 2} fill={dial} />
463
+ {Array.from({ length: 12 }, (_, i) => {
464
+ const angle = (i / 12) * 2 * Math.PI - Math.PI / 2;
465
+ const outer = FACE_CENTRE - 10;
466
+ const inner = outer - (i % 3 === 0 ? 9 : 6);
467
+ return (
468
+ <Line
469
+ key={i}
470
+ x1={FACE_CENTRE + Math.cos(angle) * inner}
471
+ y1={FACE_CENTRE + Math.sin(angle) * inner}
472
+ x2={FACE_CENTRE + Math.cos(angle) * outer}
473
+ y2={FACE_CENTRE + Math.sin(angle) * outer}
474
+ stroke={tick}
475
+ strokeWidth={i % 3 === 0 ? 2 : 1}
476
+ strokeLinecap="round"
477
+ />
478
+ );
479
+ })}
480
+ <AnimatedLine
481
+ animatedProps={hourHand}
482
+ stroke={hand}
483
+ strokeWidth={3}
484
+ strokeLinecap="round"
485
+ />
486
+ <AnimatedLine
487
+ animatedProps={minuteHand}
488
+ stroke={hand}
489
+ strokeWidth={2}
490
+ strokeLinecap="round"
491
+ />
492
+ <Circle cx={FACE_CENTRE} cy={FACE_CENTRE} r={3} fill={hand} />
493
+ </Svg>
494
+ );
495
+ }
496
+
497
+ /**
498
+ * Where a hand of `length` ends, given how far round the dial it has turned.
499
+ *
500
+ * A worklet, so both hands can be computed on the UI thread. Twelve o'clock is
501
+ * straight up and SVG's zero angle is to the right, hence the quarter turn.
502
+ */
503
+ function handEnd(turn: number, length: number) {
504
+ 'worklet';
505
+ const angle = turn * 2 * Math.PI - Math.PI / 2;
506
+ return {
507
+ x1: FACE_CENTRE,
508
+ y1: FACE_CENTRE,
509
+ x2: FACE_CENTRE + Math.cos(angle) * length,
510
+ y2: FACE_CENTRE + Math.sin(angle) * length,
511
+ };
512
+ }
513
+
514
+ function ClockFaceLayout({
515
+ value,
516
+ onValueChange,
517
+ hourCycle,
518
+ minuteStep,
519
+ locale,
520
+ disabled,
521
+ }: FaceProps) {
522
+ const slots = timePickerVariants();
523
+ const times = useMemo(() => timesOfDay(minuteStep), [minuteStep]);
524
+ const index = Math.min(
525
+ times.length - 1,
526
+ Math.round(timeToMinutes(value) / minuteStep)
527
+ );
528
+
529
+ const setIndex = useCallback(
530
+ (next: number) => {
531
+ const time = times[next];
532
+ if (time) onValueChange(time);
533
+ },
534
+ [onValueChange, times]
535
+ );
536
+
537
+ return (
538
+ <View className={slots.clock()}>
539
+ <ClockFace value={value} />
540
+ <Column
541
+ items={times}
542
+ index={index}
543
+ onIndexChange={setIndex}
544
+ label={(time) => formatTime(time, { hourCycle, locale })}
545
+ disabled={disabled}
546
+ accessibilityLabel="Time"
547
+ className={slots.column()}
548
+ />
549
+ </View>
550
+ );
551
+ }
552
+
553
+ /* -------------------------------------------------------------------------- */
554
+ /* ruler */
555
+ /* -------------------------------------------------------------------------- */
556
+
557
+ /**
558
+ * One big readout over a scale you swipe.
559
+ *
560
+ * The scale is a horizontal scroll view with a tick per step and half its
561
+ * width of padding at each end, so the first and last times can reach the
562
+ * centre line — the same trick the columns use, on the other axis.
563
+ *
564
+ * The readout is a plain `Text` driven by React rather than an animated one
565
+ * driven by the offset. It only has to be right when the scale comes to rest,
566
+ * and re-rendering one string per settled step is cheaper than keeping a
567
+ * formatted time on the UI thread, where `Intl` cannot go.
568
+ */
569
+ function RulerFace({
570
+ value,
571
+ onValueChange,
572
+ hourCycle,
573
+ minuteStep,
574
+ locale,
575
+ disabled,
576
+ }: FaceProps) {
577
+ const slots = timePickerVariants();
578
+ const ref = useRef<ScrollView>(null);
579
+ const times = useMemo(() => timesOfDay(minuteStep), [minuteStep]);
580
+ const index = Math.min(
581
+ times.length - 1,
582
+ Math.round(timeToMinutes(value) / minuteStep)
583
+ );
584
+ const resting = useRef(index);
585
+ /*
586
+ * Measured, not assumed: the scroller is narrower than the panel by whatever
587
+ * padding the presentation put around it, which this component cannot see.
588
+ *
589
+ * Half the scroller, *less half a tick*. A tick is drawn in the middle of a
590
+ * cell `TICK_SPACING` wide, so padding by half the width alone puts the
591
+ * cell's leading edge under the indicator and the tick itself half a cell to
592
+ * the right of it — close enough to look like a rounding error and wrong at
593
+ * every rest position.
594
+ */
595
+ const [width, setWidth] = useState(0);
596
+ const pad = Math.max(0, width / 2 - TICK_SPACING / 2);
597
+
598
+ const snapToOffsets = useMemo(
599
+ () => times.map((_, i) => i * TICK_SPACING),
600
+ [times]
601
+ );
602
+
603
+ useEffect(() => {
604
+ if (index === resting.current) return;
605
+ resting.current = index;
606
+ ref.current?.scrollTo({ x: index * TICK_SPACING, animated: true });
607
+ }, [index]);
608
+
609
+ const settle = useCallback(
610
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
611
+ const next = Math.round(event.nativeEvent.contentOffset.x / TICK_SPACING);
612
+ const clamped = Math.min(Math.max(next, 0), times.length - 1);
613
+ if (clamped === resting.current) return;
614
+ resting.current = clamped;
615
+ selectionTick();
616
+ const time = times[clamped];
617
+ if (time) onValueChange(time);
618
+ },
619
+ [onValueChange, times]
620
+ );
621
+
622
+ const stepsPerHour = Math.max(1, Math.round(60 / minuteStep));
623
+
624
+ /*
625
+ * `contentOffset` only takes effect on mount, and on mount the padding is
626
+ * still zero because the width has not been measured — so the resting
627
+ * position has to be set again once it has. Unanimated: this is the first
628
+ * frame the scale is correctly laid out in, not a movement.
629
+ */
630
+ const onLayout = useCallback(
631
+ (event: { nativeEvent: { layout: { width: number } } }) => {
632
+ const next = event.nativeEvent.layout.width;
633
+ if (next === width) return;
634
+ setWidth(next);
635
+ ref.current?.scrollTo({ x: resting.current * TICK_SPACING, animated: false });
636
+ },
637
+ [width]
638
+ );
639
+
640
+ return (
641
+ <View className="gap-4">
642
+ <Text className={slots.readout()}>
643
+ {formatTime(value, { hourCycle, locale })}
644
+ </Text>
645
+
646
+ <View className="relative h-14 justify-center" onLayout={onLayout}>
647
+ <ScrollView
648
+ ref={ref}
649
+ horizontal
650
+ contentContainerStyle={{ paddingHorizontal: pad }}
651
+ contentOffset={{ x: index * TICK_SPACING, y: 0 }}
652
+ scrollEnabled={!disabled}
653
+ showsHorizontalScrollIndicator={false}
654
+ snapToOffsets={snapToOffsets}
655
+ disableIntervalMomentum
656
+ decelerationRate="fast"
657
+ onMomentumScrollEnd={settle}
658
+ onScrollEndDrag={settle}
659
+ accessibilityRole="adjustable"
660
+ accessibilityLabel="Time"
661
+ accessibilityValue={{ text: formatTime(value, { hourCycle, locale }) }}
662
+ >
663
+ {/*
664
+ No numbers under the ticks. The readout above already says the
665
+ time, and a scale this dense has room for a label every few hours
666
+ at most — which is a legend, not a scale, and reads as clutter
667
+ beside a number set at 36px.
668
+ */}
669
+ {times.map((_, i) => (
670
+ <View
671
+ key={i}
672
+ style={{ width: TICK_SPACING }}
673
+ className="items-center justify-center"
674
+ >
675
+ <View
676
+ className={cn(
677
+ 'w-0.5 rounded-full',
678
+ // Taller and darker on the hour, so the scale reads as hours
679
+ // rather than as an undifferentiated comb.
680
+ i % stepsPerHour === 0 ? 'h-7 bg-muted-foreground' : 'h-4 bg-border'
681
+ )}
682
+ />
683
+ </View>
684
+ ))}
685
+ </ScrollView>
686
+
687
+ {/* Over the scale rather than in it: the indicator marks the centre of
688
+ the control, which is a fixed point, while every tick moves. */}
689
+ <View
690
+ pointerEvents="none"
691
+ className="absolute inset-y-1 left-1/2 w-0.5 -translate-x-px rounded-full bg-primary"
692
+ />
693
+ </View>
694
+ </View>
695
+ );
696
+ }
697
+
698
+ /* -------------------------------------------------------------------------- */
699
+ /* The panel */
700
+ /* -------------------------------------------------------------------------- */
701
+
702
+ /** The width the panel asks for. Wide enough for three columns of digits. */
703
+ const PANEL_WIDTH = 300;
704
+
705
+ interface PanelProps extends FaceProps {
706
+ layout: TimePickerLayout;
707
+ className?: string;
708
+ /**
709
+ * Take the container's width instead of the panel's own.
710
+ *
711
+ * A popover has no width until its content claims one, so there the fixed
712
+ * width is doing real work. A sheet is already the width of the screen, and
713
+ * a 300pt box centred in it reads as a card that has been dropped into a
714
+ * sheet rather than as the sheet's own contents.
715
+ */
716
+ fullWidth?: boolean;
717
+ /** Rendered under the face — the sheet's Done button. */
718
+ footer?: ReactNode;
719
+ }
720
+
721
+ function Panel({ layout, className, fullWidth, footer, ...face }: PanelProps) {
722
+ const slots = timePickerVariants();
723
+
724
+ return (
725
+ <View
726
+ className={cn(slots.panel(), className)}
727
+ style={fullWidth ? undefined : { width: PANEL_WIDTH }}
728
+ >
729
+ {layout === 'wheel' ? <WheelFace {...face} /> : null}
730
+ {layout === 'clock' ? <ClockFaceLayout {...face} /> : null}
731
+ {layout === 'ruler' ? <RulerFace {...face} /> : null}
732
+ {footer}
733
+ </View>
734
+ );
735
+ }
736
+
737
+ /* -------------------------------------------------------------------------- */
738
+ /* TimePicker */
739
+ /* -------------------------------------------------------------------------- */
740
+
741
+ export interface TimePickerProps {
742
+ /** Controlled selection, as `{ hour, minute }` on a 24-hour clock. */
743
+ value?: TimeValue;
744
+ /** Starting selection when uncontrolled. Defaults to the top of the hour. */
745
+ defaultValue?: TimeValue;
746
+ onValueChange?: (value: TimeValue) => void;
747
+ /** Which face the panel draws. */
748
+ layout?: TimePickerLayout;
749
+ /** How the panel gets onto the screen. `inline` renders it with no trigger. */
750
+ presentation?: TimePickerPresentation;
751
+ /** Controlled open state of the panel. */
752
+ open?: boolean;
753
+ onOpenChange?: (open: boolean) => void;
754
+ /** `24` drops the meridiem column and writes hours 00–23. */
755
+ hourCycle?: HourCycle;
756
+ /**
757
+ * Minutes between selectable times. `wheel` defaults to 1; `clock` and
758
+ * `ruler` default to 30 and 15, since both scroll the whole day at once.
759
+ */
760
+ minuteStep?: number;
761
+ /** Earliest selectable time, inclusive. */
762
+ minTime?: TimeValue;
763
+ /** Latest selectable time, inclusive. */
764
+ maxTime?: TimeValue;
765
+ /** What the trigger reads when nothing has been chosen. */
766
+ placeholder?: string;
767
+ /** Override how the chosen time is written on the trigger. */
768
+ format?: (value: TimeValue) => string;
769
+ /** BCP 47 tag for the time's text and the meridiem labels. */
770
+ locale?: string;
771
+ /** Stop the trigger opening it, and the faces from being scrolled. */
772
+ disabled?: boolean;
773
+ className?: string;
774
+ /**
775
+ * A trigger of your own. Given one, it is cloned with an `onPress` that
776
+ * opens the panel — so a field row or an icon button can stand in for the
777
+ * default button without this component knowing what either looks like.
778
+ *
779
+ * Ignored by `presentation="inline"`, which has no trigger.
780
+ */
781
+ children?: ReactElement<{ onPress?: () => void }> | ReactNode;
782
+ }
783
+
784
+ /** The step each layout picks when the caller does not. */
785
+ const DEFAULT_STEP: Record<TimePickerLayout, number> = {
786
+ wheel: 1,
787
+ clock: 30,
788
+ ruler: 15,
789
+ };
790
+
791
+ function TimePickerRoot({
792
+ value: valueProp,
793
+ defaultValue,
794
+ onValueChange,
795
+ layout = 'wheel',
796
+ presentation = 'popover',
797
+ open: openProp,
798
+ onOpenChange,
799
+ hourCycle = 12,
800
+ minuteStep,
801
+ minTime,
802
+ maxTime,
803
+ placeholder = DEFAULT_PLACEHOLDER,
804
+ format,
805
+ locale,
806
+ disabled = false,
807
+ className,
808
+ children,
809
+ }: TimePickerProps) {
810
+ const step = minuteStep ?? DEFAULT_STEP[layout];
811
+
812
+ const [internalValue, setInternalValue] = useState<TimeValue | undefined>(defaultValue);
813
+ const [internalOpen, setInternalOpen] = useState(false);
814
+
815
+ const isValueControlled = valueProp !== undefined;
816
+ const isOpenControlled = openProp !== undefined;
817
+ const selected = isValueControlled ? valueProp : internalValue;
818
+ const open = isOpenControlled ? openProp : internalOpen;
819
+
820
+ /*
821
+ * What the faces scroll to before anything has been picked. The nearest
822
+ * allowed time to the top of the current hour, so an unset picker opens
823
+ * somewhere plausible rather than at midnight — and inside the span, since
824
+ * scrolling to a time the caller has forbidden is a worse first frame.
825
+ */
826
+ const fallback = useMemo(() => {
827
+ const now = new Date();
828
+ return clampTime(roundToStep({ hour: now.getHours(), minute: 0 }, step), minTime, maxTime);
829
+ }, [step, minTime, maxTime]);
830
+
831
+ const draft = selected ?? fallback;
832
+
833
+ const setOpen = useCallback(
834
+ (next: boolean) => {
835
+ if (!isOpenControlled) setInternalOpen(next);
836
+ onOpenChange?.(next);
837
+ },
838
+ [isOpenControlled, onOpenChange]
839
+ );
840
+
841
+ /*
842
+ * Clamped and stepped on the way out, not on the way in. A face reports the
843
+ * row it landed on, and it does not know about `minTime` or a step it is not
844
+ * itself using — putting both here means every layout is bounded by the same
845
+ * rule and none of them has to carry it.
846
+ */
847
+ const commit = useCallback(
848
+ (next: TimeValue) => {
849
+ const bounded = clampTime(roundToStep(next, step), minTime, maxTime);
850
+ if (isSameTime(bounded, selected)) return;
851
+ if (!isValueControlled) setInternalValue(bounded);
852
+ onValueChange?.(bounded);
853
+ },
854
+ [isValueControlled, maxTime, minTime, onValueChange, selected, step]
855
+ );
856
+
857
+ const label = useMemo(() => {
858
+ if (!selected) return null;
859
+ if (format) return format(selected);
860
+ return formatTime(selected, { hourCycle, locale });
861
+ }, [format, hourCycle, locale, selected]);
862
+
863
+ const face: FaceProps = {
864
+ value: draft,
865
+ onValueChange: commit,
866
+ hourCycle,
867
+ minuteStep: step,
868
+ locale,
869
+ disabled,
870
+ };
871
+
872
+ if (presentation === 'inline') {
873
+ return <Panel layout={layout} className={className} {...face} />;
874
+ }
875
+
876
+ const trigger = (
877
+ children ?? (
878
+ <Button variant="outline" disabled={disabled} className={cn('justify-start gap-2', className)}>
879
+ <ClockIcon size={16} />
880
+ <Text className={label ? undefined : 'text-muted-foreground'}>
881
+ {label ?? placeholder}
882
+ </Text>
883
+ </Button>
884
+ )
885
+ ) as ReactElement<{ onPress?: () => void }>;
886
+
887
+ if (presentation === 'dialog') {
888
+ return (
889
+ <Dialog open={open} onOpenChange={setOpen}>
890
+ <Dialog.Trigger>{trigger}</Dialog.Trigger>
891
+ {/*
892
+ `items-center` on the content rather than a width on the panel: the
893
+ dialog sizes to its child, and a panel of a fixed width inside a
894
+ stretch-aligned parent would be pinned to one edge.
895
+
896
+ Blurred rather than dimmed. A dialog is the presentation you reach
897
+ for when the time *is* the decision on the screen, and frosting what
898
+ is behind it says that in a way a dim does not. Falls back to the dim
899
+ when expo-blur is not installed, so it is safe either way.
900
+ */}
901
+ <Dialog.Content blur className="items-center gap-0 p-4">
902
+ <Panel
903
+ layout={layout}
904
+ {...face}
905
+ footer={
906
+ // Full width, like the sheet's. It is the only tap target in the
907
+ // dialog, and a small button in a corner of one asks to be
908
+ // aimed at.
909
+ <Dialog.Close>
910
+ <Button className="mt-1 w-full">Done</Button>
911
+ </Dialog.Close>
912
+ }
913
+ />
914
+ </Dialog.Content>
915
+ </Dialog>
916
+ );
917
+ }
918
+
919
+ const isSheet = presentation === 'bottom-sheet';
920
+
921
+ /*
922
+ * The sheet gets a Done button and the popover does not. A popover is
923
+ * dismissed by tapping anywhere outside it, which is most of the screen; a
924
+ * sheet's outside is the strip above it, and a picker whose scale is under
925
+ * your thumb needs somewhere deliberate to finish.
926
+ *
927
+ * Full width and set apart from the face rather than tucked under it. It is
928
+ * the only tap target in the sheet, and at the bottom of the screen it is
929
+ * also the one under the thumb already holding the phone.
930
+ */
931
+ const footer = isSheet ? (
932
+ <Popover.Close>
933
+ <Button className="mt-2 w-full">Done</Button>
934
+ </Popover.Close>
935
+ ) : null;
936
+
937
+ return (
938
+ <Popover
939
+ open={open}
940
+ onOpenChange={setOpen}
941
+ presentation={isSheet ? 'bottom-sheet' : 'popover'}
942
+ >
943
+ <Popover.Trigger>{trigger}</Popover.Trigger>
944
+ {/*
945
+ `full` in a sheet, `content-fit` anchored. A sheet is already the width
946
+ of the screen, and a panel sized to its content sits centred in it as a
947
+ card that happens to be inside a sheet rather than as the sheet's own
948
+ contents — which is the difference between the two and the reason the
949
+ sheet is worth having.
950
+
951
+ The padding is on the panel, not on the content: in sheet mode a
952
+ className on the panel is merged into the sheet's own padding and would
953
+ replace it.
954
+ */}
955
+ <Popover.Content width={isSheet ? 'full' : 'content-fit'}>
956
+ <Panel
957
+ layout={layout}
958
+ fullWidth={isSheet}
959
+ className={isSheet ? 'w-full gap-4 px-1 pb-2' : 'self-center p-3'}
960
+ footer={footer}
961
+ {...face}
962
+ />
963
+ </Popover.Content>
964
+ </Popover>
965
+ );
966
+ }
967
+ TimePickerRoot.displayName = 'TimePicker';
968
+
969
+ export const TimePicker = Object.assign(TimePickerRoot, {
970
+ Trigger: Popover.Trigger,
971
+ });