# Date field

A date typed or stepped one part at a time, in the locale's order, with no calendar.

- **Import:** `import { DateField } from "@booleanpress/ui/date-field"`
- **APG Spinbutton:** <https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/>
- **Page:** <https://ui.booleanpress.com/components/date-field> · @booleanpress/ui 0.2.0

## Usage

`DateField` is a group of three segments, day, month and year, in the order and with the separators of the provider's `locale` (month/day/year in en-US, day.month.year in de-DE); each segment is a spin button. It needs no peer package, so it suits a date of birth or an invoice date where a calendar adds nothing. Name it with `aria-labelledby` pointing at a visible label, or `aria-label`.

```tsx
import { useState } from "react"
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export function InvoiceDate() {
  const [date, setDate] = useState<Date | null>(null)
  return (
    <div className="flex flex-col gap-2">
      <Label id="invoice-date">Invoice date</Label>
      <DateField aria-labelledby="invoice-date" value={date} onValueChange={setDate} />
    </div>
  )
}
```

It is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`. The value is midnight of the day in the provider's `timeZone` (the browser's when none is set); `onValueChange` fires once the three segments are filled, and with null when a filled field loses one. A month or year that leaves the day past the month's end brings it back to the last day. A year of one or two digits becomes the year nearest today, within 50 years, when focus leaves it: 85 is 1985 and 30 is 2030 in 2026.

`min` and `max` mark a date outside them invalid, without changing it. `size`, `variant="filled"`, `fluid`, `disabled` and `aria-invalid` follow the other fields. `name` submits `YYYY-MM-DD` in a hidden input. For a calendar as well, use `DatePicker`.

## Examples

### Basic

Month, day and year in the locale's order, named by its label.

```tsx
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export default function DateFieldBasic() {
  return (
    <div className="flex flex-col gap-2">
      <Label id="invoice-date-label">Invoice date</Label>
      <DateField aria-labelledby="invoice-date-label" defaultValue={new Date(2026, 9, 14)} />
    </div>
  )
}
```

### Locale order

Inside a `BooleanUIProvider` with `locale="de-DE"` and German strings: day, month and year, divided by dots.

```tsx
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"
import { BooleanUIProvider } from "@booleanpress/ui/provider"

export default function DateFieldLocaleOrder() {
  return (
    <BooleanUIProvider locale="de-DE" strings={{ day: "Tag", month: "Monat", year: "Jahr", emptySegment: "Leer" }}>
      <div className="flex flex-col gap-2">
        <Label id="rechnung-label">Rechnungsdatum</Label>
        <DateField aria-labelledby="rechnung-label" defaultValue={new Date(2026, 9, 14)} />
      </div>
    </BooleanUIProvider>
  )
}
```

### Min and max

`min` and `max` bound the date to October 2026; 3 November is outside, so the field is invalid.

```tsx
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export default function DateFieldMinMax() {
  return (
    <div className="flex flex-col gap-2">
      <Label id="launch-label">Launch date (October 2026)</Label>
      <DateField
        aria-labelledby="launch-label"
        aria-describedby="launch-hint"
        min={new Date(2026, 9, 1)}
        max={new Date(2026, 9, 31)}
        defaultValue={new Date(2026, 10, 3)}
      />
      <p id="launch-hint" className="text-sm text-muted-foreground">
        A date outside October marks the field invalid.
      </p>
    </div>
  )
}
```

### Sizes

`sm` is 28 px tall with 12 px text, `default` 35 px with 14 px, `lg` 42 px with 16 px.

```tsx
import { DateField } from "@booleanpress/ui/date-field"

const day = new Date(2026, 9, 14)

