ISO dates
StableDates as "2026-10-12" strings: strict and forgiving parsers, range labels with a spoken form, and the date grid’s arrow-key model.
Every picker in the calendar kit takes and returns ISO date strings, because a Date carries a time and a zone and invites off-by-one-day bugs, while a string survives JSON, forms and URLs and sorts correctly as text. This file has the helpers around them: parsing what people type or paste, labelling ranges for eyes and ears, and where each key moves focus in a month grid.
Strict and forgiving parsing
import { useId, useState } from 'react'import { parseISODate, parseMonth, parsePastedDate, todayISO } from '@rchursin/product/calendar/dates'import { dtf } from '@rchursin/product/lib/dates'import { Button, TextInput } from '@rchursin/product/ui'
/** * Paste or type a date. parsePastedDate is forgiving: ISO text (a time after it is fine) and * dates with a month name. It refuses all-number dates like 2/3/2026 on purpose, because * nobody can tell February 3 from March 2. parseISODate is strict: exactly YYYY-MM-DD, and a * real day (2026-02-30 is null). */const samples = ['2026-10-12', '12 Oct 2026', 'October 12, 2026', '2026-10-12T09:30', '2/3/2026', '2026-02-30']const long = dtf({ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
export default function PasteDate() { const id = useId() const [text, setText] = useState('12 Oct 2026') const iso = parsePastedDate(text) const strict = parseISODate(text) const monthWord = /\p{L}{3,}/u.exec(text)?.[0]Usage
import { todayISO, parseISODate, parsePastedDate, parseMonth, formatDateRange, speakable, dateForKey } from '@rchursin/product/calendar/dates'parsePastedDate('12 Oct 2026') // "2026-10-12"parseISODate('2026-02-30') // nullconst label = formatDateRange(start, end) // "Oct 1 – 28, 2026"<span aria-label={speakable(label)}>{label}</span>Examples
Range labels, shown and spoken
Intl drops what both ends share. speakable() turns the dash into “to”.
import { formatDateRange, speakable } from '@rchursin/product/calendar/dates'
/** * formatDateRange leaves Intl to drop what both ends share (the month, the year), and prints one * date when both ends are the same day. speakable() swaps the dash for "to", which every screen * reader says the same way: put it in an aria-label or hidden text next to the visible label. */const ranges = [ { kind: 'Same month', start: new Date(2026, 9, 1), end: new Date(2026, 9, 28) }, { kind: 'Across months', start: new Date(2026, 9, 26), end: new Date(2026, 10, 3) }, { kind: 'Across years', start: new Date(2026, 11, 28), end: new Date(2027, 0, 3) }, { kind: 'One day', start: new Date(2026, 9, 13), end: new Date(2026, 9, 13) },]
export default function DateRanges() { return ( <ul className="lux-card flex w-full max-w-lg flex-col divide-y divide-line rounded-xl bg-raised"> {ranges.map((r) => { const shown = formatDateRange(r.start, r.end) return ( <li key={r.kind} className="flex flex-col gap-1 px-4 py-3"> <span className="font-mono text-[10.5px] tracking-[0.12em] text-fg-3 uppercase">{r.kind}</span> <span className="text-[15px] font-medium text-fg tabular-nums">{shown}</span> <span className="text-[12.5px] text-fg-3"> Long: <span className="text-fg-2">{formatDateRange(r.start, r.end, 'long')}</span> </span> <span className="text-[12.5px] text-fg-3"> Spoken: <span className="text-fg-2">{speakable(shown)}</span> </span> </li> ) })} </ul> )}The date grid’s key model
Focus the box and use the arrows, Home, End, Page Up and Page Down (with Shift for a year).
import { useId, useState, type KeyboardEvent } from 'react'import { dateForKey } from '@rchursin/product/calendar/dates'import { dtf } from '@rchursin/product/lib/dates'import { Button, KeyCombo, Segmented } from '@rchursin/product/ui'
/** * dateForKey is the key model of the APG date grid, without the grid: give it a key and a date, * get the date focus should move to (or null for keys the grid ignores). Focus the box and try * the keys. It starts on March 31, so Page Up shows the clamp to February 28. */const start = new Date(2026, 2, 31)const long = dtf({ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })const keys = [ ['left', 'right', 'One day'], ['up', 'down', 'One week'], ['home', 'end', 'Start or end of the week'], ['pageup', 'pagedown', 'One month (with Shift, a year)'],] as const
export default function GridKeys() {API
parseISODate(s) · todayISO()
| Prop | Type | Default | Description |
|---|---|---|---|
srequired | string | null | undefined | – | Exactly YYYY-MM-DD. Anything else, or a day that doesn’t exist (2026-02-30), gives null. |
returns | Date | null | – | Local midnight on that day. |
todayISO() | () => ISODate | – | Today in the visitor’s zone. Call it in effects or handlers, not in prerendered markup. |
parsePastedDate(text) · parseMonth(text)
| Prop | Type | Default | Description |
|---|---|---|---|
parsePastedDate(text) | (text: string) => ISODate | null | – | ISO with or without a time after it, “12 Oct 2026”, “12th October 2026”, “October 12, 2026”. All-number dates (2/3/2026) give null on purpose: they are ambiguous. |
parseMonth(text) | (text: string) => number | null | – | 1–12 from “3”, “03”, “mar”, “March”, “sept”, “Oct.” in the dates locale or English. A name needs three letters and must match one month only (“ma” is null). |
formatDateRange(a, b, style?) · speakable(s)
| Prop | Type | Default | Description |
|---|---|---|---|
a, brequired | Date | – | Start and end, inclusive. The same day prints one date. |
style | 'short' | 'long' | 'short' | short “Oct 1 – 28, 2026” · long “Thursday, October 1 – Wednesday, October 28, 2026”. |
returns | string | – | From Intl.DateTimeFormat#formatRange in the dates locale (getDateLocale). |
speakable(s) | (s: string) => string | – | Replaces a dash (– or —) and the spaces around it with “ to ”, for aria-labels and announcements. |
dateForKey(e, from, firstDay, rtl?)
| Prop | Type | Default | Description |
|---|---|---|---|
erequired | { key: string; shiftKey: boolean } | – | A keydown event, or anything shaped like one. |
fromrequired | Date | – | The date that has focus now. |
firstDayrequired | number | – | Week start (0 = Sunday), used by Home and End. |
rtl | boolean | false | Swap left and right for right-to-left layouts. |
returns | Date | null | – | Where focus goes, or null for keys the grid doesn’t use (let those through). |
Also in this file
| Prop | Type | Default | Description |
|---|---|---|---|
Types | ISODate · TimeValue · DateRange | – | "2026-10-12", "09:30" (24-hour) and { start, end }, inclusive. |
Shifting | shiftDays · shiftMonths · clampISO · orderRange · isoFromParts | – | ISO in, ISO out. Months clamp the day; orderRange puts two picks in order. |
Times | formatTimeRange · formatSpan · formatTimeValue · minutesOf · timeValue · timeOf · dateAt · snapDown | – | “10 – 11 AM”, “1 hr 30 min”, “9:30 AM” from "09:30", and 15-minute slot maths for the time grid. |
Presets | rangePresets · presetFor | – | The range picker’s Today, Last 7 days, This month… and the preset a range matches today. |
Locale facts | dateFieldOrder · timeZoneName · timeZoneAbbr · weekendDays | – | Field order and separator, the zone’s name (“PT”), and which days are the weekend. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Left arrowRight arrow | In the third example: one day back or forward (swapped in right-to-left layouts). |
| Up arrowDown arrow | One week back or forward. |
| HomeEnd | First or last day of the week, using the chosen week start. |
| Page UpPage Down | One month back or forward, keeping the day number and clamping it (March 31 → February 28). |
| ShiftPage UpShiftPage Down | One year back or forward. |
- dateForKey follows the APG date grid, with one change: Home and End use the locale’s week instead of always Sunday.
- Range labels use an en dash, which some screen readers read as “en dash”. Give them speakable(label) as the accessible name.
- Refusing all-number pasted dates prevents a silent wrong guess; say so next to the field and accept the format the field shows.
Guidelines
Do
- Keep ISO strings in state, URLs and storage; make Dates only to draw or do maths.
- Compare ISO dates as plain strings: "2026-10-12" < "2026-10-13".
- Show what a pasted date was read as before saving it.
Don’t
- Don’t parse dates with new Date(text): it reads "2026-10-12" as UTC midnight and shows the day before in the Americas.
- Don’t guess the order of an all-number date.
In use
Related
- Date helpersSmall date maths and Intl formatting on plain Dates: month grids, week starts from the locale, ISO weeks, relative days, durations.
- Date fieldA date typed in segments in the locale’s order, with a calendar button beside it. Paste "2026-10-21" to fill it.
- Date pickerA field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
- Range pickerA start and end date for report filters, with analytics presets, typed ends and two linked months.
- Calendar gridOne month as an ARIA grid with the APG date-picker keys. Every picker in the kit is built on it.
Source
The component’s own files, exactly as they run on this site.
/** * Calendar-only date maths on top of lib/dates: ISO date strings, the APG grid's key moves, * range labels and presets, typed-date parsing, 15-minute time slots and the visitor's zone. * * Every picker in this kit takes and returns ISO strings: * dates "2026-10-12" (ISODate: a calendar day, no time, no zone) * times "09:30" (TimeValue: 24-hour wall-clock time, like <input type="time">) * A Date carries a time and a zone, so passing Dates around invites off-by-one-day bugs when a * zone changes. Strings survive JSON, forms and URLs unchanged, and sort correctly as text. */import { addDays, addMonths, addYears, at, daysInMonth, dtf, formatTime, isSameDay, startOfMonth,