// ~/src/siteplane/components/date-time-picker/date-time-picker.tsx
"use client";
import {
CalendarDateTime,
type DateValue,
Time,
toCalendarDateTime as toCalendarDateTimeAt,
} from "@internationalized/date";
import { CalendarDaysIcon, ClockIcon } from "lucide-react";
import * as React from "react";
import {
Button as DateTimeButtonPrimitive,
DateFieldStateContext,
DateInput as DateTimeInputPrimitive,
DatePicker as DateTimePickerPrimitive,
type DatePickerState,
DateSegment as DateTimeSegmentPrimitive,
Dialog as DateTimeDialogPrimitive,
DialogTrigger as TimeSelectorTriggerPrimitive,
Group as DateTimeGroupPrimitive,
Popover as DateTimePopoverPrimitive,
useLocale,
} from "react-aria-components";
import { isInvalidAriaValue } from "../../system/form/aria-invalid";
import { useFormReset } from "../../system/form/use-form-reset";
import { normalizeFormattedText } from "../../system/locale/formatted-text";
import { SiteplaneLocaleProvider } from "../../system/locale/siteplane-locale-provider";
import {
type SiteplaneControlSize,
SiteplaneUIPortalScope,
useResolvedControlSize,
} from "../../system/provider/siteplane-ui-provider";
import {
formControlTextClassNames,
surfaceClassNames,
type FormControlTextSize,
type FormControlVariant,
} from "../../system/styles/surface";
import { cn } from "../../system/utils/cn";
import { Button } from "../button";
import { Calendar } from "../calendar";
import {
calendarMonthBounds,
type CalendarOptions,
fromNativeDate,
isCalendarDayDisabled,
toNativeDate,
} from "../calendar/calendar";
import {
type FieldControlBridge,
hasClearedSegment,
type SegmentedValue,
useFieldControlBridge,
useFieldControlProxy,
useFieldErrorSegments,
useSegmentedValue,
} from "../field/field-control-bridge";
import { Input } from "../input";
import { ExternalInputOwner } from "../input/input";
import { InputGroup, InputGroupAddon, InputGroupText } from "../input-group";
import {
type TimeFieldHourCycle,
TimeFieldOptionColumn,
useHourOptionLabels,
} from "../time-field/time-field";
// The shared date segment styles are owned by DateField.
import "../date-field/date-field.css";
import "./date-time-picker.css";
export type DateTimePickerValue = {
date: Date;
time: {
hour: number;
minute: number;
};
};
export interface DateTimePickerProps {
"aria-describedby"?: string | undefined;
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
"aria-label"?: string;
"aria-labelledby"?: string | undefined;
calendarProps?: CalendarOptions;
className?: string;
defaultValue?: DateTimePickerValue;
disabled?: boolean;
form?: string;
/**
* The clock the time segments and the time selector show: `24` (00 to 23),
* or `12` with the locale's AM and PM. The value stays in 24-hour time.
*/
hourCycle?: TimeFieldHourCycle;
/**
* The error a required picker shows while its date and time are
* incomplete, such as after a segment is cleared. Default: "Enter a
* complete date and time."
*/
incompleteLabel?: string;
locale?: string;
minuteStep?: number;
name?: string;
onValueChange?: (value: DateTimePickerValue | undefined) => void;
openDatePickerLabel?: string;
openTimePickerLabel?: string;
readOnly?: boolean;
required?: boolean;
size?: SiteplaneControlSize;
textSize?: FormControlTextSize;
value?: DateTimePickerValue | null;
variant?: FormControlVariant;
}
const actionSizes = {
md: "icon-xs",
lg: "icon-xs",
sm: "icon-2xs",
xl: "icon-sm",
} as const satisfies Record<
SiteplaneControlSize,
"icon-2xs" | "icon-xs" | "icon-sm"
>;
const hourOptions = Array.from({ length: 24 }, (_, hour) =>
String(hour).padStart(2, "0"),
);
function normalizeTime(value: DateTimePickerValue["time"]) {
return {
hour: Math.min(Math.max(Math.trunc(value.hour) || 0, 0), 23),
minute: Math.min(Math.max(Math.trunc(value.minute) || 0, 0), 59),
};
}
function toCalendarDateTime(
value: DateTimePickerValue | null | undefined,
): CalendarDateTime | null {
if (!value) return null;
const time = normalizeTime(value.time);
return new CalendarDateTime(
value.date.getFullYear(),
value.date.getMonth() + 1,
value.date.getDate(),
time.hour,
time.minute,
);
}
function toNativeValue(
value: DateValue | null | undefined,
): DateTimePickerValue | undefined {
if (!value || !("hour" in value)) return undefined;
return {
date: toNativeDate(value),
time: { hour: value.hour, minute: value.minute },
};
}
function formatValue(value: DateValue | null | undefined): string {
const nativeValue = toNativeValue(value);
if (!nativeValue) return "";
return `${String(nativeValue.date.getFullYear()).padStart(4, "0")}-${String(nativeValue.date.getMonth() + 1).padStart(2, "0")}-${String(nativeValue.date.getDate()).padStart(2, "0")}T${String(nativeValue.time.hour).padStart(2, "0")}:${String(nativeValue.time.minute).padStart(2, "0")}`;
}
/**
* The Calendar's month bounds as date-time bounds of the picker: from the
* start of the first allowed day to 23:59 on the last.
*/
function dateTimeBounds(calendarProps: CalendarOptions | undefined): {
maxValue?: CalendarDateTime;
minValue?: CalendarDateTime;
} {
const { maxValue, minValue } = calendarMonthBounds(calendarProps ?? {});
return {
...(maxValue === undefined
? {}
: { maxValue: toCalendarDateTimeAt(maxValue, new Time(23, 59)) }),
...(minValue === undefined
? {}
: { minValue: toCalendarDateTimeAt(minValue) }),
};
}
type DateTimeSegmentValue = React.ComponentProps<
typeof DateTimeSegmentPrimitive
>["segment"];
function isTimeSegment(segment: DateTimeSegmentValue | undefined): boolean {
return (
segment?.type === "hour" ||
segment?.type === "minute" ||
segment?.type === "second" ||
segment?.type === "dayPeriod" ||
segment?.type === "timeZoneName"
);
}
function getSegmentPart(
segment: DateTimeSegmentValue,
segments: readonly DateTimeSegmentValue[],
): "date" | "hidden" | "time" {
if (isTimeSegment(segment)) return "time";
if (segment.type !== "literal") return "date";
if (segment.text.includes(":")) return "time";
// The space before AM or PM on a 12-hour clock lies between two time
// segments, so it belongs to the time. React Aria isolates the time with
// bidi marks, which are literals of their own, so the neighbours are the
// nearest segments that are not literals.
if (/^\s+$/.test(segment.text)) {
const index = segments.indexOf(segment);
const previous = segments
.slice(0, Math.max(index, 0))
.findLast(({ type }) => type !== "literal");
const next = segments
.slice(index + 1)
.find(({ type }) => type !== "literal");
if (index >= 0 && isTimeSegment(previous) && isTimeSegment(next)) {
return "time";
}
}
if (/^[./\-\s]+$/.test(segment.text)) return "date";
return "hidden";
}
// The picker's DateInput keeps its own field state; the segments tell the
// picker whether it shows a cleared segment.
function DateTimeSegment({
onClearedChange,
segment,
}: {
onClearedChange: (cleared: boolean) => void;
segment: DateTimeSegmentValue;
}): React.ReactElement {
const fieldState = React.useContext(DateFieldStateContext);
const cleared = hasClearedSegment(fieldState);
React.useLayoutEffect(() => onClearedChange(cleared));
const part = getSegmentPart(segment, fieldState?.segments ?? []);
// The segment shows the text the server and the browser agree on: Node's
// formatToParts puts a narrow no-break space before AM and PM where the
// browser has a plain one (time-field.tsx).
return (
{({ text }) => normalizeFormattedText(text)}
);
}
// The date, the date button, the comma and the time read in the locale's
// direction, which React Aria's arrow keys follow, whatever the page's: an
// en-US value keeps its date before its time in a right-to-left page. The dir
// attribute also isolates the row from the text around it. The field keeps
// the page's direction, so the time button ends it at the page's inline end,
// and date-time-picker.css keeps the field's insets on the field's sides.
function DateTimeSegments({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
const { direction } = useLocale();
return (