export default function DateFieldSizes() {
  return (
    <div className="flex flex-col items-center gap-3">
      <DateField size="sm" aria-label="Small" defaultValue={day} />
      <DateField aria-label="Default" defaultValue={day} />
      <DateField size="lg" aria-label="Large" defaultValue={day} />
    </div>
  )
}
```

### Filled

`variant="filled"` draws the grey `--field-filled` fill.

```tsx
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export default function DateFieldFilled() {
  return (
    <div className="flex flex-col gap-2">
      <Label id="renewal-label">Renewal date</Label>
      <DateField aria-labelledby="renewal-label" variant="filled" defaultValue={new Date(2026, 11, 1)} />
    </div>
  )
}
```

### Disabled

The segments leave the tab order and cannot change.

```tsx
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export default function DateFieldDisabled() {
  return (
    <div className="flex flex-col gap-2">
      <Label id="joined-label">Customer since</Label>
      <DateField aria-labelledby="joined-label" disabled defaultValue={new Date(2026, 2, 9)} />
    </div>
  )
}
```

### Invalid

`aria-invalid` draws the error edge and sets `aria-invalid` on every segment; `aria-describedby` reads the message.

```tsx
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export default function DateFieldInvalid() {
  return (
    <div className="flex flex-col gap-2">
      <Label id="due-label">Payment due</Label>
      <DateField aria-labelledby="due-label" aria-invalid aria-describedby="due-error" />
      <p id="due-error" className="text-sm text-destructive-strong">
        Enter the day the payment is due.
      </p>
    </div>
  )
}
```

### With a form

`name` submits the date as `YYYY-MM-DD`; the form's Reset puts `defaultValue` back, in the segments and the value sent.

```tsx
import * as React from "react"
import { Button } from "@booleanpress/ui/button"
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export default function DateFieldWithForm() {
  const [sent, setSent] = React.useState("")

  return (
    <form
      className="flex w-full max-w-sm flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSent([...new FormData(event.currentTarget)].map(([key, value]) => `${key} = ${value}`).join(", "))
      }}
    >
      <div className="flex flex-col gap-2">
        <Label id="send-on-label">Send on</Label>
        <DateField aria-labelledby="send-on-label" name="day" defaultValue={new Date(2026, 9, 5)} />
      </div>
      <div className="flex gap-2">
        <Button type="submit">Submit</Button>
        <Button type="reset" variant="outline">
          Reset
        </Button>
      </div>
      <pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap text-foreground">{sent ? `Sent: ${sent}` : "Press Submit to see what the form sends."}</pre>
    </form>
  )
}
```

### Controlled with a rule

The parent refuses weekends: each change is only proposed through `onValueChange`, and the field shows and submits the `value` it is given.

```tsx
import * as React from "react"
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export default function DateFieldControlledRule() {
  const [value, setValue] = React.useState<Date | null>(new Date(2026, 9, 9))
  const [refused, setRefused] = React.useState(false)

  return (
    <div className="flex flex-col gap-2">
      <Label id="delivery-day-label">Delivery day</Label>
      <DateField
        aria-labelledby="delivery-day-label"
        aria-describedby="delivery-day-note"
        value={value}
        onValueChange={(next) => {
          // The parent decides: a weekend is refused, and the field stays on the day it had.
          const weekend = next !== null && (next.getDay() === 0 || next.getDay() === 6)
          setRefused(weekend)
          if (!weekend) setValue(next)
        }}
      />
      <p id="delivery-day-note" className="text-sm text-muted-foreground">
        {refused ? "Weekends are not allowed." : "Monday to Friday only."}
      </p>
    </div>
  )
}
```

### Read-only

`readOnly` shows the date and keeps the segments focusable, but no change is made.

```tsx
import { DateField } from "@booleanpress/ui/date-field"
import { Label } from "@booleanpress/ui/label"

export default function DateFieldReadOnly() {
  return (
    <div className="flex flex-col gap-2">
      <Label id="signup-label">Customer since</Label>
      <DateField aria-labelledby="signup-label" readOnly defaultValue={new Date(2026, 2, 9)} />
    </div>
  )
}
```

## Accessibility

**Semantics.** A `group` holding three `spinbutton`s, each with `aria-valuenow`, `aria-valuemin`, `aria-valuemax` and `aria-valuetext` (the month's name, or "Empty" while unfilled). The separators are hidden from screen readers. The segments are laid out left to right in every direction, in the locale's order.

**Labels.** Name the group with `aria-labelledby` or `aria-label`. Each segment is named by the provider strings `day`, `month` and `year`, after the group's label; an empty one reads `emptySegment`. The month names come from `Intl` in the provider's locale.

**Focus.** Each segment is a tab stop; the left and right arrows also move between them. A filled segment moves focus to the next. A press on the field's padding focuses the first empty segment.

**Known limits.**

- On a phone, the segments are editable text so the number keyboard opens; typing goes through the same rules as a key press.
- `min` and `max` mark the field invalid; they do not stop the arrows or clamp the value.
- Dates are on the Gregorian calendar in every locale.
- In Arabic locales the segments read left to right, the day at the left, where Arabic text (and `DatePicker`'s field) writes a numeric date right to left, the day at the right.

### Keyboard

| Key | Behaviour |
| --- | --- |
| ↑ | Adds one to the segment, wrapping from the highest value to the lowest. |
| ↓ | Takes one from the segment, wrapping from the lowest to the highest. |
| → | Moves to the next segment. |
| ← | Moves to the previous segment. |
| 0–9 | Types into the segment, moving on once it is complete (two digits, or four for the year). |
| Backspace | Removes the segment's last digit; on an empty segment, moves to the previous one. |
| Delete | Empties the segment. |
| Home | Sets the segment to its lowest value. |
| End | Sets the segment to its highest value. |

## API

### DateField

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `Date \| null` |  | The date it starts with, uncontrolled. |
| `disabled` | `boolean` | `false` | Takes the segments out of the tab order and stops every change. |
| `fluid` | `boolean` | `false` | Fills the width of its container. |
| `form` | `string` |  | The id of the form the value belongs to, for a field placed outside it. |
| `max` | `Date` |  | The latest day; a later date marks the field invalid. |
| `min` | `Date` |  | The earliest day; an earlier date marks the field invalid. |
| `name` | `string` |  | Submits the date as `YYYY-MM-DD` in a hidden input of this name. |
| `onValueChange` | `((value: Date \| null) => void)` |  | Called with the new date once day, month and year are filled, and with null when a filled field loses one. |
| `readOnly` | `boolean` | `false` | Keeps the segments focusable but stops every change. |
| `required` | `boolean` | `false` | Sets `aria-required` on every segment. |
| `size` | `"default" \| "sm" \| "lg"` |  | 28, 35 or 42 px tall. Defaults to the provider's `controlSize`. |
| `value` | `Date \| null` |  | The date, when you control it: midnight of the day in the provider's time zone. |
| `variant` | `"default" \| "filled"` |  | `filled` fills the field grey. Defaults to the provider's `fieldVariant`. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Provider strings:** `hour`, `minute`, `second`, `dayPeriod`, `day`, `month`, `year`, `emptySegment` (`BooleanUIProvider`'s `strings`).

## Theming

The field is `Input`'s look (`--field`, `--control`, `--ring`); the focused segment is `--primary` with `--primary-foreground` text, like selected text; empty segments are `--muted-foreground`.
