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.