@voila.dev/ui 1.1.9

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 (99) hide show
  1. package/package.json +78 -0
  2. package/src/components/ui/accordion.tsx +79 -0
  3. package/src/components/ui/alert-dialog.tsx +198 -0
  4. package/src/components/ui/alert.tsx +105 -0
  5. package/src/components/ui/aspect-ratio.tsx +30 -0
  6. package/src/components/ui/avatar.tsx +125 -0
  7. package/src/components/ui/badge-variants.ts +166 -0
  8. package/src/components/ui/badge.tsx +45 -0
  9. package/src/components/ui/banner.tsx +84 -0
  10. package/src/components/ui/breadcrumb.tsx +127 -0
  11. package/src/components/ui/button-group.tsx +99 -0
  12. package/src/components/ui/button-variants.ts +108 -0
  13. package/src/components/ui/button.tsx +54 -0
  14. package/src/components/ui/calendar.tsx +266 -0
  15. package/src/components/ui/card.tsx +155 -0
  16. package/src/components/ui/carousel.tsx +319 -0
  17. package/src/components/ui/chat.tsx +896 -0
  18. package/src/components/ui/checkbox-group.tsx +44 -0
  19. package/src/components/ui/checkbox.tsx +34 -0
  20. package/src/components/ui/chip.tsx +60 -0
  21. package/src/components/ui/collapsible.tsx +44 -0
  22. package/src/components/ui/color-picker.tsx +191 -0
  23. package/src/components/ui/combobox.tsx +317 -0
  24. package/src/components/ui/command.tsx +187 -0
  25. package/src/components/ui/confirm-dialog.tsx +125 -0
  26. package/src/components/ui/context-menu.tsx +254 -0
  27. package/src/components/ui/copyable-text.tsx +65 -0
  28. package/src/components/ui/date-picker.tsx +316 -0
  29. package/src/components/ui/date-time-picker.tsx +1071 -0
  30. package/src/components/ui/dialog.tsx +176 -0
  31. package/src/components/ui/direction.tsx +15 -0
  32. package/src/components/ui/drawer.tsx +178 -0
  33. package/src/components/ui/dropdown-menu.tsx +247 -0
  34. package/src/components/ui/empty.tsx +131 -0
  35. package/src/components/ui/field.tsx +257 -0
  36. package/src/components/ui/formatted-input.tsx +219 -0
  37. package/src/components/ui/gallery.tsx +161 -0
  38. package/src/components/ui/hover-card.tsx +77 -0
  39. package/src/components/ui/icon-picker.tsx +193 -0
  40. package/src/components/ui/image-cropper.tsx +787 -0
  41. package/src/components/ui/image-upload-field.tsx +344 -0
  42. package/src/components/ui/input-group.tsx +175 -0
  43. package/src/components/ui/input-otp.tsx +83 -0
  44. package/src/components/ui/input.tsx +21 -0
  45. package/src/components/ui/item.tsx +212 -0
  46. package/src/components/ui/kbd.tsx +62 -0
  47. package/src/components/ui/label.tsx +19 -0
  48. package/src/components/ui/list.tsx +46 -0
  49. package/src/components/ui/menu-variants.ts +71 -0
  50. package/src/components/ui/menubar.tsx +226 -0
  51. package/src/components/ui/money-input.tsx +96 -0
  52. package/src/components/ui/native-date-picker.tsx +120 -0
  53. package/src/components/ui/native-select.tsx +74 -0
  54. package/src/components/ui/navigation-menu.tsx +183 -0
  55. package/src/components/ui/pagination.tsx +150 -0
  56. package/src/components/ui/popover.tsx +104 -0
  57. package/src/components/ui/profile-header.tsx +196 -0
  58. package/src/components/ui/progress.tsx +71 -0
  59. package/src/components/ui/radio-group.tsx +115 -0
  60. package/src/components/ui/rating.tsx +200 -0
  61. package/src/components/ui/resizable.tsx +61 -0
  62. package/src/components/ui/responsive-dialog.tsx +278 -0
  63. package/src/components/ui/responsive-select.tsx +289 -0
  64. package/src/components/ui/responsive-sheet.tsx +225 -0
  65. package/src/components/ui/scroll-area.tsx +51 -0
  66. package/src/components/ui/section.tsx +106 -0
  67. package/src/components/ui/segmented-control.tsx +148 -0
  68. package/src/components/ui/select.tsx +216 -0
  69. package/src/components/ui/separator.tsx +69 -0
  70. package/src/components/ui/sheet.tsx +154 -0
  71. package/src/components/ui/shortcut.tsx +41 -0
  72. package/src/components/ui/sidebar.tsx +739 -0
  73. package/src/components/ui/skeleton.tsx +23 -0
  74. package/src/components/ui/slider.tsx +74 -0
  75. package/src/components/ui/sonner.tsx +64 -0
  76. package/src/components/ui/spinner.tsx +19 -0
  77. package/src/components/ui/stat-card.tsx +138 -0
  78. package/src/components/ui/stepper.tsx +181 -0
  79. package/src/components/ui/sticky-action-bar.tsx +42 -0
  80. package/src/components/ui/switch.tsx +30 -0
  81. package/src/components/ui/table.tsx +132 -0
  82. package/src/components/ui/tabs.tsx +77 -0
  83. package/src/components/ui/textarea.tsx +24 -0
  84. package/src/components/ui/time-picker.tsx +221 -0
  85. package/src/components/ui/toggle-group.tsx +86 -0
  86. package/src/components/ui/toggle.tsx +43 -0
  87. package/src/components/ui/tooltip.tsx +79 -0
  88. package/src/components/ui/translation-input.tsx +126 -0
  89. package/src/components/ui/user-avatar.tsx +74 -0
  90. package/src/css-modules.d.ts +4 -0
  91. package/src/hooks/use-command-palette.ts +28 -0
  92. package/src/hooks/use-mobile.ts +21 -0
  93. package/src/hooks/use-pagination.ts +81 -0
  94. package/src/hooks/use-picker-state.ts +44 -0
  95. package/src/lib/cva.ts +18 -0
  96. package/src/lib/time-math.ts +62 -0
  97. package/src/lib/utils.ts +14 -0
  98. package/src/styles/globals.css +36 -0
  99. package/src/styles.css +10 -0
