// ~/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";