diff --git a/packages/apollo-wind/src/components/forms/field-control.test.tsx b/packages/apollo-wind/src/components/forms/field-control.test.tsx
index 26d780657..fac50d935 100644
--- a/packages/apollo-wind/src/components/forms/field-control.test.tsx
+++ b/packages/apollo-wind/src/components/forms/field-control.test.tsx
@@ -105,3 +105,34 @@ describe('FIELD_CONTROL_GEOMETRY', () => {
expect(formField.onChange).toHaveBeenCalledWith(null);
});
});
+
+// Hosts seed date fields from JSON, so the bound value can be any string.
+describe('FieldControl date values', () => {
+ const dateField: FieldMetadata = { name: 'day', type: 'date', label: 'Day' };
+ const dateTimeField: FieldMetadata = { name: 'at', type: 'datetime', label: 'At' };
+
+ it('shows an ISO date-only value as that local calendar day', () => {
+ render();
+ expect(screen.getByText('September 23rd, 2026')).toBeInTheDocument();
+ });
+
+ it('shows an ISO date-time value as its local date and time', () => {
+ render();
+ expect(screen.getByText(/September 23rd, 2026 at 07:30/)).toBeInTheDocument();
+ });
+
+ it.each([
+ ['an empty string', ''],
+ ['a locale string', 'Wed Sep 23 2026 00:00:00 GMT-0700 (Pacific Daylight Time)'],
+ ['an expression', '=vars.when'],
+ ])('shows the placeholders for %s instead of throwing', (_label, value) => {
+ render(
+ <>
+
+
+ >
+ );
+ expect(screen.getByText('Pick a date')).toBeInTheDocument();
+ expect(screen.getByText('Pick a date and time')).toBeInTheDocument();
+ });
+});
diff --git a/packages/apollo-wind/src/components/forms/field-control.tsx b/packages/apollo-wind/src/components/forms/field-control.tsx
index ece6fb754..606a69c4d 100644
--- a/packages/apollo-wind/src/components/forms/field-control.tsx
+++ b/packages/apollo-wind/src/components/forms/field-control.tsx
@@ -24,6 +24,7 @@ import {
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
+import { toDateValue } from '@/lib/date-value';
import { joinIds } from './aria-ids';
import type { FieldMetadata, FieldOption, FieldType, SliderFieldMetadata } from './form-schema';
import { LiteralValueContext } from './literal-value-context';
@@ -371,7 +372,7 @@ export function FieldControl({
return (
{
expect(await axe(container)).toHaveNoViolations();
});
});
+
+// Hosts seed date fields from JSON, so a value can be any string.
+describe('MetadataForm untyped date values', () => {
+ const dateSchema = (
+ initialData: Record,
+ validation?: ValidationConfig
+ ): FormSchema => ({
+ id: 'dates',
+ title: 'Dates',
+ initialData,
+ sections: [
+ {
+ id: 's',
+ fields: [
+ { name: 'day', type: 'date', label: 'Day', validation },
+ { name: 'at', type: 'datetime', label: 'At', validation },
+ ],
+ },
+ ],
+ });
+
+ it.each([
+ ['an empty string', ''],
+ ['a locale string', 'Wed Sep 23 2026 00:00:00 GMT-0700 (Pacific Daylight Time)'],
+ ['an expression', '=vars.when'],
+ ])('shows the placeholders for %s instead of throwing', (_label, value) => {
+ render();
+ expect(screen.getByText('Pick a date')).toBeInTheDocument();
+ expect(screen.getByText('Pick a date and time')).toBeInTheDocument();
+ });
+
+ it('shows an ISO date-time value on both controls', () => {
+ const iso = '2026-09-23T07:00:00.000Z';
+ render();
+ const instant = new Date(iso);
+ expect(screen.getByText(format(instant, 'PPP'))).toBeInTheDocument();
+ expect(
+ screen.getByText(`${format(instant, 'PPP')} at ${format(instant, 'HH:mm')}`)
+ ).toBeInTheDocument();
+ });
+
+ it('submits an optional field holding an unrecognised value as absent', async () => {
+ const user = userEvent.setup();
+ const handleSubmit = vi.fn();
+ render(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+
+ await waitFor(() => expect(handleSubmit).toHaveBeenCalled());
+ expect(handleSubmit.mock.calls[0][0]).toMatchObject({ day: undefined, at: undefined });
+ });
+
+ it('gives a required field holding an unrecognised value the required message', async () => {
+ const user = userEvent.setup();
+ const handleSubmit = vi.fn();
+ render(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+
+ expect(await screen.findByText('Choose a day')).toBeInTheDocument();
+ expect(handleSubmit).not.toHaveBeenCalled();
+ });
+
+ it('treats an unrecognised value as empty for a conditionally required field', async () => {
+ const user = userEvent.setup();
+ const handleSubmit = vi.fn();
+ render(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+
+ expect(await screen.findByText('This field is required')).toBeInTheDocument();
+ expect(handleSubmit).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/apollo-wind/src/components/forms/validation-converter.test.ts b/packages/apollo-wind/src/components/forms/validation-converter.test.ts
index d43ad7e13..696c75595 100644
--- a/packages/apollo-wind/src/components/forms/validation-converter.test.ts
+++ b/packages/apollo-wind/src/components/forms/validation-converter.test.ts
@@ -75,7 +75,7 @@ describe('validationConfigToZod', () => {
it('creates date schema for date field', () => {
const schema = validationConfigToZod(undefined, 'date');
expect(schema.safeParse(new Date()).success).toBe(true);
- expect(schema.safeParse('2024-01-01').success).toBe(true); // coerce
+ expect(schema.safeParse('2024-01-01').success).toBe(true);
});
it('creates date schema for datetime field', () => {
@@ -206,6 +206,56 @@ describe('validationConfigToZod', () => {
});
});
+ describe('date values', () => {
+ const unrecognised = [
+ '',
+ '2026',
+ 'Wed Sep 23 2026 00:00:00 GMT-0700 (Pacific Daylight Time)',
+ '=vars.when',
+ 1758610800123,
+ ];
+
+ it.each(['date', 'datetime'] as const)('reads an unrecognised %s value as absent', (type) => {
+ for (const value of unrecognised) {
+ expect(validationConfigToZod(undefined, type).safeParse(value)).toEqual({
+ success: true,
+ data: undefined,
+ });
+ expect(validationConfigToZod({ required: false }, type).safeParse(value).success).toBe(
+ true
+ );
+ }
+ });
+
+ it.each([
+ 'date',
+ 'datetime',
+ ] as const)('gives an unrecognised required %s value the required message', (type) => {
+ const schema = validationConfigToZod(
+ { required: true, messages: { required: 'Pick a date' } },
+ type
+ );
+ for (const value of [...unrecognised, undefined, null]) {
+ const result = schema.safeParse(value);
+ expect(result.success).toBe(false);
+ expect(result.error?.issues[0].message).toBe('Pick a date');
+ }
+ });
+
+ it('parses an ISO date-time to a Date that keeps its milliseconds', () => {
+ const result = validationConfigToZod({ required: true }, 'datetime').safeParse(
+ '2026-09-23T07:00:00.123Z'
+ );
+ expect(result.success).toBe(true);
+ expect((result.data as Date).getTime()).toBe(Date.UTC(2026, 8, 23, 7, 0, 0, 123));
+ });
+
+ it('parses a date-only string to local midnight', () => {
+ const result = validationConfigToZod({ required: true }, 'date').safeParse('2026-09-23');
+ expect(result.data).toEqual(new Date(2026, 8, 23));
+ });
+ });
+
describe('required validation', () => {
it('makes field required when config.required is true', () => {
const schema = validationConfigToZod({ required: true }, 'text');
diff --git a/packages/apollo-wind/src/components/forms/validation-converter.ts b/packages/apollo-wind/src/components/forms/validation-converter.ts
index 287302866..79091927e 100644
--- a/packages/apollo-wind/src/components/forms/validation-converter.ts
+++ b/packages/apollo-wind/src/components/forms/validation-converter.ts
@@ -1,4 +1,5 @@
import { z } from 'zod';
+import { toDateValue } from '@/lib/date-value';
import type { CustomValueType, FieldType, ValidationConfig, ValueModeId } from './form-schema';
import { type CodecContext, isDecodedEmpty, type ValueModeCodec } from './value-modes';
@@ -41,7 +42,7 @@ export function validationConfigToZod(
// If no config, return optional base schema
if (!config) {
- return schema.nullish();
+ return withDateParsing(schema.nullish(), fieldType);
}
// Apply type-specific constraints
@@ -73,11 +74,24 @@ export function validationConfigToZod(
) {
schema = (schema as z.ZodArray).min(1, requiredMessage);
}
- return applyCustomExpression(schema, config);
+ // An unrecognised date reaches the schema as undefined, so missing is its only failure.
+ if (isDateType(fieldType)) {
+ schema = z.date({ error: requiredMessage });
+ }
+ return withDateParsing(applyCustomExpression(schema, config), fieldType);
}
// `null` too: it is a cleared field's value, as react-hook-form expects one to be.
- return applyCustomExpression(schema, config).nullish();
+ return withDateParsing(applyCustomExpression(schema, config).nullish(), fieldType);
+}
+
+function isDateType(fieldType: FieldType): boolean {
+ return fieldType === 'date' || fieldType === 'datetime';
+}
+
+// Outside the optional wrapper: an unrecognised value must read as absent, not fail `z.date()`.
+function withDateParsing(schema: z.ZodTypeAny, fieldType: FieldType): z.ZodTypeAny {
+ return isDateType(fieldType) ? z.preprocess(toDateValue, schema) : schema;
}
export interface ModeAwareSchemaOptions {
@@ -181,10 +195,9 @@ function getBaseSchemaForType(
return z.array(z.string());
case 'date':
- return z.coerce.date();
-
case 'datetime':
- return z.coerce.date();
+ // Parsed by `withDateParsing`; coercion would turn numbers and locale strings into dates.
+ return z.date();
case 'file':
// File validation is typically handled separately by the file input
diff --git a/packages/apollo-wind/src/components/ui/date-picker.test.tsx b/packages/apollo-wind/src/components/ui/date-picker.test.tsx
index e84e331c1..0f36a58be 100644
--- a/packages/apollo-wind/src/components/ui/date-picker.test.tsx
+++ b/packages/apollo-wind/src/components/ui/date-picker.test.tsx
@@ -361,3 +361,15 @@ describe('DateRangePicker in an input group', () => {
expect(screen.getByText('Jan 05, 2026 - Jan 09, 2026')).toBeInTheDocument();
});
});
+
+// Untyped callers can still hand over something other than a Date.
+describe('DatePicker non-Date values', () => {
+ it.each([
+ ['a string', '2026-09-23T07:00:00.000Z'],
+ ['a number', 1758610800123],
+ ['an Invalid Date', new Date(Number.NaN)],
+ ])('shows the placeholder for %s instead of throwing', (_label, value) => {
+ render();
+ expect(screen.getByText('Pick a date')).toBeInTheDocument();
+ });
+});
diff --git a/packages/apollo-wind/src/components/ui/date-picker.tsx b/packages/apollo-wind/src/components/ui/date-picker.tsx
index 939701615..a01cb9eed 100644
--- a/packages/apollo-wind/src/components/ui/date-picker.tsx
+++ b/packages/apollo-wind/src/components/ui/date-picker.tsx
@@ -1,6 +1,6 @@
'use client';
-import { format } from 'date-fns';
+import { format, isValid } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import * as React from 'react';
import type { DateRange } from 'react-day-picker';
@@ -58,6 +58,8 @@ export const DatePicker = React.forwardRef(f
) {
const generatedId = React.useId();
const validationId = errorId ?? `${id ?? `date-picker-${generatedId.replace(/:/g, '')}`}-error`;
+ // Trusts the `Date` type only as far as not throwing: anything else is no value.
+ const date = value instanceof Date && isValid(value) ? value : undefined;
// Inside an InputGroup the trigger is the group's control: the group draws the box, and the
// panel anchors to it.
@@ -77,8 +79,8 @@ export const DatePicker = React.forwardRef(f
'aria-label':
id || ariaLabelledBy
? undefined
- : value
- ? `Selected date: ${format(value, 'PPP')}`
+ : date
+ ? `Selected date: ${format(date, 'PPP')}`
: placeholder,
'aria-labelledby': ariaLabelledBy,
...validation.aria,
@@ -95,7 +97,7 @@ export const DatePicker = React.forwardRef(f
className={className}
placeholder={placeholder}
>
- {value && {format(value, 'PPP')}}
+ {date && {format(date, 'PPP')}}
) : (
@@ -110,8 +112,8 @@ export const DatePicker = React.forwardRef(f
)}
>
- {value ? (
- format(value, 'PPP')
+ {date ? (
+ format(date, 'PPP')
) : (
{placeholder}
)}
@@ -121,7 +123,7 @@ export const DatePicker = React.forwardRef(f
{
await waitFor(() => expect(measure).toHaveBeenCalled());
});
});
+
+// Untyped callers can still hand over something other than a Date.
+describe('DateTimePicker non-Date values', () => {
+ it.each([
+ ['a string', '2026-09-23T07:00:00.000Z'],
+ ['a number', 1758610800123],
+ ['an Invalid Date', new Date(Number.NaN)],
+ ])('shows the placeholder for %s instead of throwing', (_label, value) => {
+ render();
+ expect(screen.getByText('Pick a date and time')).toBeInTheDocument();
+ });
+});
+
+describe('DateTimePicker picking a day', () => {
+ it('keeps the milliseconds when a new day is picked', async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+ render(
+
+ );
+
+ await user.click(screen.getByRole('button'));
+ await user.click(screen.getByRole('button', { name: /15th/ }));
+
+ const selected = handleChange.mock.calls[0][0] as Date;
+ expect(selected.getDate()).toBe(15);
+ expect([
+ selected.getHours(),
+ selected.getMinutes(),
+ selected.getSeconds(),
+ selected.getMilliseconds(),
+ ]).toEqual([14, 30, 15, 123]);
+ });
+});
diff --git a/packages/apollo-wind/src/components/ui/datetime-picker.tsx b/packages/apollo-wind/src/components/ui/datetime-picker.tsx
index d7219713e..9754fccd8 100644
--- a/packages/apollo-wind/src/components/ui/datetime-picker.tsx
+++ b/packages/apollo-wind/src/components/ui/datetime-picker.tsx
@@ -1,4 +1,4 @@
-import { format } from 'date-fns';
+import { format, isValid } from 'date-fns';
import { CalendarIcon, Clock } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
@@ -64,23 +64,20 @@ export const DateTimePicker = React.forwardRef(value);
+ // Trusts the `Date` type only as far as not throwing: anything else is no value.
+ const date = value instanceof Date && isValid(value) ? value : undefined;
+ const [selectedDate, setSelectedDate] = React.useState(date);
// A native time input only accepts 24-hour text; use12Hour affects the display alone.
- const [timeValue, setTimeValue] = React.useState(value ? format(value, 'HH:mm') : '');
+ const [timeValue, setTimeValue] = React.useState(date ? format(date, 'HH:mm') : '');
// Re-seed when the parent changes the value, compared by time so a fresh Date with the
// same instant (or our own change echoed back) is not a change.
- const valueTime = value?.getTime();
+ const valueTime = date?.getTime();
const [seededTime, setSeededTime] = React.useState(valueTime);
if (!Object.is(valueTime, seededTime)) {
setSeededTime(valueTime);
- if (value === undefined) {
- setSelectedDate(undefined);
- setTimeValue('');
- } else if (!Number.isNaN(valueTime)) {
- setSelectedDate(value);
- setTimeValue(format(value, 'HH:mm'));
- }
+ setSelectedDate(date);
+ setTimeValue(date ? format(date, 'HH:mm') : '');
}
const handleDateSelect = (date: Date | undefined) => {
@@ -91,6 +88,7 @@ export const DateTimePicker = React.forwardRef
editableOnValueChange?.(date ? toDateOnlyString(date) : '')}
initialFocus
/>
@@ -300,7 +295,7 @@ export function QuickFormField({
// than the date branch's Calendar popover. Stored as an ISO string.
editableOnValueChange?.(date ? date.toISOString() : '')}
onOpenChange={(open) => {
if (!open) onValueBlur?.();
diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts b/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts
index a41a6c64e..349fc0541 100644
--- a/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts
+++ b/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts
@@ -1,3 +1,4 @@
+import { toDateValue } from '@/lib/date-value';
import type { QuickFormFieldOption } from './types';
export const DEFAULT_SELECT_OPTIONS: QuickFormFieldOption[] = [
@@ -15,36 +16,7 @@ export function parseListValue(value: string): string[] {
}
}
-const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
-
-/**
- * Parses a date field's stored value, returning undefined for empty or invalid input.
- *
- * Date-only strings (`YYYY-MM-DD`) are parsed as a local date instead of going through
- * `new Date(string)` directly -- the latter treats date-only strings as UTC midnight,
- * which rolls over to the previous day once formatted in a negative-UTC-offset
- * timezone. Full ISO timestamps (which already carry explicit time/zone info) go
- * through `new Date` as-is.
- */
-export function parseDateValue(value: string): Date | undefined {
- if (DATE_ONLY_PATTERN.test(value)) {
- const [year, month, day] = value.split('-').map(Number);
- const date = new Date(year, month - 1, day);
- // new Date() normalizes out-of-range components (e.g. month 13, day 40)
- // into a different valid date instead of rejecting them -- reject
- // anything that didn't round-trip back to the requested year/month/day.
- const isValid =
- !Number.isNaN(date.getTime()) &&
- date.getFullYear() === year &&
- date.getMonth() === month - 1 &&
- date.getDate() === day;
- return isValid ? date : undefined;
- }
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? undefined : date;
-}
-
-/** Formats a Date as a local `YYYY-MM-DD` string, the inverse of parseDateValue's date-only path. */
+/** Formats a Date as a local `YYYY-MM-DD` string, which `toDateValue` reads back as a local date. */
export function toDateOnlyString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -54,7 +26,7 @@ export function toDateOnlyString(date: Date): string {
/** Formats a date field's value for display, falling back to the raw value if it isn't a valid date. */
export function formatDateValue(value: string): string {
- const date = parseDateValue(value);
+ const date = toDateValue(value);
return date
? date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
: value;
diff --git a/packages/apollo-wind/src/lib/date-value.test.ts b/packages/apollo-wind/src/lib/date-value.test.ts
new file mode 100644
index 000000000..d82b1e58d
--- /dev/null
+++ b/packages/apollo-wind/src/lib/date-value.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { toDateValue } from './date-value';
+
+describe('toDateValue', () => {
+ it('reads a UTC date-time with milliseconds as that instant', () => {
+ expect(toDateValue('2026-09-23T07:00:00.123Z')?.getTime()).toBe(
+ Date.UTC(2026, 8, 23, 7, 0, 0, 123)
+ );
+ });
+
+ it('honours an explicit offset', () => {
+ expect(toDateValue('2026-09-23T07:00:00+02:00')?.getTime()).toBe(
+ Date.UTC(2026, 8, 23, 5, 0, 0)
+ );
+ });
+
+ it('reads a date-time without an offset as local time', () => {
+ expect(toDateValue('2026-09-23T07:00')?.getTime()).toBe(new Date(2026, 8, 23, 7, 0).getTime());
+ });
+
+ it('truncates fractional seconds to milliseconds', () => {
+ expect(toDateValue('2026-09-23T07:00:00.1239Z')?.getUTCMilliseconds()).toBe(123);
+ });
+
+ it('reads a date-only string as local midnight on that calendar day', () => {
+ const date = toDateValue('2026-09-23');
+ expect([date?.getFullYear(), date?.getMonth(), date?.getDate(), date?.getHours()]).toEqual([
+ 2026, 8, 23, 0,
+ ]);
+ });
+
+ it('returns a valid Date as the same instant', () => {
+ const date = new Date(2026, 8, 23, 7, 0, 0, 123);
+ expect(toDateValue(date)?.getTime()).toBe(date.getTime());
+ });
+
+ it.each([
+ ['an empty string', ''],
+ ['null', null],
+ ['undefined', undefined],
+ ['a number', 2026],
+ ['a year string', '2026'],
+ ['a reduced year', '26'],
+ ['a reduced year-month', '202609'],
+ ['an epoch-milliseconds string', '1758610800123'],
+ ['a locale string', 'Wed Sep 23 2026 00:00:00 GMT-0700 (Pacific Daylight Time)'],
+ ['an expression', '=vars.when'],
+ ['an out-of-range day', '2026-02-30'],
+ ['an Invalid Date', new Date(Number.NaN)],
+ ['an object', {}],
+ ])('returns undefined for %s', (_label, value) => {
+ expect(toDateValue(value)).toBeUndefined();
+ });
+});
diff --git a/packages/apollo-wind/src/lib/date-value.ts b/packages/apollo-wind/src/lib/date-value.ts
new file mode 100644
index 000000000..29bb774bd
--- /dev/null
+++ b/packages/apollo-wind/src/lib/date-value.ts
@@ -0,0 +1,26 @@
+import { isValid, parseISO } from 'date-fns';
+
+// JSON Schema's `format: date` and `format: date-time` (RFC 3339), with seconds optional. A
+// missing offset is local time. `parseISO` alone also accepts reduced forms such as "26".
+const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/;
+
+/**
+ * Reads a date or date-time value from untyped input, such as JSON-sourced form data.
+ *
+ * Accepts a valid `Date` or an RFC 3339 date / date-time string; a date-only string is a local
+ * calendar date. Anything else is no value: the standard parsers disagree on numbers, digit-only
+ * and locale strings, so they are not guessed at.
+ */
+export function toDateValue(value: unknown): Date | undefined {
+ if (value instanceof Date) {
+ if (isValid(value)) return value;
+ } else if (
+ typeof value === 'string' &&
+ (DATE_PATTERN.test(value) || DATE_TIME_PATTERN.test(value))
+ ) {
+ const date = parseISO(value);
+ if (isValid(date)) return date;
+ }
+ return undefined;
+}