@@ -0,0 +1,1071 @@
1
+ import { CalendarDotsIcon } from "@phosphor-icons/react";
2
+ import * as React from "react";
3
+ import { Button, type ButtonProps } from "#/components/ui/button.tsx";
4
+ import { Calendar } from "#/components/ui/calendar.tsx";
5
+ import { DatePickerTrigger } from "#/components/ui/date-picker.tsx";
6
+ import { Label } from "#/components/ui/label.tsx";
7
+ import { NativeDateTimePicker } from "#/components/ui/native-date-picker.tsx";
8
+ import { Popover, PopoverContent } from "#/components/ui/popover.tsx";
9
+ import { useIsMobile } from "#/hooks/use-mobile.ts";
10
+ import { usePickerState } from "#/hooks/use-picker-state.ts";
11
+ import {
12
+ formatMinutesLabel,
13
+ minutesOfDay,
14
+ withMinutes,
15
+ } from "#/lib/time-math.ts";
16
+ import { cn } from "#/lib/utils.ts";
17
+
18
+ // One datetime field, two surfaces — mirroring `ResponsiveSelect`/`ResponsiveDialog`:
19
+ // the Base UI popover `DateTimePicker` (calendar + time list) on desktop and the
20
+ // OS-native `<input type="datetime-local">` under the `useIsMobile` breakpoint
21
+ // (768px), where the native picker is the better touch experience. All three speak
22
+ // a single `Date | null` value model so callers never juggle datetime strings.
23
+
24
+ /** Default time (09:00) applied when a day is picked before any time exists. */
25
+ const DEFAULT_MINUTES = 9 * 60;
26
+
27
+ const pad = (input: number): string => String(input).padStart(2, "0");
28
+
29
+ /** Local `yyyy-MM-ddTHH:mm` — the shape an `<input type="datetime-local">` wants. */
30
+ function toLocalInputValue(date: Date): string {
31
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
32
+ date.getDate(),
33
+ )}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
34
+ }
35
+
36
+ /** Parse the native input's local datetime string back to a `Date` (null when empty). */
37
+ function parseLocalInputValue(value: string): Date | null {
38
+ if (!value) return null;
39
+ const date = new Date(value);
40
+ return Number.isNaN(date.getTime()) ? null : date;
41
+ }
42
+
43
+ /** Long localized date + time for the trigger label (e.g. "Jun 20, 2026, 2:30 PM"). */
44
+ const DEFAULT_DATE_TIME_FORMAT: Intl.DateTimeFormatOptions = {
45
+ dateStyle: "medium",
46
+ timeStyle: "short",
47
+ };
48
+
49
+ /** Localized time label for a minute-of-day, matching `TimePicker`. */
50
+ function timeLabel(totalMinutes: number, locale: string | undefined): string {
51
+ return formatMinutesLabel(totalMinutes, locale);
52
+ }
53
+
54
+ /** Every minute-of-day option a time list offers, `minuteStep` apart. */
55
+ function useTimeOptions(minuteStep: number): number[] {
56
+ return React.useMemo(() => {
57
+ const stepMinutes = minuteStep > 0 ? minuteStep : 30;
58
+ const times: number[] = [];
59
+ for (let minutes = 0; minutes <= 23 * 60 + 59; minutes += stepMinutes) {
60
+ times.push(minutes);
61
+ }
62
+ return times;
63
+ }, [minuteStep]);
64
+ }
65
+
66
+ type DateTimeSharedProps = {
67
+ /** Controlled value; pass `null` for a controlled empty selection. */
68
+ value?: Date | null;
69
+ defaultValue?: Date;
70
+ onValueChange?: (date: Date | null) => void;
71
+ placeholder?: string;
72
+ /** BCP-47 locale (e.g. "fr-FR"), applied to the trigger label, calendar, and time labels. */
73
+ locale?: string;
74
+ disabled?: boolean;
75
+ /** Name for a hidden form input; the value is serialized as `yyyy-MM-ddTHH:mm`. */
76
+ name?: string;
77
+ id?: string;
78
+ className?: string;
79
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"];
80
+ "aria-label"?: string;
81
+ /** Minutes between two options in the time list. Defaults to 30. */
82
+ minuteStep?: number;
83
+ };
84
+
85
+ type DateTimePickerProps = DateTimeSharedProps & {
86
+ /**
87
+ * `Intl.DateTimeFormat` options for the trigger label. Defaults to
88
+ * `{ dateStyle: "medium", timeStyle: "short" }` (e.g. "Jun 20, 2026, 2:30 PM").
89
+ */
90
+ formatOptions?: Intl.DateTimeFormatOptions;
91
+ variant?: ButtonProps["variant"];
92
+ /** Escape hatch for the underlying Calendar (disabled days, week numbers…). */
93
+ calendarProps?: Omit<
94
+ React.ComponentProps<typeof Calendar>,
95
+ "mode" | "selected" | "onSelect" | "locale"
96
+ >;
97
+ defaultOpen?: boolean;
98
+ open?: boolean;
99
+ onOpenChange?: (open: boolean) => void;
100
+ };
101
+
102
+ /**
103
+ * {@link DateTimePicker}'s selection handlers around the shared picker state.
104
+ * Picking a day keeps the existing time-of-day (or 09:00 the first time), and
105
+ * leaves the popover open so the time can still be chosen; picking a time
106
+ * completes the selection (against today when no day is set yet) and closes
107
+ * the popover, mirroring `TimePicker`.
108
+ */
109
+ function createDateTimeSelectionHandlers(state: {
110
+ isControlled: boolean;
111
+ value: Date | null | undefined;
112
+ setUncontrolledValue: (value: Date | undefined) => void;
113
+ setOpen: (open: boolean) => void;
114
+ onValueChange: ((date: Date | null) => void) | undefined;
115
+ }): {
116
+ handleDaySelect: (day: Date | undefined) => void;
117
+ handleTimeSelect: (totalMinutes: number) => void;
118
+ } {
119
+ const commit = (next: Date | null) => {
120
+ if (!state.isControlled) state.setUncontrolledValue(next ?? undefined);
121
+ state.onValueChange?.(next);
122
+ };
123
+
124
+ const handleDaySelect = (day: Date | undefined) => {
125
+ if (!day) {
126
+ commit(null);
127
+ return;
128
+ }
129
+ commit(
130
+ withMinutes(
131
+ day,
132
+ state.value ? minutesOfDay(state.value) : DEFAULT_MINUTES,
133
+ ),
134
+ );
135
+ };
136
+
137
+ const handleTimeSelect = (totalMinutes: number) => {
138
+ commit(withMinutes(state.value ?? new Date(), totalMinutes));
139
+ state.setOpen(false);
140
+ };
141
+
142
+ return { handleDaySelect, handleTimeSelect };
143
+ }
144
+
145
+ function dateTimeTriggerLabel(
146
+ value: Date | null | undefined,
147
+ locale: string | undefined,
148
+ formatOptions: Intl.DateTimeFormatOptions,
149
+ placeholder: string,
150
+ ): string {
151
+ return value
152
+ ? new Intl.DateTimeFormat(locale, formatOptions).format(value)
153
+ : placeholder;
154
+ }
155
+
156
+ /** Serialized `yyyy-MM-ddTHH:mm` form value, rendered only when named. */
157
+ function DateTimeHiddenInput({
158
+ name,
159
+ value,
160
+ }: {
161
+ name: string | undefined;
162
+ value: Date | null | undefined;
163
+ }) {
164
+ if (!name) {
165
+ return null;
166
+ }
167
+ return (
168
+ <input
169
+ type="hidden"
170
+ name={name}
171
+ value={value ? toLocalInputValue(value) : ""}
172
+ />
173
+ );
174
+ }
175
+
176
+ /** The scrollable time-of-day column beside {@link DateTimePicker}'s calendar. */
177
+ function DateTimeOptionList({
178
+ options,
179
+ selectedMinutes,
180
+ locale,
181
+ ariaLabel,
182
+ onSelect,
183
+ }: {
184
+ options: ReadonlyArray<number>;
185
+ selectedMinutes: number | null;
186
+ locale: string | undefined;
187
+ ariaLabel: string;
188
+ onSelect: (totalMinutes: number) => void;
189
+ }) {
190
+ // Center the selected time option once the popup is positioned (the ref fires
191
+ // too early, so defer by a frame). Optional call: jsdom has no scrollIntoView.
192
+ const setSelectedOption = React.useCallback(
193
+ (node: HTMLButtonElement | null) => {
194
+ if (!node) return;
195
+ requestAnimationFrame(() => node.scrollIntoView?.({ block: "center" }));
196
+ },
197
+ [],
198
+ );
199
+
200
+ return (
201
+ <div
202
+ role="listbox"
203
+ aria-label={ariaLabel}
204
+ className="flex max-h-72 w-28 flex-col gap-0.5 overflow-y-auto overscroll-contain border-l p-1.5"
205
+ >
206
+ {options.map((minutes) => {
207
+ const selected = selectedMinutes === minutes;
208
+ return (
209
+ <Button
210
+ key={minutes}
211
+ type="button"
212
+ role="option"
213
+ aria-selected={selected}
214
+ data-selected={selected || undefined}
215
+ variant="ghost"
216
+ size="sm"
217
+ className="shrink-0 justify-center font-normal data-selected:bg-primary data-selected:text-primary-foreground data-selected:hover:bg-primary data-selected:hover:text-primary-foreground"
218
+ ref={selected ? setSelectedOption : undefined}
219
+ onClick={() => onSelect(minutes)}
220
+ >
221
+ {timeLabel(minutes, locale)}
222
+ </Button>
223
+ );
224
+ })}
225
+ </div>
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Composed date + time picker built on the kit's Base UI `Popover` and `Calendar`:
231
+ * the calendar sets the day, a scrollable list sets the time, and the trigger shows
232
+ * the combined, localized datetime. Controlled or uncontrolled, like `DatePicker`.
233
+ */
234
+ function DateTimePicker({
235
+ value: controlledValue,
236
+ defaultValue,
237
+ onValueChange,
238
+ placeholder = "Pick a date and time",
239
+ formatOptions = DEFAULT_DATE_TIME_FORMAT,
240
+ locale,
241
+ name,
242
+ "aria-label": ariaLabel,
243
+ minuteStep = 30,
244
+ calendarProps,
245
+ defaultOpen,
246
+ open: controlledOpen,
247
+ onOpenChange,
248
+ ...triggerProps
249
+ }: DateTimePickerProps) {
250
+ const { isControlled, value, setUncontrolledValue, open, setOpen } =
251
+ usePickerState<Date>({
252
+ value: controlledValue,
253
+ defaultValue,
254
+ open: controlledOpen,
255
+ defaultOpen,
256
+ onOpenChange,
257
+ });
258
+
259
+ const { handleDaySelect, handleTimeSelect } = createDateTimeSelectionHandlers(
260
+ { isControlled, value, setUncontrolledValue, setOpen, onValueChange },
261
+ );
262
+
263
+ const options = useTimeOptions(minuteStep);
264
+
265
+ const selectedMinutes = value ? minutesOfDay(value) : null;
266
+ const selectedDay = value ?? undefined;
267
+
268
+ return (
269
+ <Popover open={open} onOpenChange={setOpen}>
270
+ <DatePickerTrigger
271
+ slot="date-time-picker-trigger"
272
+ icon={
273
+ <CalendarDotsIcon className="size-4 shrink-0 text-muted-foreground" />
274
+ }
275
+ empty={!value}
276
+ aria-label={ariaLabel}
277
+ {...triggerProps}
278
+ >
279
+ {dateTimeTriggerLabel(value, locale, formatOptions, placeholder)}
280
+ </DatePickerTrigger>
281
+ <DateTimeHiddenInput name={name} value={value} />
282
+ <PopoverContent
283
+ data-slot="date-time-picker-content"
284
+ className="w-auto p-0"
285
+ align="start"
286
+ >
287
+ <div className="flex">
288
+ <Calendar
289
+ mode="single"
290
+ selected={selectedDay}
291
+ onSelect={handleDaySelect}
292
+ defaultMonth={selectedDay}
293
+ locale={locale}
294
+ autoFocus
295
+ {...calendarProps}
296
+ />
297
+ <DateTimeOptionList
298
+ options={options}
299
+ selectedMinutes={selectedMinutes}
300
+ locale={locale}
301
+ ariaLabel={ariaLabel ?? placeholder}
302
+ onSelect={handleTimeSelect}
303
+ />
304
+ </div>
305
+ </PopoverContent>
306
+ </Popover>
307
+ );
308
+ }
309
+
310
+ type NativeDateTimePickerProps = React.ComponentProps<
311
+ typeof NativeDateTimePicker
312
+ >;
313
+
314
+ type NativeDateTimeInputProps = Omit<
315
+ NativeDateTimePickerProps,
316
+ "value" | "defaultValue" | "onChange"
317
+ > & {
318
+ value?: Date | null;
319
+ onValueChange?: (date: Date | null) => void;
320
+ };
321
+
322
+ /**
323
+ * `Date`-valued adapter over the native `<input type="datetime-local">`, so the
324
+ * mobile surface shares the picker family's value model. The native field uses the
325
+ * viewer's local clock, matching {@link DateTimePicker}.
326
+ */
327
+ function NativeDateTimeInput({
328
+ value,
329
+ onValueChange,
330
+ ...props
331
+ }: NativeDateTimeInputProps) {
332
+ return (
333
+ <NativeDateTimePicker
334
+ value={value ? toLocalInputValue(value) : ""}
335
+ onChange={(event) =>
336
+ onValueChange?.(parseLocalInputValue(event.target.value))
337
+ }
338
+ {...props}
339
+ />
340
+ );
341
+ }
342
+
343
+ type ResponsiveDateTimeInputProps = DateTimeSharedProps & {
344
+ /** `Intl.DateTimeFormat` options for the desktop trigger label. */
345
+ formatOptions?: Intl.DateTimeFormatOptions;
346
+ calendarProps?: DateTimePickerProps["calendarProps"];
347
+ /** Min selectable time on the native input, `HH:mm`. */
348
+ min?: string;
349
+ /** Max selectable time on the native input, `HH:mm`. */
350
+ max?: string;
351
+ };
352
+
353
+ /**
354
+ * The datetime equivalent of `ResponsiveSelect`: the Base UI {@link DateTimePicker}
355
+ * on desktop and the native {@link NativeDateTimeInput} under the `useIsMobile`
356
+ * breakpoint, behind one `Date | null` value API. Both surfaces fill their
357
+ * container width by default (override via `className`).
358
+ */
359
+ function ResponsiveDateTimeInput({
360
+ value,
361
+ defaultValue,
362
+ onValueChange,
363
+ placeholder,
364
+ formatOptions,
365
+ locale,
366
+ disabled,
367
+ name,
368
+ id,
369
+ className,
370
+ "aria-invalid": ariaInvalid,
371
+ "aria-label": ariaLabel,
372
+ minuteStep,
373
+ calendarProps,
374
+ min,
375
+ max,
376
+ }: ResponsiveDateTimeInputProps) {
377
+ const isMobile = useIsMobile();
378
+
379
+ if (isMobile) {
380
+ return (
381
+ <NativeDateTimeInput
382
+ id={id}
383
+ name={name}
384
+ value={value ?? null}
385
+ onValueChange={onValueChange}
386
+ disabled={disabled}
387
+ required={false}
388
+ min={min}
389
+ max={max}
390
+ aria-invalid={ariaInvalid}
391
+ aria-label={ariaLabel}
392
+ className={className}
393
+ wrapperClassName="w-full"
394
+ />
395
+ );
396
+ }
397
+
398
+ return (
399
+ <DateTimePicker
400
+ id={id}
401
+ name={name}
402
+ value={value}
403
+ defaultValue={defaultValue}
404
+ onValueChange={onValueChange}
405
+ placeholder={placeholder}
406
+ formatOptions={formatOptions}
407
+ locale={locale}
408
+ disabled={disabled}
409
+ minuteStep={minuteStep}
410
+ calendarProps={calendarProps}
411
+ aria-invalid={ariaInvalid}
412
+ aria-label={ariaLabel}
413
+ className={cn("w-full", className)}
414
+ />
415
+ );
416
+ }
417
+
418
+ /** A start/end pair, each `null` until picked, as {@link DateTimeRangeInput} holds it. */
419
+ type DateTimeRange = {
420
+ start: Date | null;
421
+ end: Date | null;
422
+ };
423
+
424
+ type DateTimeRangeInputProps = {
425
+ /** Controlled range; both sides may be `null` independently. */
426
+ value?: DateTimeRange;
427
+ defaultValue?: DateTimeRange;
428
+ onValueChange?: (range: DateTimeRange) => void;
429
+ /** Label above the start field. Defaults to "Start". */
430
+ startLabel?: React.ReactNode;
431
+ /** Label above the end field. Defaults to "End". */
432
+ endLabel?: React.ReactNode;
433
+ startPlaceholder?: string;
434
+ endPlaceholder?: string;
435
+ /** id for the start field; the end field derives `${startId}-end` when `endId` is omitted. */
436
+ startId?: string;
437
+ endId?: string;
438
+ /** BCP-47 locale (e.g. "fr-FR"), applied to both fields' labels, calendars, and time lists. */
439
+ locale?: string;
440
+ disabled?: boolean;
441
+ /** Minutes between two options in each time list. Defaults to 30. */
442
+ minuteStep?: number;
443
+ /**
444
+ * Duration (minutes) used to seed the end when a start is picked while the end
445
+ * is still empty, and to push the end forward when a new start lands on or past
446
+ * it, so the range stays valid without extra clicks. Defaults to 60.
447
+ */
448
+ defaultDurationMinutes?: number;
449
+ /** `Intl.DateTimeFormat` options for the desktop trigger labels. */
450
+ formatOptions?: Intl.DateTimeFormatOptions;
451
+ /** Styles the wrapping grid (e.g. `md:col-span-2`). */
452
+ className?: string;
453
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"];
454
+ };
455
+
456
+ /** Local `yyyy-MM-ddTHH:mm` at midnight of the given day. */
457
+ function startOfDay(date: Date): Date {
458
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
459
+ }
460
+
461
+ /**
462
+ * Controlled-or-uncontrolled `{ start, end }` state, shared by the two range
463
+ * inputs: `range` is the current value and `commit` writes it (updating the
464
+ * internal state only while uncontrolled) before notifying the caller.
465
+ */
466
+ function useDateTimeRangeState({
467
+ value,
468
+ defaultValue,
469
+ onValueChange,
470
+ }: {
471
+ value?: DateTimeRange;
472
+ defaultValue?: DateTimeRange;
473
+ onValueChange?: (range: DateTimeRange) => void;
474
+ }): { range: DateTimeRange; commit: (next: DateTimeRange) => void } {
475
+ const isControlled = value !== undefined;
476
+ const [uncontrolled, setUncontrolled] = React.useState<DateTimeRange>(
477
+ defaultValue ?? { start: null, end: null },
478
+ );
479
+
480
+ return {
481
+ range: isControlled ? value : uncontrolled,
482
+ commit: (next: DateTimeRange) => {
483
+ if (!isControlled) setUncontrolled(next);
484
+ onValueChange?.(next);
485
+ },
486
+ };
487
+ }
488
+
489
+ function resolveEndFieldId(
490
+ startId: string | undefined,
491
+ endId: string | undefined,
492
+ ): string | undefined {
493
+ if (endId !== undefined) {
494
+ return endId;
495
+ }
496
+ return startId ? `${startId}-end` : undefined;
497
+ }
498
+
499
+ /**
500
+ * Keep the end from landing before the start: bound the native input and disable
501
+ * earlier days in the desktop calendar (the start-change seeding handles same-day
502
+ * times).
503
+ */
504
+ function endFieldBounds(start: Date | null): {
505
+ min: string | undefined;
506
+ calendarProps: { disabled: { before: Date } } | undefined;
507
+ } {
508
+ if (start === null) {
509
+ return { min: undefined, calendarProps: undefined };
510
+ }
511
+ return {
512
+ min: toLocalInputValue(start),
513
+ calendarProps: { disabled: { before: startOfDay(start) } },
514
+ };
515
+ }
516
+
517
+ /**
518
+ * Two labeled {@link ResponsiveDateTimeInput}s bound into a single start/end range.
519
+ * Picking a start seeds (or nudges) the end so the span stays valid, and the end
520
+ * field is bounded to never fall before the start (its calendar disables earlier
521
+ * days and the native input carries a `min`). Both surfaces speak one
522
+ * `{ start, end }` value model, so callers never juggle datetime strings.
523
+ */
524
+ function DateTimeRangeInput({
525
+ value: controlledValue,
526
+ defaultValue,
527
+ onValueChange,
528
+ startLabel = "Start",
529
+ endLabel = "End",
530
+ startPlaceholder,
531
+ endPlaceholder,
532
+ startId,
533
+ endId,
534
+ locale,
535
+ disabled,
536
+ minuteStep,
537
+ defaultDurationMinutes = 60,
538
+ formatOptions,
539
+ className,
540
+ "aria-invalid": ariaInvalid,
541
+ }: DateTimeRangeInputProps) {
542
+ const { range, commit } = useDateTimeRangeState({
543
+ value: controlledValue,
544
+ defaultValue,
545
+ onValueChange,
546
+ });
547
+
548
+ const handleStartChange = (start: Date | null) => {
549
+ let end = range.end;
550
+ if (start !== null && (end === null || end.getTime() <= start.getTime())) {
551
+ end = new Date(start.getTime() + defaultDurationMinutes * 60_000);
552
+ }
553
+ commit({ start, end });
554
+ };
555
+
556
+ const handleEndChange = (end: Date | null) => {
557
+ commit({ start: range.start, end });
558
+ };
559
+
560
+ const resolvedEndId = resolveEndFieldId(startId, endId);
561
+ const endBounds = endFieldBounds(range.start);
562
+
563
+ return (
564
+ <div className={cn("grid grid-cols-1 gap-3 sm:grid-cols-2", className)}>
565
+ <div className="flex flex-col gap-1.5">
566
+ <Label htmlFor={startId}>{startLabel}</Label>
567
+ <ResponsiveDateTimeInput
568
+ id={startId}
569
+ value={range.start}
570
+ onValueChange={handleStartChange}
571
+ placeholder={startPlaceholder}
572
+ locale={locale}
573
+ disabled={disabled}
574
+ minuteStep={minuteStep}
575
+ formatOptions={formatOptions}
576
+ aria-invalid={ariaInvalid}
577
+ />
578
+ </div>
579
+ <div className="flex flex-col gap-1.5">
580
+ <Label htmlFor={resolvedEndId}>{endLabel}</Label>
581
+ <ResponsiveDateTimeInput
582
+ id={resolvedEndId}
583
+ value={range.end}
584
+ onValueChange={handleEndChange}
585
+ placeholder={endPlaceholder}
586
+ locale={locale}
587
+ disabled={disabled}
588
+ minuteStep={minuteStep}
589
+ formatOptions={formatOptions}
590
+ min={endBounds.min}
591
+ calendarProps={endBounds.calendarProps}
592
+ aria-invalid={ariaInvalid}
593
+ />
594
+ </div>
595
+ </div>
596
+ );
597
+ }
598
+
599
+ type ShiftTimeRangeInputProps = {
600
+ /** Controlled range; both sides may be `null` independently. */
601
+ value?: DateTimeRange;
602
+ defaultValue?: DateTimeRange;
603
+ onValueChange?: (range: DateTimeRange) => void;
604
+ placeholder?: string;
605
+ id?: string;
606
+ /** BCP-47 locale (e.g. "fr-FR") for the trigger label, calendar, and time lists. */
607
+ locale?: string;
608
+ disabled?: boolean;
609
+ /** Minutes between two options in each time list. Defaults to 30. */
610
+ minuteStep?: number;
611
+ /** Duration (minutes) seeding the end when only a start exists. Defaults to 60. */
612
+ defaultDurationMinutes?: number;
613
+ className?: string;
614
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"];
615
+ "aria-label"?: string;
616
+ };
617
+
618
+ /** Which end of the range the popover is currently editing. */
619
+ type ShiftStep = "start" | "end";
620
+
621
+ /**
622
+ * Keep the end strictly after the start: preserve the user's end when it still
623
+ * holds, otherwise reseed it one duration later, rolling across midnight when
624
+ * needed.
625
+ */
626
+ function endAfter(
627
+ start: Date,
628
+ end: Date | null,
629
+ defaultDurationMinutes: number,
630
+ ): Date {
631
+ return end !== null && end.getTime() > start.getTime()
632
+ ? end
633
+ : new Date(start.getTime() + defaultDurationMinutes * 60_000);
634
+ }
635
+
636
+ /** The range after picking `day` on the active step, keeping the time-of-day. */
637
+ function rangeWithDay({
638
+ range,
639
+ day,
640
+ step,
641
+ defaultDurationMinutes,
642
+ }: {
643
+ range: DateTimeRange;
644
+ day: Date;
645
+ step: ShiftStep;
646
+ defaultDurationMinutes: number;
647
+ }): DateTimeRange {
648
+ const base = startOfDay(day);
649
+ if (step === "start") {
650
+ const start = withMinutes(
651
+ base,
652
+ range.start ? minutesOfDay(range.start) : DEFAULT_MINUTES,
653
+ );
654
+ return { start, end: endAfter(start, range.end, defaultDurationMinutes) };
655
+ }
656
+ const minutes = range.end
657
+ ? minutesOfDay(range.end)
658
+ : range.start
659
+ ? minutesOfDay(range.start)
660
+ : DEFAULT_MINUTES;
661
+ return { start: range.start, end: withMinutes(base, minutes) };
662
+ }
663
+
664
+ /** The range after picking `minutes` on the active step, keeping the day. */
665
+ function rangeWithTime({
666
+ range,
667
+ minutes,
668
+ step,
669
+ defaultDurationMinutes,
670
+ }: {
671
+ range: DateTimeRange;
672
+ minutes: number;
673
+ step: ShiftStep;
674
+ defaultDurationMinutes: number;
675
+ }): DateTimeRange {
676
+ if (step === "start") {
677
+ const base = range.start ? startOfDay(range.start) : startOfDay(new Date());
678
+ const start = withMinutes(base, minutes);
679
+ return { start, end: endAfter(start, range.end, defaultDurationMinutes) };
680
+ }
681
+ const base = range.end
682
+ ? startOfDay(range.end)
683
+ : range.start
684
+ ? startOfDay(range.start)
685
+ : startOfDay(new Date());
686
+ return { start: range.start, end: withMinutes(base, minutes) };
687
+ }
688
+
689
+ /** Short localized day for the trigger and the step tabs, e.g. "20 juin". */
690
+ function shiftDateLabel(date: Date, locale: string | undefined): string {
691
+ return new Intl.DateTimeFormat(locale, {
692
+ day: "numeric",
693
+ month: "short",
694
+ }).format(date);
695
+ }
696
+
697
+ /** One side of the range as a day + time summary, or a dash when unpicked. */
698
+ function shiftStepLabel(date: Date | null, locale: string | undefined): string {
699
+ return date === null
700
+ ? "—"
701
+ : `${shiftDateLabel(date, locale)} · ${timeLabel(minutesOfDay(date), locale)}`;
702
+ }
703
+
704
+ /**
705
+ * The trigger label: a single day with a time span when the shift stays within
706
+ * one day, otherwise both sides spelled out; the placeholder until both are set.
707
+ */
708
+ function shiftRangeLabel({
709
+ range,
710
+ locale,
711
+ placeholder,
712
+ }: {
713
+ range: DateTimeRange;
714
+ locale: string | undefined;
715
+ placeholder: string;
716
+ }): string {
717
+ const { start, end } = range;
718
+ if (start === null || end === null) {
719
+ return placeholder;
720
+ }
721
+ if (startOfDay(start).getTime() === startOfDay(end).getTime()) {
722
+ return `${shiftDateLabel(start, locale)} · ${timeLabel(
723
+ minutesOfDay(start),
724
+ locale,
725
+ )} – ${timeLabel(minutesOfDay(end), locale)}`;
726
+ }
727
+ return `${shiftStepLabel(start, locale)} → ${shiftStepLabel(end, locale)}`;
728
+ }
729
+
730
+ /**
731
+ * On the end step, never let the end land on or before the start: earlier times
732
+ * on the start's own day are grayed out (earlier days are already off the
733
+ * calendar).
734
+ */
735
+ function isShiftTimeDisabled({
736
+ minutes,
737
+ step,
738
+ range,
739
+ activeDay,
740
+ }: {
741
+ minutes: number;
742
+ step: ShiftStep;
743
+ range: DateTimeRange;
744
+ activeDay: Date | null;
745
+ }): boolean {
746
+ if (step !== "end" || range.start === null || activeDay === null) {
747
+ return false;
748
+ }
749
+ if (activeDay.getTime() !== startOfDay(range.start).getTime()) {
750
+ return false;
751
+ }
752
+ return minutes <= minutesOfDay(range.start);
753
+ }
754
+
755
+ /**
756
+ * A single trigger for a shift's start and end. One field, but you pick a full
757
+ * start datetime then a full end datetime: the popover shows one calendar and one
758
+ * time grid at a time, switched by a Start/End tab. Because each end owns its own
759
+ * date, cross-day shifts (e.g. 20:00 → 02:00 the next day) are picked directly,
760
+ * with no wrap trick. Speaks the same `{ start, end }` model as {@link DateTimeRangeInput}.
761
+ */
762
+ function ShiftTimeRangeInput({
763
+ value: controlledValue,
764
+ defaultValue,
765
+ onValueChange,
766
+ placeholder = "Pick a shift",
767
+ id,
768
+ locale,
769
+ disabled = false,
770
+ minuteStep = 30,
771
+ defaultDurationMinutes = 60,
772
+ className,
773
+ "aria-invalid": ariaInvalid,
774
+ "aria-label": ariaLabel,
775
+ }: ShiftTimeRangeInputProps) {
776
+ const { range, commit } = useDateTimeRangeState({
777
+ value: controlledValue,
778
+ defaultValue,
779
+ onValueChange,
780
+ });
781
+ const [open, setOpen] = React.useState(false);
782
+ const [step, setStep] = React.useState<ShiftStep>("start");
783
+
784
+ const handleDaySelect = (picked: Date | undefined) => {
785
+ if (!picked) return;
786
+ commit(rangeWithDay({ range, day: picked, step, defaultDurationMinutes }));
787
+ };
788
+
789
+ const handleTimeSelect = (minutes: number) => {
790
+ commit(rangeWithTime({ range, minutes, step, defaultDurationMinutes }));
791
+ if (step === "start") {
792
+ // Guide the user straight to picking the end.
793
+ setStep("end");
794
+ } else {
795
+ setOpen(false);
796
+ }
797
+ };
798
+
799
+ return (
800
+ <Popover
801
+ open={open}
802
+ onOpenChange={(next) => {
803
+ setOpen(next);
804
+ if (next) setStep("start");
805
+ }}
806
+ >
807
+ <DatePickerTrigger
808
+ slot="shift-time-range-trigger"
809
+ icon={
810
+ <CalendarDotsIcon className="size-4 shrink-0 text-muted-foreground" />
811
+ }
812
+ id={id}
813
+ className={cn("w-full", className)}
814
+ disabled={disabled}
815
+ empty={!(range.start && range.end)}
816
+ aria-invalid={ariaInvalid}
817
+ aria-label={ariaLabel}
818
+ >
819
+ {shiftRangeLabel({ range, locale, placeholder })}
820
+ </DatePickerTrigger>
821
+ <PopoverContent
822
+ data-slot="shift-time-range-content"
823
+ className="w-[calc(100vw-2rem)] p-0 sm:w-auto"
824
+ align="start"
825
+ >
826
+ <ShiftStepTabs
827
+ step={step}
828
+ range={range}
829
+ locale={locale}
830
+ onStepChange={setStep}
831
+ />
832
+ <ShiftPickerBody
833
+ range={range}
834
+ step={step}
835
+ locale={locale}
836
+ minuteStep={minuteStep}
837
+ onDaySelect={handleDaySelect}
838
+ onTimeSelect={handleTimeSelect}
839
+ />
840
+ </PopoverContent>
841
+ </Popover>
842
+ );
843
+ }
844
+
845
+ /** The Start/End switch above the picker, each side showing its current summary. */
846
+ function ShiftStepTabs({
847
+ step,
848
+ range,
849
+ locale,
850
+ onStepChange,
851
+ }: {
852
+ step: ShiftStep;
853
+ range: DateTimeRange;
854
+ locale: string | undefined;
855
+ onStepChange: (step: ShiftStep) => void;
856
+ }) {
857
+ return (
858
+ <div className="grid grid-cols-2 gap-1.5 border-b p-1.5">
859
+ <StepTab
860
+ active={step === "start"}
861
+ heading={startLabelText(locale)}
862
+ value={shiftStepLabel(range.start, locale)}
863
+ onClick={() => onStepChange("start")}
864
+ />
865
+ <StepTab
866
+ active={step === "end"}
867
+ heading={endLabelText(locale)}
868
+ value={shiftStepLabel(range.end, locale)}
869
+ onClick={() => onStepChange("end")}
870
+ />
871
+ </div>
872
+ );
873
+ }
874
+
875
+ /** The active side's day and minutes-of-day, `null` until that side is picked. */
876
+ function shiftActiveSelection(
877
+ range: DateTimeRange,
878
+ step: ShiftStep,
879
+ ): { activeDay: Date | null; activeMinutes: number | null } {
880
+ const active = step === "start" ? range.start : range.end;
881
+ return {
882
+ activeDay: active ? startOfDay(active) : null,
883
+ activeMinutes: active ? minutesOfDay(active) : null,
884
+ };
885
+ }
886
+
887
+ /** Bound the end step's calendar to the start's day onward. */
888
+ function shiftCalendarDisabled(
889
+ range: DateTimeRange,
890
+ step: ShiftStep,
891
+ ): { before: Date } | undefined {
892
+ if (step !== "end") {
893
+ return undefined;
894
+ }
895
+ if (range.start === null) {
896
+ return undefined;
897
+ }
898
+ return { before: startOfDay(range.start) };
899
+ }
900
+
901
+ function shiftCalendarDefaultMonth(
902
+ activeDay: Date | null,
903
+ range: DateTimeRange,
904
+ ): Date | undefined {
905
+ return activeDay ?? range.start ?? undefined;
906
+ }
907
+
908
+ /** The active step's calendar beside its time column. */
909
+ function ShiftPickerBody({
910
+ range,
911
+ step,
912
+ locale,
913
+ minuteStep,
914
+ onDaySelect,
915
+ onTimeSelect,
916
+ }: {
917
+ range: DateTimeRange;
918
+ step: ShiftStep;
919
+ locale: string | undefined;
920
+ minuteStep: number;
921
+ onDaySelect: (day: Date | undefined) => void;
922
+ onTimeSelect: (minutes: number) => void;
923
+ }) {
924
+ const timeOptions = useTimeOptions(minuteStep);
925
+ const { activeDay, activeMinutes } = shiftActiveSelection(range, step);
926
+
927
+ return (
928
+ <div className="flex flex-col sm:flex-row">
929
+ <Calendar
930
+ mode="single"
931
+ selected={activeDay ?? undefined}
932
+ onSelect={onDaySelect}
933
+ defaultMonth={shiftCalendarDefaultMonth(activeDay, range)}
934
+ locale={locale}
935
+ disabled={shiftCalendarDisabled(range, step)}
936
+ autoFocus
937
+ />
938
+ {/* The time column stretches to the calendar's exact height (its scroll
939
+ list is absolutely positioned on desktop so it never grows the row),
940
+ so the divider spans cleanly with no dangling border. */}
941
+ <div className="relative w-full border-t sm:w-40 sm:border-t-0 sm:border-l">
942
+ <TimeList
943
+ options={timeOptions}
944
+ selectedMinutes={activeMinutes}
945
+ isDisabled={(minutes) =>
946
+ isShiftTimeDisabled({ minutes, step, range, activeDay })
947
+ }
948
+ onSelect={onTimeSelect}
949
+ locale={locale}
950
+ ariaLabel={
951
+ step === "start" ? startLabelText(locale) : endLabelText(locale)
952
+ }
953
+ />
954
+ </div>
955
+ </div>
956
+ );
957
+ }
958
+
959
+ /** Localized labels for the Start/End tabs. */
960
+ function startLabelText(locale: string | undefined): string {
961
+ return locale?.startsWith("fr") ? "Début" : "Start";
962
+ }
963
+ function endLabelText(locale: string | undefined): string {
964
+ return locale?.startsWith("fr") ? "Fin" : "End";
965
+ }
966
+
967
+ /** One of the two step tabs: a heading over the side's current datetime summary. */
968
+ function StepTab({
969
+ active,
970
+ heading,
971
+ value,
972
+ onClick,
973
+ }: {
974
+ active: boolean;
975
+ heading: string;
976
+ value: string;
977
+ onClick: () => void;
978
+ }) {
979
+ return (
980
+ <button
981
+ type="button"
982
+ data-active={active || undefined}
983
+ onClick={onClick}
984
+ className="flex flex-col items-start gap-0.5 rounded-md border border-transparent px-2.5 py-1.5 text-left transition-colors hover:bg-accent data-active:border-input data-active:bg-accent"
985
+ >
986
+ <span className="text-xs font-medium text-muted-foreground">
987
+ {heading}
988
+ </span>
989
+ <span className="line-clamp-1 text-sm">{value}</span>
990
+ </button>
991
+ );
992
+ }
993
+
994
+ /** The active step's time options as a single scrollable column. */
995
+ function TimeList({
996
+ options,
997
+ selectedMinutes,
998
+ isDisabled,
999
+ onSelect,
1000
+ locale,
1001
+ ariaLabel,
1002
+ }: {
1003
+ options: ReadonlyArray<number>;
1004
+ selectedMinutes: number | null;
1005
+ isDisabled: (minutes: number) => boolean;
1006
+ onSelect: (minutes: number) => void;
1007
+ locale: string | undefined;
1008
+ ariaLabel: string;
1009
+ }) {
1010
+ const centerOption = React.useCallback((node: HTMLButtonElement | null) => {
1011
+ if (!node) return;
1012
+ requestAnimationFrame(() => node.scrollIntoView?.({ block: "center" }));
1013
+ }, []);
1014
+ // Open scrolled to the selected time, or centered on midday when none is picked
1015
+ // yet (so the list doesn't sit pinned at 00:00). Snap the target to the nearest
1016
+ // option in case the step doesn't divide noon.
1017
+ const focusMinutes = selectedMinutes ?? 12 * 60;
1018
+ const scrollTarget = options.reduce(
1019
+ (best, minutes) =>
1020
+ Math.abs(minutes - focusMinutes) < Math.abs(best - focusMinutes)
1021
+ ? minutes
1022
+ : best,
1023
+ options[0] ?? focusMinutes,
1024
+ );
1025
+ return (
1026
+ <div
1027
+ role="listbox"
1028
+ aria-label={ariaLabel}
1029
+ // On desktop the list fills its stretched parent (which matches the
1030
+ // calendar height) and scrolls as a single narrow column. On mobile the
1031
+ // row stacks under the calendar, so a single column would waste the full
1032
+ // width and scroll forever; lay the times out as a compact grid instead.
1033
+ className="grid max-h-56 grid-cols-3 gap-1 overflow-y-auto overscroll-contain p-1.5 sm:absolute sm:inset-0 sm:flex sm:max-h-none sm:grid-cols-1 sm:flex-col sm:gap-0.5"
1034
+ >
1035
+ {options.map((minutes) => {
1036
+ const selected = selectedMinutes === minutes;
1037
+ return (
1038
+ <Button
1039
+ key={minutes}
1040
+ type="button"
1041
+ role="option"
1042
+ aria-selected={selected}
1043
+ data-selected={selected || undefined}
1044
+ disabled={isDisabled(minutes)}
1045
+ variant="ghost"
1046
+ size="sm"
1047
+ className="shrink-0 justify-center font-normal data-selected:bg-primary data-selected:text-primary-foreground data-selected:hover:bg-primary data-selected:hover:text-primary-foreground"
1048
+ ref={minutes === scrollTarget ? centerOption : undefined}
1049
+ onClick={() => onSelect(minutes)}
1050
+ >
1051
+ {timeLabel(minutes, locale)}
1052
+ </Button>
1053
+ );
1054
+ })}
1055
+ </div>
1056
+ );
1057
+ }
1058
+
1059
+ export {
1060
+ DateTimePicker,
1061
+ type DateTimePickerProps,
1062
+ type DateTimeRange,
1063
+ DateTimeRangeInput,
1064
+ type DateTimeRangeInputProps,
1065
+ NativeDateTimeInput,
1066
+ type NativeDateTimeInputProps,
1067
+ ResponsiveDateTimeInput,
1068
+ type ResponsiveDateTimeInputProps,
1069
+ ShiftTimeRangeInput,
1070
+ type ShiftTimeRangeInputProps,
1071
+ };