// ~/src/siteplane/components/date-picker/date-picker.tsx
"use client";
import { type DateValue } from "@internationalized/date";
import { CalendarIcon } from "lucide-react";
import * as React from "react";
import {
Button as DatePickerButtonPrimitive,
DatePicker as DatePickerPrimitive,
type DatePickerState,
Dialog as DatePickerDialogPrimitive,
Group as DatePickerGroupPrimitive,
Popover as DatePickerPopoverPrimitive,
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,
} from "../../system/provider/siteplane-ui-provider";
import {
surfaceClassNames,
type FormControlTextSize,
} 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 {
useFieldControlBridge,
useFieldControlProxy,
} from "../field/field-control-bridge";
import { ExternalInputOwner, Input } from "../input/input";
import "./date-picker.css";
export type DatePickerProps = {
"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;
/** The id of the form the date belongs to, like DateField. */
form?: string;
formatValue?: ((value: Date | undefined) => React.ReactNode) | undefined;
locale?: string;
/** Submits the date as `YYYY-MM-DD` under this name, like DateField. */
name?: string;
onValueChange?: (value: Date | undefined) => void;
placeholder?: React.ReactNode;
/** A form is not submitted while the picker holds no date. */
required?: boolean;
/** The control size, like the other date and time fields. */
size?: SiteplaneControlSize;
/**
* The trigger text: unset is 14 px, or 16 px at `xl`; `sm` and `base` set
* 14 px and 16 px at every size.
*/
textSize?: FormControlTextSize;
value?: Date | null;
};
function toCalendarDate(value: Date | null | undefined) {
return value ? fromNativeDate(value) : null;
}
function toOptionalNativeDate(
value: DateValue | null | undefined,
): Date | undefined {
return value ? toNativeDate(value) : undefined;
}
function formatDatePickerValue(value: DateValue | null | undefined): string {
if (!value) return "";
return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`;
}
function noop(): void {}
export function DatePicker(props: DatePickerProps): React.ReactElement {
const picker = ;
return props.locale ? (
{picker}
) : (
picker
);
}
function DatePickerRoot({
"aria-describedby": ariaDescribedBy,
"aria-invalid": ariaInvalid,
"aria-label": ariaLabel = "Choose date",
"aria-labelledby": ariaLabelledBy,
calendarProps,
className,
defaultValue,
disabled,
form,
formatValue,
name,
onValueChange,
placeholder = "Choose date",
required,
size,
textSize,
value,
}: DatePickerProps): React.ReactElement {
const controlled = value !== undefined;
const groupRef = React.useRef(null);
const field = useFieldControlBridge({
"aria-describedby": ariaDescribedBy,
"aria-labelledby": ariaLabelledBy,
disabled,
name,
});
// React Aria validates the date unless the application controls the invalid
// state through aria-invalid or an invalid Field.
const controlledInvalid =
ariaInvalid !== undefined
? isInvalidAriaValue(ariaInvalid)
: field.invalid || undefined;
// A required picker outside a Field that stopped a form submission.
const [missing, setMissing] = React.useState(false);
const invalid = controlledInvalid ?? (field.valid === false || missing);
const focusTrigger = () =>
groupRef.current?.querySelector(
'[data-slot="date-picker-trigger"]',
) ?? null;
// The React Aria trigger drops aria-invalid, so the state is set on the
// rendered button.
React.useLayoutEffect(() => {
const trigger = groupRef.current?.querySelector(
'[data-slot="date-picker-trigger"]',
);
if (invalid) trigger?.setAttribute("aria-invalid", "true");
else trigger?.removeAttribute("aria-invalid");
});
const { locale } = useLocale();
const shortDateFormatter = React.useMemo(
() =>
new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
year: "numeric",
}),
[locale],
);
// React Aria's optional props reject `undefined` under
// exactOptionalPropertyTypes, so unset values are omitted.
return (
isCalendarDayDisabled(toNativeDate(date), calendarProps?.disabled)
}
isDisabled={field.disabled}
{...(controlledInvalid === undefined
? {}
: { isInvalid: controlledInvalid })}
{...(required === undefined ? {} : { isRequired: required })}
{...calendarMonthBounds(calendarProps ?? {})}
onChange={(nextValue) => {
if (nextValue) setMissing(false);
onValueChange?.(toOptionalNativeDate(nextValue));
}}
shouldCloseOnSelect
>
{({ state }) => {
const nativeValue = toOptionalNativeDate(state.value);
const label = formatValue
? formatValue(nativeValue)
: nativeValue
? normalizeFormattedText(shortDateFormatter.format(nativeValue))
: placeholder;
return (
<>
}
size={size}
variant="outline"
>
{label}
{
setMissing(true);
focusTrigger()?.focus();
}}
required={required}
state={state}
/>
>
);
}}
);
}
/**
* The picker's form value, as DateField submits it: `YYYY-MM-DD` under `name`,
* empty while no date is chosen. Inside a Field a proxy input registers the
* picker with the Field and Form, which check `required`. Outside a Field a
* visually hidden text input checks it, since a hidden input takes no part in
* constraint validation.
*/
function DatePickerFormValue({
controlled,
controlledInvalid,
defaultValue,
disabled,
focusTarget,
form,
name,
onMissing,
required,
state,
}: {
controlled: boolean;
controlledInvalid: boolean | undefined;
defaultValue: Date | undefined;
disabled: boolean;
focusTarget: () => HTMLElement | null;
form: string | undefined;
name: string | undefined;
onMissing: () => void;
required: boolean | undefined;
state: DatePickerState;
}): React.ReactElement {
const resetRef = useFormReset(form, () => {
if (!controlled) state.setValue(toCalendarDate(defaultValue));
});
const value = formatDatePickerValue(state.value);
const proxyProps = useFieldControlProxy({
controlled: controlledInvalid !== undefined,
disabled,
displayValidation: state.displayValidation,
focusTarget,
form,
name,
onRevalidate: state.commitValidation,
realtimeValidation: state.realtimeValidation,
required,
value,
});
return (
<>
{proxyProps ? (
) : required ? (
{
event.preventDefault();
onMissing();
}}
required
tabIndex={-1}
type="text"
unstyled
value={value}
/>
) : null}
>
);
}
// ~/src/siteplane/components/date-picker/date-picker.css
@layer theme, base, components, siteplane, utilities;
@layer siteplane {
/* An invalid trigger takes the field error contour, like an invalid Select
* trigger. */
:root [data-chassis].sp-date-picker-trigger[data-invalid] {
--sp-rw-button-rim: inset 0 0 0 var(--sp-rw-border-default)
light-dark(
oklch(67.3% 0.2102 26.56 / 40%),
oklch(76.15% 0.1564 26.56 / 40%)
);
}
/* The trigger takes the field text scale of the other date and time fields:
* the Button's 14 px, or 16 px at xl, unless textSize sets one size for every
* control size. */
:root [data-chassis].sp-date-picker-trigger[data-text-size="sm"] {
font-size: var(--sp-rw-font-14);
line-height: var(--sp-rw-line-21);
}
:root [data-chassis].sp-date-picker-trigger[data-text-size="base"] {
font-size: var(--sp-rw-font-16);
line-height: var(--sp-rw-line-24);
}
/* The surface (border, radius, shadow) is the floating-md recipe that
* design-rework.css draws for the popover's data-slot. */
.sp-date-picker-popover {
z-index: 50;
width: max-content;
padding: var(--sp-rw-space-4);
outline: none;
transform-origin: var(--trigger-anchor-point);
}
.sp-date-picker-popover[data-entering] {
animation: sp-date-picker-enter var(--motion-duration-overlay)
var(--motion-ease-standard) both;
}
.sp-date-picker-popover[data-exiting] {
animation: sp-date-picker-exit var(--motion-duration-fast)
var(--motion-ease-soft-in-out) both;
}
@keyframes sp-date-picker-enter {
from {
opacity: 0;
transform: scale(0.98);
}
}
@keyframes sp-date-picker-exit {
to {
opacity: 0;
transform: scale(0.98);
}
}
@media (prefers-reduced-motion: reduce) {
.sp-date-picker-popover[data-entering],
.sp-date-picker-popover[data-exiting] {
animation-duration: 1ms;
}
}
html[data-motion-reduce="true"] .sp-date-picker-popover[data-entering],
html[data-motion-reduce="true"] .sp-date-picker-popover[data-exiting],
[data-sp-motion="reduced"] .sp-date-picker-popover[data-entering],
[data-sp-motion="reduced"] .sp-date-picker-popover[data-exiting] {
animation-duration: 1ms;
}
}
// ~/src/siteplane/components/date-picker/index.ts
export * from "./date-picker";