@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.
- package/package.json +78 -0
- package/src/components/ui/accordion.tsx +79 -0
- package/src/components/ui/alert-dialog.tsx +198 -0
- package/src/components/ui/alert.tsx +105 -0
- package/src/components/ui/aspect-ratio.tsx +30 -0
- package/src/components/ui/avatar.tsx +125 -0
- package/src/components/ui/badge-variants.ts +166 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/banner.tsx +84 -0
- package/src/components/ui/breadcrumb.tsx +127 -0
- package/src/components/ui/button-group.tsx +99 -0
- package/src/components/ui/button-variants.ts +108 -0
- package/src/components/ui/button.tsx +54 -0
- package/src/components/ui/calendar.tsx +266 -0
- package/src/components/ui/card.tsx +155 -0
- package/src/components/ui/carousel.tsx +319 -0
- package/src/components/ui/chat.tsx +896 -0
- package/src/components/ui/checkbox-group.tsx +44 -0
- package/src/components/ui/checkbox.tsx +34 -0
- package/src/components/ui/chip.tsx +60 -0
- package/src/components/ui/collapsible.tsx +44 -0
- package/src/components/ui/color-picker.tsx +191 -0
- package/src/components/ui/combobox.tsx +317 -0
- package/src/components/ui/command.tsx +187 -0
- package/src/components/ui/confirm-dialog.tsx +125 -0
- package/src/components/ui/context-menu.tsx +254 -0
- package/src/components/ui/copyable-text.tsx +65 -0
- package/src/components/ui/date-picker.tsx +316 -0
- package/src/components/ui/date-time-picker.tsx +1071 -0
- package/src/components/ui/dialog.tsx +176 -0
- package/src/components/ui/direction.tsx +15 -0
- package/src/components/ui/drawer.tsx +178 -0
- package/src/components/ui/dropdown-menu.tsx +247 -0
- package/src/components/ui/empty.tsx +131 -0
- package/src/components/ui/field.tsx +257 -0
- package/src/components/ui/formatted-input.tsx +219 -0
- package/src/components/ui/gallery.tsx +161 -0
- package/src/components/ui/hover-card.tsx +77 -0
- package/src/components/ui/icon-picker.tsx +193 -0
- package/src/components/ui/image-cropper.tsx +787 -0
- package/src/components/ui/image-upload-field.tsx +344 -0
- package/src/components/ui/input-group.tsx +175 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/item.tsx +212 -0
- package/src/components/ui/kbd.tsx +62 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/list.tsx +46 -0
- package/src/components/ui/menu-variants.ts +71 -0
- package/src/components/ui/menubar.tsx +226 -0
- package/src/components/ui/money-input.tsx +96 -0
- package/src/components/ui/native-date-picker.tsx +120 -0
- package/src/components/ui/native-select.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +183 -0
- package/src/components/ui/pagination.tsx +150 -0
- package/src/components/ui/popover.tsx +104 -0
- package/src/components/ui/profile-header.tsx +196 -0
- package/src/components/ui/progress.tsx +71 -0
- package/src/components/ui/radio-group.tsx +115 -0
- package/src/components/ui/rating.tsx +200 -0
- package/src/components/ui/resizable.tsx +61 -0
- package/src/components/ui/responsive-dialog.tsx +278 -0
- package/src/components/ui/responsive-select.tsx +289 -0
- package/src/components/ui/responsive-sheet.tsx +225 -0
- package/src/components/ui/scroll-area.tsx +51 -0
- package/src/components/ui/section.tsx +106 -0
- package/src/components/ui/segmented-control.tsx +148 -0
- package/src/components/ui/select.tsx +216 -0
- package/src/components/ui/separator.tsx +69 -0
- package/src/components/ui/sheet.tsx +154 -0
- package/src/components/ui/shortcut.tsx +41 -0
- package/src/components/ui/sidebar.tsx +739 -0
- package/src/components/ui/skeleton.tsx +23 -0
- package/src/components/ui/slider.tsx +74 -0
- package/src/components/ui/sonner.tsx +64 -0
- package/src/components/ui/spinner.tsx +19 -0
- package/src/components/ui/stat-card.tsx +138 -0
- package/src/components/ui/stepper.tsx +181 -0
- package/src/components/ui/sticky-action-bar.tsx +42 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +132 -0
- package/src/components/ui/tabs.tsx +77 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/time-picker.tsx +221 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +79 -0
- package/src/components/ui/translation-input.tsx +126 -0
- package/src/components/ui/user-avatar.tsx +74 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/use-command-palette.ts +28 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/hooks/use-pagination.ts +81 -0
- package/src/hooks/use-picker-state.ts +44 -0
- package/src/lib/cva.ts +18 -0
- package/src/lib/time-math.ts +62 -0
- package/src/lib/utils.ts +14 -0
- package/src/styles/globals.css +36 -0
- 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
|
+
};
|