Components / forms
DateField
Not <input type="date">. The native control cannot be skinned, its picker is the platform's, and its value handling varies. What it does well — a numeric keypad and a real parser — this reproduces without giving up the appearance.
Summary
A date as text, parsed strictly as YYYY-MM-DD and confirmed back in the reader's own locale.
| APG pattern | none — a text input with a parser |
| Built on | native elements |
| Status | stable |
| Since | 0.2.0 |
Anatomy
Root: _none_. Explicit roles: none written — the elements carry their own.
_No named parts. The frame is the whole anatomy._
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
id | string | required | Wired to the label, the message and the control. |
label | string | none | The visible label, and the accessible name. |
value | string | none | The date, as YYYY-MM-DD. Passing it makes the field controlled. |
defaultValue | string | `` | The date it starts with when the field keeps its own state. |
onChange | (value: string, detail: { text: string | none | Called with a valid, in-range YYYY-MM-DD, or '' when the text is not one. The second argument carries the raw text and whether it parsed, for a caller that wants to hold the half-typed state itself. |
valid | boolean }) => void | required | unknown |
min | string | none | Earliest date allowed, as YYYY-MM-DD. ISO dates compare as text, so no parsing is needed. |
max | string | none | Latest date allowed, as YYYY-MM-DD. |
locale | string | none | The locale the confirmation line is written in. Defaults to the page's. It affects display only — what the field will parse never changes. |
description | React.ReactNode | none | Replaces the format hint shown before a date has been entered. |
error | React.ReactNode | none | An error from the caller. It wins over the field's own parsing message. |
formatLabel | string | Use the format YYYY-MM-DD | The hint shown when the field is empty. |
invalidLabel | string | Enter a real date, as YYYY-MM-DD | The message shown when the text is not a real date. |
rangeLabel | string | none | Replaces the generated out-of-range message, which names the bounds in the reader's locale. |
adornment | React.ReactNode | none | Fills Field's trailing adornment slot. DatePicker puts its calendar toggle here; a plain date field usually wants nothing, because the message row already reports what was parsed. |
States
_None. Rest is the only condition it has._
Behavior
Typing is not validating. The field parses on every keystroke so a caller holding the value sees it the moment it is real, but it does not mark itself invalid until blur. Telling someone they are wrong while they are still typing the year is a message about the field's impatience, not about their date.
onChange reports the value, and separately the text. It is called with a valid, in-range YYYY-MM-DD, or '' when the text is not one — so a caller storing the value never has to hold a half-typed string. The second argument carries the raw text and whether it parsed, for a caller that would rather hold it.
Range checks are string comparisons. ISO dates sort as text, which is the one convenience the format buys: min and max need no parsing and cannot disagree with the parser about what a date is.
Accessibility
The message row does double duty. Before anything is typed it names the format; once a date parses it becomes that date written out in the reader's locale. Both reach the control through aria-describedby, so a screen-reader user is told the format when they arrive and told what was understood after they type — which is the confirmation a sighted reader gets from the same line.
An error replaces the description rather than joining it. That is Field's rule and this inherits it: aria-errormessage and aria-describedby point at one element, and only its role changes.
inputMode="numeric" raises a numeric keypad on touch. It does not restrict what can be typed — the dashes still need a keyboard that has one — and the parser, not the keyboard, is what enforces the format.
Keyboard
Nothing of its own. It is a text input and behaves like one, which is the point: eight digits is faster than eleven arrow presses, and it is the only part of a date control that works with a switch or a screen reader without a calendar's two-dimensional keyboard model.
Roles, states and properties
_None written. The elements carry their own._
Focus
One stop, like any text input. Focus never moves on its own — there is no popup to open and nothing to return from.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It sets no target size of its own; anything interactive inside it brings its own.
Tested
test/apg/date-field.test.jsx
Known gaps
_No upstream requirement is recorded as not applicable._
Appearance
_None. It consumes no token directly._
Motion
None. The message row swaps its text as parsing succeeds or fails; animating that would draw the eye to a line that changes on nearly every keystroke.
Content
The hint names the format in the format. YYYY-MM-DD, not "year, month, day" — the reader is about to type it, and the shape is the instruction.
The confirmation is the whole date, spelled out. dateStyle: 'full' gives Saturday, March 7, 2026, which disambiguates the two readings of 03-07 for anyone who mistyped one for the other.
The error says what to do, not what went wrong. Enter a real date, as YYYY-MM-DD rather than invalid date. An out-of-range message names the bounds in the reader's locale, because the bound is the fact they are missing.
Usage
Pair it with a Calendar only when browsing matters. Picking a delivery date from next week wants a calendar; typing a birthday does not, and forcing a date of birth through twelve month-clicks is the classic version of this mistake.
min and max are for real bounds, not soft preferences. A date outside them is an error, and an error the reader cannot resolve without changing their answer. A preferred range belongs in the description.
When not to use it. For a moment in time — a meeting slot, a deadline with an hour attached. This value is a calendar date and carries no zone, and bolting a time onto it produces the ambiguity the type exists to prevent.
Notes
ISO only, and the alternative is on the record. docs/proposals/2026-08-28-date-components.md holds both decisions: no time zone, and no locale parsing. The second is the one with a cost — a reader who does not think in ISO has to learn the format — and it was taken because 3/7/26 is 3 July in London and 7 March in New York and no detection makes that safe. Accepting more formats later is a compatible change; tightening one is not.
formatISO pins timeZone after the caller's options, not before. Spreading them last let a single { timeZone } win, and 7 March printed as the 6th — the exact defect, in the one function written to prevent it. There is no zone in which a calendar date is a different day, so there is nothing to override; style, weekday and era stay the caller's. A test asserts the override is refused across four zones on both sides of the meridian.
Nothing here constructs a Date except as a vehicle for Intl, and then only through Date.UTC. new Date('2026-03-07') parses as UTC midnight and prints as 6 March west of Greenwich — a defect invisible in every test written in the author's own zone, and the reason the parser does its own arithmetic.
2026-02-30 matches the pattern and is not a day. The parser checks the day against the month's real length, leap years included, because a parser that accepts it hands the caller a date that does not exist.
Not <input type="date">. The native control cannot be skinned, its picker is the platform's, and its value handling varies. What it does well — a numeric keypad and a real parser — this reproduces without giving up the appearance.