Date range field

A first and a last day, picked from one calendar.

Installation

npx shadcn@latest add @formdef/date-range-field

Register it under widgets in lib/formdef/index.tsx, as widgets: { dateRange: DateRangeField }.

Usage

The value is { from?: string; to?: string }, each an ISO date such as 2027-03-01. The first click selects a range of one day, from and to alike; the second click moves one end.

export const  = createForm({
  : .({
    : 
      .({ : ..().(), : ..().() })
      .(({ ,  }) =>  !==  &&  !==  &&  < , {
        : 'Pick a departure after your arrival',
      }),
  }),
  : { : {} },
  fields: {
    stay: { : 'dateRange', : { : 'Stay', : '2027-01-01' } },
  },
})

The range is one field. A refine on it, here demanding a departure after the arrival, reports on the field itself, so the widget shows its message. Leave out path: a message on stay.to would belong to no widget.

PropMeaning
formatTurns each ISO date into the text on the button.
separatorStands between the two dates on the button. Defaults to –.
numberOfMonthsHow many months the calendar shows side by side. Defaults to 2.
min, maxThe first and last day that can be picked, as ISO dates.

The calendar props and the time-zone behaviour are those of the date field.

Props

Prop

Type

Source

import { type FieldOf, fieldParts } from '@formdef/core'

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 DateRange = {
  from?: string | undefined
  to?: string | undefined
}

type DateRangeFieldProps = FieldOf<DateRange> &
  FieldLabels &
  CalendarAttrs & {
    placeholder?: string | undefined
    format?: ((iso: string) => string) | undefined
    separator?: string | undefined
    numberOfMonths?: number | undefined
    className?: string | undefined
  }

const PICK_A_RANGE = 'Pick a date range'
const EN_DASH = ' – '
const TWO_MONTHS = 2

const asIs = (iso: string): string => iso

const isoOf = (date: Date | undefined): string | undefined =>
  date === undefined ? undefined : toIsoDate(date)

export const DateRangeField = ({
  field,
  label,
  labelAside,
  description,
  placeholder = PICK_A_RANGE,
  format = asIs,
  separator = EN_DASH,
  numberOfMonths = TWO_MONTHS,
  disabled,
  required,
  min,
  max,
  className,
  ...calendar
}: DateRangeFieldProps) => {
  const parts = fieldParts(field, { description })
  const { from, to } = field.state.value ?? {}
  const isEmpty = from === undefined && to === undefined
  const selected = isEmpty
    ? undefined
    : {
        from: from === undefined ? undefined : fromIsoDate(from),
        to: to === undefined ? undefined : fromIsoDate(to),
      }
  const text = isEmpty
    ? undefined
    : [from, to].flatMap((end) => (end === undefined ? [] : [format(end)])).join(separator)

  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={text}
        placeholder={placeholder}
        disabled={disabled}
        required={required}
        className={className}
      >
        <Calendar
          {...calendar}
          mode="range"
          numberOfMonths={numberOfMonths}
          selected={selected}
          {...(selected?.from === undefined ? {} : { defaultMonth: selected.from })}
          disabled={outsideBounds(min, max, field.name)}
          onSelect={(range) =>
            field.handleChange({ from: isoOf(range?.from), to: isoOf(range?.to) })
          }
        />
      </CalendarPopover>
    </FieldFrame>
  )
}