// ~/src/siteplane/components/time-field/time-field.tsx "use client"; import { getLocalTimeZone, Time, today, toCalendarDateTime, } from "@internationalized/date"; import { ClockIcon } from "lucide-react"; import { animate } from "motion/react"; import * as React from "react"; import { DateInput as DateInputPrimitive, DateSegment as DateSegmentPrimitive, TimeField as TimeFieldPrimitive, type TimeFieldState, 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 { getMotionTransitionFromCss } from "../../system/motion/css-transition"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { formControlTextClassNames, type FormControlTextSize, type FormControlVariant, } from "../../system/styles/surface"; import { cn } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { Button } from "../button"; 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 } from "../input-group"; import { ListBox, ListBoxItem } from "../list-box"; import { Popover, PopoverPopup, PopoverTrigger } from "../popover"; import { ScrollArea } from "../scroll-area"; import "./time-field.css"; export type TimeFieldValue = { hour: number; minute: number; }; /** A 12-hour clock with AM and PM, or a 24-hour clock. */ export type TimeFieldHourCycle = 12 | 24; export interface TimeFieldProps { "aria-describedby"?: string | undefined; "aria-invalid"?: React.AriaAttributes["aria-invalid"]; "aria-label"?: string; "aria-labelledby"?: string | undefined; /** Where the segments sit in a field wider than they are. */ align?: "start" | "center"; className?: string; defaultValue?: TimeFieldValue; disabled?: boolean; form?: string; /** * The clock the segments and the selector show: `24` (00 to 23), or `12` * with the locale's AM and PM. The value stays in 24-hour time either way. */ hourCycle?: TimeFieldHourCycle; hourLabel?: string; hoursLabel?: string; iconMode?: "action-end" | "decorative-start" | "none"; /** * The error a required field shows while its time is incomplete, such as * after a segment is cleared. Default: "Enter a complete time." */ incompleteLabel?: string; minuteLabel?: string; minutesLabel?: string; minuteStep?: number; name?: string; /** Reports the time the user set, or `undefined` when the user cleared it. */ onValueChange?: (value: TimeFieldValue | undefined) => void; openPickerLabel?: string; readOnly?: boolean; required?: boolean; size?: SiteplaneControlSize; textSize?: FormControlTextSize; value?: TimeFieldValue | null; variant?: FormControlVariant; } const timeFieldActionSizes = { 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"), ); /** * The names of the hour options on a 12-hour clock, such as "09 PM" for 21, * in the locale's day period and with the leading zero the segments show. * Unset on a 24-hour clock, whose options are the hours themselves. The Time * Field and the Date Time Picker selectors share it. */ export function useHourOptionLabels( hourCycle: TimeFieldHourCycle, ): Record | undefined { const { locale } = useLocale(); return React.useMemo(() => { if (hourCycle !== 12) return undefined; // The segments' own rule: React Aria turns a 12-hour cycle into hour12. const format = new Intl.DateTimeFormat(locale, { hour: "2-digit", hour12: true, }); return Object.fromEntries( hourOptions.map((option) => [ option, normalizeFormattedText( format.format(new Date(2000, 0, 1, Number(option))), ), ]), ); }, [hourCycle, locale]); } /** * A literal that separates two segments, the colon or the space before AM, * cancels their padding (time-field.css), so it sits as close to them as in * text. */ function isSeparatorLiteral(text: string): boolean { return text.includes(":") || /^\s+$/.test(text); } function normalizeTimeFieldValue(value: TimeFieldValue): TimeFieldValue { 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 toTime(value: TimeFieldValue | null | undefined): Time | null { if (!value) return null; const normalized = normalizeTimeFieldValue(value); return new Time(normalized.hour, normalized.minute); } function fromTime(value: Time): TimeFieldValue { return { hour: value.hour, minute: value.minute }; } function formatTimeFieldValue(value: Time | null): string { if (!value) return ""; return `${String(value.hour).padStart(2, "0")}:${String(value.minute).padStart(2, "0")}`; } function setTimeFieldStateValue( state: TimeFieldState, value: Time | null, ): void { if (!value) { state.setValue(null); return; } const dateValue = state.value; state.setValue( dateValue ? dateValue.set({ hour: value.hour, millisecond: value.millisecond, minute: value.minute, second: value.second, }) : toCalendarDateTime(today(getLocalTimeZone()), value), ); } export function TimeField(props: TimeFieldProps): React.ReactElement { return ; } function TimeFieldRoot({ "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, align = "start", className, defaultValue, disabled, form, hourCycle = 24, hourLabel, hoursLabel = "Hours", iconMode = "action-end", incompleteLabel = "Enter a complete time.", minuteLabel, minutesLabel = "Minutes", minuteStep = 5, name, onValueChange, openPickerLabel = "Open time selector", readOnly, required, size, textSize, value, variant = "default", }: TimeFieldProps): React.ReactElement { const controlled = value !== undefined; const normalizedDefaultValue = defaultValue ? normalizeTimeFieldValue(defaultValue) : undefined; const resolvedSize = useResolvedControlSize(size); const resolvedTextSize = textSize ?? (resolvedSize === "lg" || resolvedSize === "xl" ? "base" : undefined); const resolvedMinuteStep = Math.min( Math.max(Math.trunc(minuteStep) || 5, 1), 60, ); const minuteOptions = React.useMemo( () => Array.from({ length: Math.ceil(60 / resolvedMinuteStep) }, (_, index) => String(index * resolvedMinuteStep).padStart(2, "0"), ), [resolvedMinuteStep], ); const field = useFieldControlBridge({ "aria-describedby": ariaDescribedBy, "aria-labelledby": ariaLabelledBy, disabled, name, }); // React Aria validates the time unless the application controls the invalid // state through aria-invalid or an invalid Field. const controlledInvalid = ariaInvalid !== undefined ? isInvalidAriaValue(ariaInvalid) : field.invalid || undefined; // Segment names built from aria-label; a FieldLabel or aria-labelledby // names the segments through React Aria instead. const namedByLabel = ariaLabel !== undefined && !field.labelledBy; // A cleared field, or a cleared segment, is reported as undefined, like // DateField. const segmented = useSegmentedValue