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.

Rendered from the package · open on its own

Summary

A date as text, parsed strictly as YYYY-MM-DD and confirmed back in the reader's own locale.

APG patternnone — a text input with a parser
Built onnative elements
Statusstable
Since0.2.0

Anatomy

Root: _none_. Explicit roles: none written — the elements carry their own.

_No named parts. The frame is the whole anatomy._

Properties

PropertyValuesDefaultWhat it does
idstringrequiredWired to the label, the message and the control.
labelstringnoneThe visible label, and the accessible name.
valuestringnoneThe date, as YYYY-MM-DD. Passing it makes the field controlled.
defaultValuestring``The date it starts with when the field keeps its own state.
onChange(value: string, detail: { text: stringnoneCalled 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.
validboolean }) => voidrequiredunknown
minstringnoneEarliest date allowed, as YYYY-MM-DD. ISO dates compare as text, so no parsing is needed.
maxstringnoneLatest date allowed, as YYYY-MM-DD.
localestringnoneThe locale the confirmation line is written in. Defaults to the page's. It affects display only — what the field will parse never changes.
descriptionReact.ReactNodenoneReplaces the format hint shown before a date has been entered.
errorReact.ReactNodenoneAn error from the caller. It wins over the field's own parsing message.
formatLabelstringUse the format YYYY-MM-DDThe hint shown when the field is empty.
invalidLabelstringEnter a real date, as YYYY-MM-DDThe message shown when the text is not a real date.
rangeLabelstringnoneReplaces the generated out-of-range message, which names the bounds in the reader's locale.
adornmentReact.ReactNodenoneFills 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.