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