Input Date

A date input composed from Field, InputGroup, Popover, and Calendar that replaces the native date input.

Usage

Not a separate component but a composition: a read-only InputGroup inside a Field triggers a Popover with the themed Calendar. Don't use the native <input type="date">: its picker can't be themed and differs per browser/OS. For every Field / Input / InputGroup combination and state side by side, see the Field Matrix.

import { Calendar } from '@epilot/spark-ui/calendar'
import { Field, FieldLabel } from '@epilot/spark-ui/field'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from '@epilot/spark-ui/input-group'
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from '@epilot/spark-ui/popover'
<Field>
  <FieldLabel>Start date</FieldLabel>
  <Popover onOpenChange={setOpen} open={open}>
    <PopoverAnchor render={<InputGroup />}>
      <InputGroupInput
        className="cursor-pointer"
        onClick={() => setOpen(true)}
        placeholder={t('pickDate')}
        readOnly
        value={date ? formatDate(date) : ''}
      />
      <InputGroupAddon align="inline-end">
        <PopoverTrigger
          render={
            <Button
              aria-label={t('openCalendar')}
              size="icon-small"
              styleVariant="ghost"
              color="neutral"
            />
          }
        >
          <MaterialSymbol name="calendar_month" />
        </PopoverTrigger>
      </InputGroupAddon>
    </PopoverAnchor>
    <PopoverContent align="start" className="w-auto p-0">
      <Calendar
        mode="single"
        onSelect={(next) => {
          setDate(next)
          setOpen(false)
        }}
        selected={date}
      />
    </PopoverContent>
  </Popover>
</Field>

The icon is a real Button (PopoverTrigger) so keyboard users can open the calendar; the whole group is the PopoverAnchor, so the panel aligns to the field, and clicking the read-only input opens it too.

Format the displayed value and pass the Calendar locale in the app's language. On mobile, the same Calendar composes with Drawer instead of Popover.

Typeable

Drop readOnly to let users type the date as well as pick it. Keep the input text in its own state, parse it on change, and set the Calendar's selected from the parsed value, so typing and picking stay in sync. Picking a date writes the formatted string back into the field.

The masking, parsing, and formatting ship as helpers: maskDateInput inserts the separator as you type (and lets backspace step one character at a time), parseTypedDate turns the masked string into a Date (rejecting impossible dates), and formatTypedDate writes a picked Date back into the field:

import {
  formatTypedDate,
  maskDateInput,
  parseTypedDate,
} from '@epilot/spark-ui/utils/date-mask'

const SEPARATOR = '.' // swap to '/' for DD/MM/YYYY
<InputGroupInput
  inputMode="numeric"
  onChange={(e) => {
    const deleting = e.target.value.length < text.length
    const masked = maskDateInput(e.target.value, { deleting, separator: SEPARATOR })
    setText(masked)
    setDate(parseTypedDate(masked))
  }}
  placeholder={`DD${SEPARATOR}MM${SEPARATOR}YYYY`}
  value={text}
/>
// ...Calendar onSelect also writes the formatted string back:
<Calendar
  mode="single"
  onSelect={(next) => {
    setDate(next)
    setText(next ? formatTypedDate(next, SEPARATOR) : '')
    setOpen(false)
  }}
  selected={date}
/>

Pass separator (. or /) to maskDateInput/formatTypedDate to switch the divider; parseTypedDate accepts either. Match the field order to the app's locale (e.g. DD.MM.YYYY for German). Leave the value untouched while it's unparseable so a half-typed date isn't wiped, and surface an error via Field when the input can't be parsed on blur.

On this page