Date field

A date picked from a calendar, stored as an ISO date.

We deliver on any day you pick.

Installation

npx shadcn@latest add @formdef/date-field

Register 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' },
    },
  },
})
PropMeaning
formatTurns the ISO date into the text on the button. Without it, the button shows the ISO date.
min, maxThe first and last day that can be picked, as ISO dates. Any other string throws.
placeholderThe 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>
  )
}