Date field
A date picked from a calendar, stored as an ISO date.
Installation
npx shadcn@latest add @formdef/date-fieldRegister it under widgets in lib/formdef/index.tsx, as widgets: { date: DateField }.
Usage
The value is an ISO date such as 2027-03-01, and '' while none is picked. The day stays the day
in every time zone: the widget never turns the value into a point in time.
const = (: string): string =>
new (`${}T00:00`).('en-GB', { : 'long' })
export const = createForm({
: .({ : ..('Pick a day') }),
: { : '' },
fields: {
arrival: {
: 'date',
: { : 'Arrival', : , : '2027-01-01', : '2027-12-31' },
},
},
})| Prop | Meaning |
|---|---|
format | Turns the ISO date into the text on the button. Without it, the button shows the ISO date. |
min, max | The first and last day that can be picked, as ISO dates. Any other string throws. |
placeholder | The text on the button while no day is picked. |
The calendar is shadcn's Calendar, and the widget passes on a few of its props, such as
captionLayout, weekStartsOn and locale.
min and max only stop a day from being picked. A bound the data has to keep belongs in the
schema as well.
A date someone knows
A calendar suits a day someone picks: an appointment, a delivery slot. A date someone already knows,
such as a birth date, is faster to type. Three text fields in a row do it, each with
inputMode: 'numeric' so a phone shows the number pad:
type = { : string; : string; : string }
const = .().(/^\d+$/, 'Use digits')
const = ({ , , }: ): boolean => {
const = new ((), () - 1, ())
return .() === () - 1 && .() === ()
}
export const = createForm({
: .({
:
.({ : , : , : })
.(, { : 'Enter a date that exists', : ['year'] }),
}),
: { : { : '', : '', : '' } },
fields: {
born: {
: {
day: { : 'text', : { : 'Day', : 'numeric', : 'w-[5ch]' } },
month: {
: 'text',
: { : 'Month', : 'numeric', : 'w-[5ch]' },
},
year: { : 'text', : { : 'Year', : 'numeric', : 'w-[6ch]' } },
},
: [{ : ['day', 'month', 'year'] }],
},
},
})The refine on born checks the date the three parts make. An object entry shows no message of its
own, so path places it on a part: here the year, the last one typed.
Props
Prop
Type
Source
'use client'
import { type FieldOf, fieldParts } from '@formdef/core'
import { useState } from 'react'
import { type CalendarAttrs, CalendarPopover } from '@/components/formdef/calendar-popover'
import { FieldFrame } from '@/components/formdef/field-frame'
import { Calendar } from '@/components/ui/calendar'
import { fromIsoDate, outsideBounds, toIsoDate } from '@/lib/formdef/date'
import { type FieldLabels, labelIdFor } from '@/lib/formdef/field-labels'
type DateFieldProps = FieldOf<string> &
FieldLabels &
CalendarAttrs & {
placeholder?: string | undefined
format?: ((iso: string) => string) | undefined
className?: string | undefined
}
const PICK_A_DATE = 'Pick a date'
const asIs = (iso: string): string => iso
export const DateField = ({
field,
label,
labelAside,
description,
placeholder = PICK_A_DATE,
format = asIs,
disabled,
required,
min,
max,
className,
...calendar
}: DateFieldProps) => {
const [isOpen, setIsOpen] = useState(false)
const parts = fieldParts(field, { description })
const value = field.state.value ?? ''
const selected = value === '' ? undefined : fromIsoDate(value)
return (
<FieldFrame
parts={parts}
label={label}
labelAside={labelAside}
description={description}
disabled={disabled}
>
<CalendarPopover
control={parts.control}
labelId={label === undefined ? undefined : labelIdFor(parts.control.id)}
text={value === '' ? undefined : format(value)}
placeholder={placeholder}
disabled={disabled}
required={required}
isOpen={isOpen}
onOpenChange={setIsOpen}
className={className}
>
<Calendar
{...calendar}
mode="single"
selected={selected}
{...(selected === undefined ? {} : { defaultMonth: selected })}
disabled={outsideBounds(min, max, field.name)}
onSelect={(day) => {
field.handleChange(day === undefined ? '' : toIsoDate(day))
setIsOpen(false)
}}
/>
</CalendarPopover>
</FieldFrame>
)
}