// ~/src/siteplane/components/date-field/date-field.tsx "use client"; import { type CalendarDate, type DateValue } from "@internationalized/date"; import { CalendarDaysIcon } from "lucide-react"; import * as React from "react"; import { DateField as DateFieldPrimitive, type DateFieldState, DateInput as DateInputPrimitive, DateSegment as DateSegmentPrimitive, 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, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { 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 } from "../input-group"; import { Popover, PopoverPopup, PopoverTrigger } from "../popover"; import "./date-field.css"; export interface DateFieldProps { "aria-describedby"?: string | undefined; "aria-invalid"?: React.AriaAttributes["aria-invalid"]; "aria-label"?: string; "aria-labelledby"?: string | undefined; calendarProps?: CalendarOptions; className?: string; defaultValue?: Date; disabled?: boolean; form?: string; iconMode?: "action-end" | "decorative-start" | "none"; /** * The error a required field shows while its date is incomplete, such as * after a segment is cleared. Default: "Enter a complete date." */ incompleteLabel?: string; insetStart?: boolean; locale?: string; name?: string; onValueChange?: (value: Date | undefined) => void; openPickerLabel?: string; /** * `input-group` renders only the date segments, without surface, calendar * button or popover, for use inside your own InputGroup. */ profile?: "default" | "input-group"; readOnly?: boolean; required?: boolean; size?: SiteplaneControlSize; textSize?: FormControlTextSize; value?: Date | null; variant?: FormControlVariant; } const dateFieldActionSizes = { md: "icon-xs", lg: "icon-xs", sm: "icon-2xs", xl: "icon-sm", } as const satisfies Record< SiteplaneControlSize, "icon-2xs" | "icon-xs" | "icon-sm" >; function toCalendarDate(value: Date | null | undefined): CalendarDate | null { return value ? fromNativeDate(value) : null; } function toOptionalNativeDate( value: DateValue | null | undefined, ): Date | undefined { return value ? toNativeDate(value) : undefined; } function formatDateFieldValue(value: DateValue | null): string { if (!value) return ""; return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`; } export function DateField(props: DateFieldProps): React.ReactElement { const { locale } = props; const field = ; return locale ? ( {field} ) : ( field ); } function DateFieldRoot({ "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, calendarProps, className, defaultValue, disabled, form, iconMode = "action-end", incompleteLabel = "Enter a complete date.", insetStart = false, name, onValueChange, openPickerLabel = "Open date calendar", profile = "default", readOnly, required, size, textSize, value, variant = "default", }: DateFieldProps): React.ReactElement { const controlled = value !== undefined; const resolvedSize = useResolvedControlSize(size); const field = useFieldControlBridge({ "aria-describedby": ariaDescribedBy, "aria-labelledby": ariaLabelledBy, disabled, name, }); // React Aria validates typed dates unless the application controls the // invalid state through aria-invalid or an invalid Field. const controlledInvalid = ariaInvalid !== undefined ? isInvalidAriaValue(ariaInvalid) : field.invalid || undefined; // A cleared segment empties the field's value. const segmented = useSegmentedValue({ controlled, defaultValue: toCalendarDate(defaultValue), incompleteMessage: incompleteLabel, onValueChange: (nextValue) => onValueChange?.(toOptionalNativeDate(nextValue)), required, serialize: formatDateFieldValue, value: toCalendarDate(value), }); // React Aria's optional props reject `undefined` under // exactOptionalPropertyTypes, so unset values are omitted. return ( {...(field.describedBy === undefined ? {} : { "aria-describedby": field.describedBy })} {...(field.labelledBy || ariaLabel === undefined ? {} : { "aria-label": ariaLabel })} {...(field.labelledBy === undefined ? {} : { "aria-labelledby": field.labelledBy })} className="sp-date-field sp-base-date-field contents" data-foundation="date-field" data-inset-start={insetStart || undefined} data-profile={profile} data-size={resolvedSize} data-slot="date-field" {...(controlled ? { value: segmented.ownerValue } : { defaultValue: toCalendarDate(defaultValue) })} granularity="day" isDateUnavailable={(date) => isCalendarDayDisabled(toNativeDate(date), calendarProps?.disabled) } isDisabled={field.disabled} {...(controlledInvalid === undefined ? {} : { isInvalid: controlledInvalid })} {...(readOnly === undefined ? {} : { isReadOnly: readOnly })} {...(required === undefined ? {} : { isRequired: required })} {...calendarMonthBounds(calendarProps ?? {})} // Typed segments start from the calendar's today, `todayDate` when set. {...(calendarProps?.todayDate === undefined ? {} : { placeholderValue: fromNativeDate(calendarProps.todayDate) })} onChange={segmented.onOwnerChange} shouldForceLeadingZeros validate={segmented.validate} > {({ state }) => ( )} ); } function DateFieldContent({ calendarProps, className, controlled, controlledInvalid, dateFieldState, defaultValue, field, form, iconMode, insetStart, openPickerLabel, profile, readOnly, required, resolvedSize, segmented, textSize, variant, }: { calendarProps: CalendarOptions | undefined; className: string | undefined; controlled: boolean; controlledInvalid: boolean | undefined; dateFieldState: DateFieldState; defaultValue: Date | undefined; field: FieldControlBridge; form: string | undefined; iconMode: "action-end" | "decorative-start" | "none"; insetStart: boolean; openPickerLabel: string; profile: "default" | "input-group"; readOnly: boolean | undefined; required: boolean | undefined; resolvedSize: SiteplaneControlSize; segmented: SegmentedValue; textSize: FormControlTextSize | undefined; variant: FormControlVariant; }): React.ReactElement { const [open, setOpen] = React.useState(false); const segmentsRef = React.useRef(null); const hiddenInputRef = useFormReset(form, () => { if (!controlled) dateFieldState.setValue(toCalendarDate(defaultValue)); }); // A cleared segment leaves the field without a value, although React Aria // still holds the last complete date. const cleared = hasClearedSegment(dateFieldState); const ownerValue = dateFieldState.value as CalendarDate | null; const fieldValue = cleared ? null : ownerValue; const serializedValue = formatDateFieldValue(fieldValue); React.useLayoutEffect(() => { segmented.sync({ cleared, effective: fieldValue, value: ownerValue }); }); const invalid = controlledInvalid ?? (dateFieldState.displayValidation.isInvalid || field.valid === false); const proxyProps = useFieldControlProxy({ controlled: controlledInvalid !== undefined, disabled: field.disabled, displayValidation: dateFieldState.displayValidation, focusTarget: () => getFirstSegment(segmentsRef.current), form, name: field.name, onRevalidate: dateFieldState.commitValidation, realtimeValidation: dateFieldState.realtimeValidation, required, value: serializedValue, }); useFieldErrorSegments(segmentsRef, { controlled: controlledInvalid !== undefined, ownerInvalid: dateFieldState.isInvalid, }); // The segments read in the locale's direction, which React Aria's arrow // keys follow, whatever the page's: an en-US date stays 10/14/2026 in a // right-to-left page. The dir attribute also isolates the row from the // text around it. const { direction } = useLocale(); const input = ( {(segment) => ( {/* React Aria builds the segments with formatToParts, whose spaces differ between the server's ICU and the browser's (Node writes a narrow no-break space before the "г." of a bg-BG date, Chrome a plain one); the segment shows the text both agree on, or the prerendered field fails hydration (React error #418). */} {({ text }) => normalizeFormattedText(text)} )} ); const control = ( {iconMode === "decorative-start" ? ( ) : null} {input} {iconMode === "action-end" ? ( } > ) : null} ); return ( <> {profile === "input-group" ? ( input ) : iconMode === "action-end" ? ( {control} { dateFieldState.setValue(fromNativeDate(nextValue)); setOpen(false); }} value={toOptionalNativeDate(dateFieldState.value) ?? null} /> ) : ( control )} {proxyProps ? ( ) : null} ); } function getFirstSegment(container: HTMLElement | null): HTMLElement | null { return ( container?.querySelector( '[role="spinbutton"], [role="textbox"]', ) ?? null ); } // ~/src/siteplane/components/date-field/date-field.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* The heights are the Input scale (28, 32, 36 and 48 px), so a date field is as * tall as the Input, Select and Button of the same size. The width is the * segments' own, like the Date Time Picker and the Number Field; a width utility * in className (w-full in a form column) replaces it. */ .sp-date-field-control { --date-field-control-height: var(--sp-rw-control-md); --date-field-segment-start: calc( var(--sp-rw-space-3) - var(--input-segment-padding-inline) ); max-width: 100%; height: var(--date-field-control-height); } .sp-date-field-control[data-icon-mode="action-end"] { padding-inline-end: var(--date-field-control-height); } .sp-date-field-control[data-size="sm"] { --date-field-control-height: var(--sp-rw-control-sm); --date-field-segment-start: calc( var(--sp-rw-space-2-5) - var(--input-segment-padding-inline) ); } .sp-date-field-control[data-size="lg"] { --date-field-control-height: var(--sp-rw-control-lg); } .sp-date-field-control[data-size="xl"] { --date-field-control-height: var(--sp-rw-control-xl); --date-field-segment-start: calc( var(--sp-rw-space-4) - var(--input-segment-padding-inline) ); } .sp-date-field-input { display: inline-flex; align-items: center; height: 100%; outline: none; } /* The insets of the segments belong to the field, which takes the page's * direction, while the segment row takes the locale's (its dir): the field * pads its start, and its end where no picker button ends it. Without the * button the segments keep the inset of the start at the end too, so a * field as wide as its segments is balanced. */ .sp-date-field-control { padding-inline-start: var(--date-field-segment-start); } .sp-date-field-control:not([data-icon-mode="action-end"]) { padding-inline-end: var(--date-field-segment-start); } /* The leading icon is a regular InputGroup addon at the field's start; the * first segment's text starts where an InputGroup input's text starts after * the same icon. */ .sp-date-field-control:has(> .sp-date-field-icon-addon) { padding-inline-start: 0; } .sp-date-field-control > .sp-date-field-icon-addon { margin-inline-end: calc( var(--sp-composite-control-padding-inline-start) - var(--input-segment-padding-inline) ); } .sp-date-segment { outline: none; } .sp-date-segment:not([data-type="literal"]) { display: inline-flex; flex: none; align-items: center; justify-content: center; border-radius: calc(var(--radius-md) + var(--input-segment-padding-block)); background-clip: content-box; caret-color: var(--color-primary); cursor: text; font-variant-numeric: tabular-nums; padding-block: var(--input-segment-padding-block); padding-inline: var(--input-segment-padding-inline); text-align: center; } /* Minimum widths keep digits steady; wider placeholders such as "mm" or * larger text grow the segment instead of running into its neighbours. */ .sp-date-segment:is([data-type="day"], [data-type="month"]) { min-width: calc(var(--spacing) * 7); } .sp-date-segment[data-type="year"] { min-width: calc(var(--spacing) * 11); } .sp-date-segment[data-literal-kind="separator"] { margin-inline: calc(var(--spacing) * -1); } .sp-date-segment[data-focused]:not([data-type="literal"]) { background-color: var(--input-segment-background); } .sp-date-segment[data-placeholder]:not([data-type="literal"]) { color: var(--color-muted-foreground); } .sp-date-segment::selection { background-color: transparent; } .sp-base-date-field[data-profile="input-group"][data-inset-start="true"] .sp-date-segment[data-type="day"] { margin-inline-start: calc(var(--spacing) * 2); } .sp-date-field-control > .sp-input-group-addon[data-align="inline-end"] { position: absolute; inset-block: 0; inset-inline-end: 0; width: var(--date-field-control-height); padding-inline: 0 !important; } .sp-date-field-popover { --popup-width: auto; } } // ~/src/siteplane/components/date-field/index.ts export { DateField } from "./date-field"; export type { DateFieldProps } from "./date-field";