Foundations

Dates

Reading calendar dates in the many forms people write them, and showing them as their locale does.

Markdown

A calendar date is a day, without a time or a time zone. People write dates in many forms, and expect any of them to be understood; they read dates in the form their language uses.

Reading what is typed

Type a date in any form, or choose a sample. Partial dates are completed from today; change the locale to change the order numbers are read in.

Locale
Reads as
2026-10-20
Shown as
October 20, 2026
Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { dateType } from "@publictheta/ui/primitives/date-type.ts";
import { useMemo, useState } from "react";
import { Controls } from "../../ui/controls.tsx";

const SAMPLES: Record<string, string[]> = {
  "en-US": ["10/20", "oct 20", "2026-10-20", "1020", "tomorrow", "+3", "2w", "fri", "13/04", "2/30"],
  "en-GB": ["20/10", "20 October", "2026-10-20", "2010", "tomorrow", "-1m", "thurs", "30/2"],
  "de-DE": ["20.10.", "20. Oktober", "2026-10-20", "morgen", "übermorgen", "30.2."],
  "ja-JP": [
    "10/20",
    "10月20日",
    "2026年10月20日(火)",
    "令和8年10月20日",
    "R8.10.20",
    "8年10月20日",
    "明後日",
    "金曜",
    "2/30",
  ],
};

export default function DateReader() {
  const [locale, setLocale] = useState("en-US");
  const [text, setText] = useState("oct 20");
  const type = useMemo(() => dateType({ locale, dateStyle: "long" }), [locale]);
  const result = type.parse(text);

  return (
    <div className="w-full">
      <Controls>
        <RadioGroup label="Locale" size="small" value={locale} onValueChange={setLocale}>
          <Radio value="en-US">English (US)</Radio>
          <Radio value="en-GB">English (UK)</Radio>
          <Radio value="de-DE">Deutsch</Radio>
          <Radio value="ja-JP">日本語</Radio>
        </RadioGroup>
      </Controls>
      <div className="mx-auto flex max-w-md flex-col gap-4">
        <input
          aria-label="Typed"
          value={text}
          onChange={(event) => setText(event.target.value)}
          className="h-10 rounded-lg border border-border bg-surface px-3 font-mono text-base text-fg-strong outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
        />
        <div className="flex flex-wrap justify-center gap-1.5">
          {(SAMPLES[locale] ?? []).map((sample) => (
            <button
              key={sample}
              type="button"
              onClick={() => setText(sample)}
              className="rounded-md bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg hover:bg-surface-strong"
            >
              {sample}
            </button>
          ))}
        </div>
        <dl aria-live="polite" className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
          <dt className="text-fg-muted">Reads as</dt>
          <dd className="font-mono text-fg-strong">{result.ok ? result.value : `— (${result.reason})`}</dd>
          <dt className="text-fg-muted">Shown as</dt>
          <dd className="text-fg-strong">{result.ok ? type.format(result.value) : "—"}</dd>
        </dl>
      </div>
    </div>
  );
}

Best practices

Show dates as the reader's locale writes them. The order of day, month and year, the separators, and the names of months all follow the language of the interface: Oct 3, 2026 in American English, 3 Oct 2026 in British, 03.10.2026 in German, 2026/10/03 in Japanese. Where the day of the week helps — a deadline, a meeting — show it with the date: Tue, Oct 13, 2026.

Read any reasonable form. Accept numbers with any separator; month names in full or abbreviated; the locale's own labelled form, such as 2026年10月13日; digits alone; and full-width characters from an input method. Whatever a field shows, it reads back.

Read what the person meant, or refuse; never guess. A reading that is right for some entries and silently wrong for others is worse than none: a pasted column of day-first dates read month-first comes out half right. Where what was typed is not a date as the locale writes one, say so and leave it as typed.

Complete a partial date from today. A date without a year is in the current year; a day alone is in the current month. A two-digit year is the one nearest to today, and no more than twenty years ahead. A year is two digits or four: three are a slip of the hand, not a year.

Resolve ambiguity by the locale, not by trial. Numbers are read in the locale's order. Four digits first are a year everywhere, so 2026-10-13 always reads. Day and month are never exchanged to make a date of numbers that are not one: 13/04 in an American field is refused, not read as 13 April.

Ignore a weekday beside a date; read one alone. Tuesday, October 13 is 13 October, and so is 10月13日(火). A weekday alone is the next such day, today included.

Understand relative words and counts. Today, tomorrow and yesterday, and the words for the days either side of them where the language has them — 明後日, übermorgen — are dates. A number with a sign counts days from today: +3, -1. With a letter it counts days, weeks, months or years: 3d, 2w, -1m, +1y.

Read the years of an era. In Japanese, 令和8年10月13日 and R8.10.13 are 13 October 2026. A short year with 年 and no era is the era's year or the century's, whichever is nearer today.

Take the date from a timestamp as written. 2026-10-13T09:30:00Z is 13 October, whatever the reader's time zone: a pasted timestamp names a day, and converting it would move some of them.

Never invent a date. 30 February is a mistake, not 2 March. Refuse it, so the mistake is seen.

Refuse a date outside the range; do not move it. A date before the earliest or after the latest allowed stays as typed, marked, with the reason — moving it to the nearest end commits a date nobody chose.

Say why. Distinguish nothing typed, text that is not a date, a date that does not exist, and a date before or after the range, so the message can name the problem.

Keep a date a date. Hold it without a time or zone — as year, month and day — so it never shifts by a day when read in another time zone.

Reading

How an American English field reads what is typed, on Tuesday 13 October 2026:

TypedRead asBecause
10/20/202620 October 2026the locale's order
2026-10-2020 October 2026four digits first are a year
10/2020 October 2026completed with this year
2020 October 2026completed with this month
102020 October 2026digits alone, in the locale's order
oct 20, 20 October20 October 2026a month named
Tue, Oct 2020 October 2026a weekday beside a date is ignored
10月20日20 October 2026labelled fields, full-width
令和8年10月20日20 October 2026a year of an era
2026-10-20T09:30:00Z20 October 2026the time is dropped
tomorrow14 October 2026relative to today
+3, 3d16 October 2026a count of days from today
2w27 October 2026a count of weeks
fri16 October 2026the next Friday
1/2/472 January 19472047 would be more than twenty years ahead
20/10, 2010—twenty is not a month; nothing is swapped
1/1/202—a year is two digits or four
2/30—there is no such day

Stepping

A step is a day; a large step a week; a page a month. A step in the month or year keeps the day, or takes the last day of a month too short for it.

Stepping stops at the ends of the range. A step of days that would pass an end lands on it. A step of a month or a year lands on the end when the end is in the month or year stepped to; when no day of that month or year is allowed, the date stays where it is rather than changing a part that was not stepped. A date already outside the range steps towards it one step at a time, and never further away.

See also Editing values and Date field.

Behaviour

Reading

What is shown reads back as the same date, in every locale and style
for (const locale of LOCALES)
  for (const dateStyle of STYLES) expect(misread({ locale, dateStyle })).toEqual([]);
Numbers are read in the locale's order, with any separators
expect(value("10/13/2026")).toBe("2026-10-13");
expect(value("10-13-2026")).toBe("2026-10-13");
expect(value("13.10.2026", { locale: "de-DE" })).toBe("2026-10-13");
expect(value("13/10/2026", { locale: "en-GB" })).toBe("2026-10-13");
expect(value("2026/10/13", { locale: "ja-JP" })).toBe("2026-10-13");
expect(value("26-10-13", { locale: "ja-JP" })).toBe("2026-10-13");
Four digits first are the year, whatever the locale
for (const locale of LOCALES) expect(value("2026-10-13", { locale })).toBe("2026-10-13");
A date without a year is in this year; a day alone is in this month
expect(value("3/7")).toBe("2026-03-07");
expect(value("7.3.", { locale: "de-DE" })).toBe("2026-03-07");
expect(value("21")).toBe("2026-10-21");
A two-digit year is the one nearest today, at most twenty years ahead
expect(value("1/2/26")).toBe("2026-01-02");
expect(value("1/2/46")).toBe("2046-01-02");
expect(value("1/2/47")).toBe("1947-01-02");
A year of three digits, or of five, is not a year
expect(value("1/1/202")).toBe("invalid");
expect(value("026")).toBe("invalid");
expect(value("Oct 13, 202")).toBe("invalid");
expect(value("202年1月1日", { locale: "ja-JP" })).toBe("invalid");
expect(value("1/1/20260")).toBe("invalid");
Digits alone are a whole date, or a month and day in the locale's order
expect(value("20261013")).toBe("2026-10-13");
expect(value("0307")).toBe("2026-03-07");
expect(value("0307", { locale: "en-GB" })).toBe("2026-07-03");
The day and the month are never swapped to make a date of what was typed
expect(value("13/04")).toBe("invalid");
expect(value("13/10/2026")).toBe("invalid");
expect(value("2012")).toBe("invalid");
expect(value("26-10-13")).toBe("invalid");
expect(value("04/13", { locale: "en-GB" })).toBe("invalid");
expect(value("1320", { locale: "en-GB" })).toBe("invalid");
Months may be named, in the locale's language or English, in full or abbreviated
expect(value("Oct 13, 2026")).toBe("2026-10-13");
expect(value("13 october")).toBe("2026-10-13");
expect(value("sept 5")).toBe("2026-09-05");
expect(value("13. Oktober 2026", { locale: "de-DE" })).toBe("2026-10-13");
expect(value("13 de octubre de 2026", { locale: "es-ES" })).toBe("2026-10-13");
expect(value("13 октября", { locale: "ru-RU" })).toBe("2026-10-13");
expect(value("13 Oct", { locale: "ja-JP" })).toBe("2026-10-13");
expect(value("13 tháng 10, 2026", { locale: "vi-VN" })).toBe("2026-10-13");
expect(value("13 tháng 12", { locale: "vi-VN" })).toBe("2026-12-13");
A weekday alongside the date is ignored
expect(value("Tuesday, October 13, 2026")).toBe("2026-10-13");
expect(value("Tue 10/13")).toBe("2026-10-13");
expect(value("10/13/2026 (Tue)")).toBe("2026-10-13");
expect(value("Dienstag, 13.10.2026", { locale: "de-DE" })).toBe("2026-10-13");
expect(value("2026年10月13日星期二", { locale: "zh-CN" })).toBe("2026-10-13");
A word that names both a weekday and a month is the weekday where the month is given otherwise
// In Spanish `mar` is both martes and marzo.
expect(value("mar, 13 oct 2026", { locale: "es-ES" })).toBe("2026-10-13");
expect(value("mar, 3 mar 2026", { locale: "es-ES" })).toBe("2026-03-03");
expect(value("mar, 13/10/2026", { locale: "es-ES" })).toBe("2026-10-13");
expect(value("3 mar", { locale: "es-ES" })).toBe("2026-03-03");
expect(value("mar 3", { locale: "es-ES" })).toBe("invalid");
In Japanese, each of the seven weekdays beside a date is ignored, in parentheses or not
const days = ["日", "月", "火", "水", "木", "金", "土"];
days.forEach((day, index) => {
  const date = `2026-10-${11 + index}`;
  for (const text of [
    `2026年10月${11 + index}日(${day})`,
    `2026年10月${11 + index}日(${day})`,
    `2026年10月${11 + index}日 ${day}`,
    `2026年10月${11 + index}日${day}曜日`,
    `10/${11 + index}(${day})`,
    `10/${11 + index} ${day}`,
    `${day}曜 10/${11 + index}`,
    `10/${11 + index}${day}曜日`,
  ])
    expect(value(text, { locale: "ja-JP" }), text).toBe(date);
});
In Korean, each of the seven weekdays beside a date is ignored, in parentheses or not
const days = ["일", "월", "화", "수", "목", "금", "토"];
days.forEach((day, index) => {
  const date = `2026-10-${11 + index}`;
  for (const text of [
    `2026년 10월 ${11 + index}일 (${day})`,
    `2026년 10월 ${11 + index}일(${day})`,
    `2026년 10월 ${11 + index}일 ${day}요일`,
    `2026. 10. ${11 + index}. (${day})`,
    `10/${11 + index} ${day}`,
  ])
    expect(value(text, { locale: "ko-KR" }), text).toBe(date);
});
Fields labelled as East Asian languages write them are read by their labels
expect(value("2026年10月13日", { locale: "ja-JP" })).toBe("2026-10-13");
expect(value("10月13日", { locale: "ja-JP" })).toBe("2026-10-13");
expect(value("21日", { locale: "ja-JP" })).toBe("2026-10-21");
expect(value("2026년 10월 13일", { locale: "ko-KR" })).toBe("2026-10-13");
expect(value("2026 年 10 月 13 日", { locale: "zh-CN" })).toBe("2026-10-13");
expect(value("10月13号", { locale: "zh-CN" })).toBe("2026-10-13");
expect(value("10月13號", { locale: "zh-TW" })).toBe("2026-10-13");
// A label with no number before it labels nothing.
expect(value("年10月13日", { locale: "ja-JP" })).toBe("invalid");
Full-width characters, as an input method types them, read the same
expect(value("2026/10/13", { locale: "ja-JP" })).toBe("2026-10-13");
expect(value("10月13日", { locale: "ja-JP" })).toBe("2026-10-13");
expect(value("2026ー10ー13", { locale: "ja-JP" })).toBe("2026-10-13");
expect(value("2026・10・13", { locale: "ja-JP" })).toBe("2026-10-13");
expect(value("2026.10.13", { locale: "ja-JP" })).toBe("2026-10-13");
A year of a Japanese era is read, by the era's name or its initial
const japanese = { locale: "ja-JP" };
expect(value("令和8年10月13日", japanese)).toBe("2026-10-13");
expect(value("令和元年5月1日", japanese)).toBe("2019-05-01");
expect(value("平成30年4月1日", japanese)).toBe("2018-04-01");
expect(value("昭和60年1月1日", japanese)).toBe("1985-01-01");
expect(value("大正12年9月1日", japanese)).toBe("1923-09-01");
expect(value("明治45年7月29日", japanese)).toBe("1912-07-29");
expect(value("R8.10.13", japanese)).toBe("2026-10-13");
expect(value("H30.4.1", japanese)).toBe("2018-04-01");
expect(value("S60.1.1", japanese)).toBe("1985-01-01");
expect(value("R8年10月13日", japanese)).toBe("2026-10-13");
// An era names its year in any field.
expect(value("令和8年10月13日")).toBe("2026-10-13");
A year an era did not reach is not a date
expect(value("平成40年1月1日", { locale: "ja-JP" })).toBe("invalid");
expect(value("H40.1.1", { locale: "ja-JP" })).toBe("invalid");
expect(value("令和0年1月1日", { locale: "ja-JP" })).toBe("invalid");
In Japanese, a short year with 年 and no era is the era's or the century's, whichever is nearer today
const japanese = { locale: "ja-JP" };
expect(value("8年10月13日", japanese)).toBe("2026-10-13");
expect(value("26年10月13日", japanese)).toBe("2026-10-13");
expect(value("5年10月13日", japanese)).toBe("2023-10-13");
expect(value("30年10月13日", japanese)).toBe("2030-10-13");
// Elsewhere 年 labels a year of the common era.
expect(value("8年10月13日", { locale: "zh-CN" })).toBe("2008-10-13");
The locale's words for today and the two days either side of it are dates
expect(value("today")).toBe("2026-10-13");
expect(value("Tomorrow")).toBe("2026-10-14");
expect(value("昨日", { locale: "ja-JP" })).toBe("2026-10-12");
expect(value("morgen", { locale: "de-DE" })).toBe("2026-10-14");
expect(value("明後日", { locale: "ja-JP" })).toBe("2026-10-15");
expect(value("一昨日", { locale: "ja-JP" })).toBe("2026-10-11");
expect(value("übermorgen", { locale: "de-DE" })).toBe("2026-10-15");
A number with a sign is that many days from today
expect(value("+3")).toBe("2026-10-16");
expect(value("-1")).toBe("2026-10-12");
expect(value("+3", { locale: "ja-JP" })).toBe("2026-10-16");
expect(value("+0")).toBe("2026-10-13");
With d, w, m or y after it, a number is days, weeks, months or years from today
expect(value("+3d")).toBe("2026-10-16");
expect(value("2w")).toBe("2026-10-27");
expect(value("-1m")).toBe("2026-09-13");
expect(value("+1y")).toBe("2027-10-13");
expect(value("+9999y")).toBe("invalid");
// Lithuanian writes `d.` after the day of the month.
expect(value("13d", { locale: "lt-LT" })).toBe("2026-10-13");
expect(value("+13d", { locale: "lt-LT" })).toBe("2026-10-26");
Months or years from today end on the last day of a month too short for the day
expect(value("+1m", { today: () => "2026-01-31" })).toBe("2026-02-28");
expect(value("-1m", { today: () => "2026-03-31" })).toBe("2026-02-28");
expect(value("+1y", { today: () => "2028-02-29" })).toBe("2029-02-28");
A weekday alone is the next such day, today included
// 13 October 2026 is a Tuesday.
expect(value("fri")).toBe("2026-10-16");
expect(value("Friday")).toBe("2026-10-16");
expect(value("tue")).toBe("2026-10-13");
expect(value("mon")).toBe("2026-10-19");
expect(value("thurs")).toBe("2026-10-15");
expect(value("金曜", { locale: "ja-JP" })).toBe("2026-10-16");
expect(value("金曜日", { locale: "ja-JP" })).toBe("2026-10-16");
expect(value("금요일", { locale: "ko-KR" })).toBe("2026-10-16");
expect(value("Freitag", { locale: "de-DE" })).toBe("2026-10-16");
next or last with a weekday, such as next friday, is that day of the week after or before

Not yet written.

A weekday of a named week, such as 来週の金曜, is read

Not yet written.

The end of the month, such as 月末, is read

Not yet written.

A time written after a date is dropped; the date is the one written, in no other zone
expect(value("2026-10-13T09:30:00Z")).toBe("2026-10-13");
expect(value("2026-10-13T23:30:00.000-08:00")).toBe("2026-10-13");
expect(value("2026-10-13 09:30")).toBe("2026-10-13");
expect(value("10/13/2026 9:30 AM")).toBe("2026-10-13");
expect(value("10/13/2026, 9:30:15 PM")).toBe("2026-10-13");
expect(value("9:30")).toBe("invalid");
expect(value("10/13/2026 25:00")).toBe("invalid");
A date that does not exist is not rolled over into one that does
expect(value("2/30/2026")).toBe("nonexistent");
expect(value("2/29/2027")).toBe("nonexistent");
expect(value("Feb 30")).toBe("nonexistent");
expect(value("2/29/2028")).toBe("2028-02-29");
expect(value("13/13/2026")).toBe("invalid");
expect(value("2/32/2026")).toBe("invalid");
A date outside the range is refused, naming the end it is beyond, not moved to it
const range = { minimum: "2026-10-01", maximum: "2026-10-31" };
expect(type(range).parse("9/15")).toEqual({ ok: false, reason: "minimum" });
expect(type(range).parse("12/25")).toEqual({ ok: false, reason: "maximum" });
expect(type(range).parse("+30")).toEqual({ ok: false, reason: "maximum" });
expect(value("10/1", range)).toBe("2026-10-01");
expect(value("10/31", range)).toBe("2026-10-31");
expect(type(range).constrain("2026-09-15")).toBe("2026-09-15");
Nothing typed is empty; anything else that is not a date is invalid
expect(value("  ")).toBe("empty");
for (const text of ["soon", "2026-10", "1/2/3/4", "Oct Nov 3", "123", "October", "Tue Fri"])
  expect(value(text)).toBe("invalid");
A locale's names are gathered once, however many fields read with them
const made = vi.spyOn(Intl, "DateTimeFormat");
try {
  dateType({ locale: "nb-NO" });
  const first = made.mock.calls.length;
  dateType({ locale: "nb-NO" });
  expect(first).toBeGreaterThan(10);
  expect(made.mock.calls.length - first).toBe(1);
} finally {
  made.mockRestore();
}
Dates in calendars other than the Gregorian are read and shown

Not yet written.

Showing

A date is shown as the locale writes dates
expect(type().format("2026-10-03")).toBe("Oct 3, 2026");
expect(type({ locale: "de-DE" }).format("2026-10-03")).toBe("03.10.2026");
expect(type({ locale: "ja-JP", dateStyle: "long" }).format("2026-10-03")).toBe("2026年10月3日");
Its parts join to what is shown
fc.assert(
  fc.property(
    fc.constantFrom(...LOCALES),
    fc.constantFrom(undefined, "short" as const, "long" as const),
    anyDate,
    (locale, weekday, date) => {
      const dates = type({ locale, weekday });
      return (
        dates
          .parts(date)
          .map((part) => part.text)
          .join("") === dates.format(date)
      );
    },
  ),
);
expect(
  type()
    .parts("2026-10-03")
    .filter((part) => part.type !== "literal")
    .map((part) => part.type),
).toEqual(["month", "day", "year"]);
The weekday is shown with the date when asked for, the date written as its style writes it
expect(type({ weekday: "short" }).format("2026-10-13")).toBe("Tue, Oct 13, 2026");
expect(type({ weekday: "long", dateStyle: "long" }).format("2026-10-13")).toBe(
  "Tuesday, October 13, 2026",
);
expect(type({ weekday: "short", dateStyle: "short" }).format("2026-10-03")).toBe("Sat, 10/3/2026");
// Engines differ on the comma German puts after the weekday.
expect(type({ weekday: "short", locale: "de-DE" }).format("2026-10-03")).toMatch(
  /^Sa\.,? 03\.10\.2026$/,
);
expect(type({ weekday: "short", locale: "ja-JP", dateStyle: "long" }).format("2026-10-11")).toBe(
  "2026年10月11日(日)",
);
expect(
  type({ weekday: "short" })
    .parts("2026-10-13")
    .filter((part) => part.type !== "literal")
    .map((part) => part.type),
).toEqual(["weekday", "month", "day", "year"]);
With its weekday, what is shown reads back as the same date, in every locale and style
for (const locale of LOCALES)
  for (const dateStyle of STYLES)
    for (const weekday of ["short", "long"] as const)
      expect(misread({ locale, dateStyle, weekday }), weekday).toEqual([]);
A date is shown in the years of an era, such as 令和8年

Not yet written.

Showing, whatever is given

The short style writes the year in full, so that a date reads back in its own century
for (const locale of ["en-US", "de-DE", "ja-JP", "fr-FR"]) {
  const short = type({ locale, dateStyle: "short" });
  expect(short.format("1940-03-05"), locale).toContain("1940");
  expect(short.parse(short.format("1940-03-05")), locale).toEqual({ ok: true, value: "1940-03-05" });
}
A value that is not a date is shown as nothing, rather than failing or as another date
for (const text of ["", "soon", "2026-10-13T00:00:00Z", "2026-02-30", "10/13/2026"]) {
  expect(type().format(text), text).toBe("");
}
A locale that is not one is put right, not failed on
expect(type({ locale: "en_US" }).format("2026-10-13")).toBe("Oct 13, 2026");

Stepping

A step is a day; a large step a week; a page a month
const dates = type();
expect(dates.step("2026-10-31", { direction: 1, size: "normal" })).toBe("2026-11-01");
expect(dates.step("2026-10-13", { direction: -1, size: "large" })).toBe("2026-10-06");
expect(dates.step("2026-10-13", { direction: 1, size: "page" })).toBe("2026-11-13");
A step in the month or the year changes that part alone
const dates = type();
expect(dates.step("2026-10-13", { direction: 1, size: "normal", part: "month" })).toBe("2026-11-13");
expect(dates.step("2026-12-13", { direction: 1, size: "normal", part: "month" })).toBe("2027-01-13");
expect(dates.step("2026-10-13", { direction: -1, size: "normal", part: "year" })).toBe("2025-10-13");
A step in the weekday is a day
const dates = type({ weekday: "short" });
expect(dates.step("2026-10-13", { direction: 1, size: "normal", part: "weekday" })).toBe("2026-10-14");
A month too short for the day ends on its last day
const dates = type();
expect(dates.step("2026-01-31", { direction: 1, size: "page" })).toBe("2026-02-28");
expect(dates.step("2028-02-29", { direction: 1, size: "normal", part: "year" })).toBe("2029-02-28");
A step of days that would pass the end of the range stops at the end
const dates = type({ minimum: "2026-10-10", maximum: "2026-10-14" });
expect(dates.step("2026-10-13", { direction: 1, size: "large" })).toBe("2026-10-14");
expect(dates.step("2026-10-14", { direction: 1, size: "normal" })).toBe("2026-10-14");
expect(dates.step("2026-10-11", { direction: -1, size: "large" })).toBe("2026-10-10");
expect(dates.step("2026-10-10", { direction: -1, size: "normal" })).toBe("2026-10-10");
A step of a month or a year that would pass the end of the range stops at the end when the end is in the month or year stepped to; otherwise the date stays
const dates = type({ minimum: "2026-09-20", maximum: "2027-03-01" });
const month = { size: "normal", part: "month" } as const;
const year = { size: "normal", part: "year" } as const;
expect(dates.step("2026-10-13", { direction: -1, ...month })).toBe("2026-09-20");
expect(dates.step("2026-09-25", { direction: -1, ...month })).toBe("2026-09-25");
expect(dates.step("2026-09-25", { direction: -1, size: "page" })).toBe("2026-09-25");
expect(dates.step("2027-02-13", { direction: 1, ...month })).toBe("2027-03-01");
expect(dates.step("2026-10-13", { direction: 1, ...year })).toBe("2027-03-01");
expect(dates.step("2026-10-13", { direction: -1, ...year })).toBe("2026-10-13");
A date outside the range steps towards it one step at a time, and never further from it
const dates = type({ minimum: "2026-10-01", maximum: "2026-10-31" });
expect(dates.step("2020-01-01", { direction: 1, size: "normal" })).toBe("2020-01-02");
expect(dates.step("2020-01-01", { direction: 1, size: "normal", part: "year" })).toBe("2021-01-01");
expect(dates.step("2020-01-01", { direction: -1, size: "normal" })).toBe("2020-01-01");
expect(dates.step("2030-01-01", { direction: -1, size: "page" })).toBe("2029-12-01");
expect(dates.step("2030-01-01", { direction: 1, size: "normal" })).toBe("2030-01-01");
Stepping stops at the years 1 and 9999
const dates = type();
expect(dates.step("0001-06-15", { direction: -1, size: "normal", part: "year" })).toBe("0001-06-15");
expect(dates.step("0001-01-01", { direction: -1, size: "normal" })).toBe("0001-01-01");
expect(dates.step("0001-01-15", { direction: -1, size: "page" })).toBe("0001-01-15");
expect(dates.step("9999-06-15", { direction: 1, size: "normal", part: "year" })).toBe("9999-06-15");
expect(dates.step("9999-12-31", { direction: 1, size: "normal" })).toBe("9999-12-31");
expect(dates.step("9999-12-15", { direction: 1, size: "page" })).toBe("9999-12-15");
Stepping an empty field starts from today, or from the end of the range nearest today
expect(type().origin).toBe("2026-10-13");
expect(type({ minimum: "2026-11-01" }).origin).toBe("2026-11-01");
expect(type({ maximum: "2026-09-30" }).origin).toBe("2026-09-30");

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/date-type.json
Source

src/primitives/date-type.ts

/**
 * The calendar-date value type: a day, with no time and no time zone, held as
 * an ISO 8601 string (`2026-10-13`) — the form databases, forms and JSON share.
 *
 * A reading is what the person meant, or it is refused; it is never a guess.
 * It accepts:
 *
 * - numbers with any separators, in the locale's order: `10/13/2026`, `13.10.26`;
 * - four digits first as a year, whatever the locale: `2026-10-13`;
 * - part of a date, completed from today: `10/13`, or just `13`;
 * - digits alone: `20261013`, or `1013`;
 * - month names, in the locale's language or English, in full or abbreviated;
 * - fields labelled as East Asian languages write them: `2026年10月13日`;
 * - a year of a Japanese era: `令和8年10月13日`, `R8.10.13`;
 * - a weekday beside the date, which is ignored, or alone, which is the next one;
 * - the locale's words for today and the days around it;
 * - a count from today: `+3`, `-1`, `2w`, `+1m`, `+1y`;
 * - a time after the date, which is dropped.
 *
 * It refuses a date that does not exist, such as 30 February, rather than
 * rolling it over; numbers that are a date only with day and month exchanged;
 * and a date outside the range, rather than moving it to the range's end.
 */
import { civilFromDay, dayFromCivil, daysInMonth, FIRST_DAY, LAST_DAY } from "../algorithms/civil-days.ts";
import { validLocale } from "../lib/valid-locale.ts";
import { normalizeTyped } from "./typed-text.ts";
import type { ParseResult, Part, Step } from "./use-field.ts";

export interface DateTypeOptions {
  readonly locale?: string | undefined;
  /** The earliest and latest dates allowed, as ISO strings. */
  readonly minimum?: string | undefined;
  readonly maximum?: string | undefined;
  readonly dateStyle?: "short" | "medium" | "long" | undefined;
  /** Shows the day of the week with the date: `Tue, Oct 13, 2026`. */
  readonly weekday?: "short" | "long" | undefined;
  /** Today, as an ISO string; what a partial date is completed from. */
  readonly today?: (() => string) | undefined;
}

export interface DateType {
  /** Why text is not a value: `empty`, `invalid`, `nonexistent`, `minimum` or `maximum`. */
  readonly parse: (text: string) => ParseResult<string>;
  readonly format: (value: string) => string;
  readonly parts: (value: string) => readonly Part[];
  readonly constrain: (value: string) => string;
  readonly step: (value: string, step: Step) => string;
  readonly origin: string;
  readonly minimum: string | undefined;
  readonly maximum: string | undefined;
}

type Field = "year" | "month" | "day";

interface YMD {
  readonly year: number;
  readonly month: number;
  readonly day: number;
}

function exists({ year, month, day }: YMD): boolean {
  return (
    year >= 1 && year <= 9999 && month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month)
  );
}

function toISO({ year, month, day }: YMD): string {
  return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}

function fromISO(value: string): YMD {
  const [year = 1, month = 1, day = 1] = value.split("-").map(Number);
  return { year, month, day };
}

/** A UTC instant at the start of the day, for arithmetic and formatting. */
function toDate({ year, month, day }: YMD): Date {
  const date = new Date(Date.UTC(2000, 0, 1));
  date.setUTCFullYear(year, month - 1, day);
  return date;
}

/** So many days on; undefined beyond the years an ISO date can write. */
function addDays(value: string, days: number): string | undefined {
  const { year, month, day } = fromISO(value);
  const next = dayFromCivil(year, month, day) + days;
  if (!(next >= FIRST_DAY && next <= LAST_DAY)) return undefined;
  civilFromDay(next, civilParts);
  return toISO({ year: civilParts[0], month: civilParts[1], day: civilParts[2] });
}

// Written into by `addDays`: one array for every call.
const civilParts = [0, 0, 0] as [number, number, number];

/** The same day in another month, or that month's last day when it is shorter. */
function addMonths(value: string, months: number): string | undefined {
  const { year, month, day } = fromISO(value);
  const index = year * 12 + (month - 1) + months;
  const nextYear = Math.floor(index / 12);
  const nextMonth = (index % 12) + 1;
  if (nextYear < 1 || nextYear > 9999) return undefined;
  return toISO({ year: nextYear, month: nextMonth, day: Math.min(day, daysInMonth(nextYear, nextMonth)) });
}

/** Whether a string is a calendar date that exists, written `YYYY-MM-DD`. */
export function isDate(value: string): boolean {
  return /^\d{4}-\d{2}-\d{2}$/.test(value) && exists(fromISO(value));
}

export function localToday(): string {
  const now = new Date();
  return toISO({ year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() });
}

/** A two-digit year is the one nearest today: no more than twenty years ahead. */
function expandYear(short: number, today: YMD): number {
  const candidate = Math.floor(today.year / 100) * 100 + short;
  if (candidate > today.year + 20) return candidate - 100;
  if (candidate < today.year - 79) return candidate + 100;
  return candidate;
}

/** A year is written with two digits or four; a month or a day with at most two. */
function fits(field: Field, digits: string): boolean {
  return digits.length <= 2 || (field === "year" && digits.length === 4);
}

/** How East Asian languages label the fields of a date. */
const LABELS: Readonly<Record<string, Field>> = {
  年: "year",
  月: "month",
  日: "day",
  号: "day",
  號: "day",
  년: "year",
  월: "month",
  일: "day",
};

const BASE = { timeZone: "UTC", calendar: "gregory" } as const;

// A number, or a word: letters, with the full stop an abbreviation ends in.
const TOKEN = /(\d+)|([\p{L}\p{M}]+\.?)/gu;
const WORD_CHARACTER = /[\p{L}\p{M}\d]/u;
// A count from today: `+3`, `-1`, `2w`.
const OFFSET = /^([+-])? ?(\d{1,6}) ?([dwmy])?$/;
// A time of day after a date, with whatever zone it carries.
const TIME =
  /(?:(?<=\d)t| |, ?)(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:[.,]\d+)?)?(?: ?[ap]\.?m\.?)?(?: ?(?:z|utc|gmt|[+-]\d\d(?::?\d\d)?))?$/;
// What an era year looks like, so the eras are asked for only when one may be there.
const ERA_HINT = /\p{Script=Han} ?(?:\d{1,2}|元) ?年|^[a-z]\.?\d{1,2} ?[./\-年]/u;

const EMPTY = { ok: false, reason: "empty" } as const;
const INVALID = { ok: false, reason: "invalid" } as const;
const NONEXISTENT = { ok: false, reason: "nonexistent" } as const;
const BELOW = { ok: false, reason: "minimum" } as const;
const ABOVE = { ok: false, reason: "maximum" } as const;

/** Typed text, or a name to compare it with, in one plain form. */
function clean(text: string, language: string): string {
  // An input method in kana mode types the long-vowel mark for a hyphen.
  return normalizeTyped(text).toLocaleLowerCase(language).replaceAll("ー", "-").replace(/\s+/g, " ").trim();
}

const word = (text: string) => (text.endsWith(".") ? text.slice(0, -1) : text);

/** A name of more than one word, or with a number in it, looked for in the text as a whole. */
interface Phrase {
  readonly text: string;
  readonly month?: number;
  readonly weekday?: number;
}

interface Vocabulary {
  /** The order the locale writes year, month and day in. */
  readonly order: readonly Field[];
  readonly months: ReadonlyMap<string, number>;
  /** Weekdays, counted from Sunday. */
  readonly weekdays: ReadonlyMap<string, number>;
  readonly phrases: readonly Phrase[];
  /** The locale's own connecting words, which carry no date. */
  readonly ignored: ReadonlySet<string>;
  /** Words for a day relative to today, as an offset in days. */
  readonly relative: ReadonlyMap<string, number>;
}

// Gathering a locale's names makes some forty formatters: once per locale, not per field.
const vocabularies = new Map<string, Vocabulary>();

function vocabulary(locale: string): Vocabulary {
  const cached = vocabularies.get(locale);
  if (cached !== undefined) return cached;

  const months = new Map<string, number>();
  const weekdays = new Map<string, number>();
  const phrases: Phrase[] = [];
  const ignored = new Set<string>();
  const relative = new Map<string, number>();

  const learn = (name: string, language: string, kind: "month" | "weekday", value: number) => {
    const text = clean(name, language);
    const tokens = text.match(TOKEN) ?? [];
    const letters = tokens.filter((token) => !/^\d/.test(token));
    if (letters.length === 0) return;
    if (tokens.length === 1) {
      const names = kind === "month" ? months : weekdays;
      const key = word(tokens[0]!);
      names.set(key, value);
      // Japanese drops the last character of a weekday in passing: 金曜 for 金曜日.
      if (key.endsWith("曜日")) names.set(key.slice(0, -1), value);
      return;
    }
    // A number beside a label is that field in digits, which the labels read.
    if (letters.every((letter) => LABELS[letter] !== undefined)) return;
    if (!phrases.some((phrase) => phrase.text === text)) phrases.push({ text, [kind]: value });
  };
  // The name as its part gives it, and with any letters written against it:
  // Hebrew joins "in" to the front of a month, Thai a particle to the end of a weekday.
  const gather = (
    parts: readonly Intl.DateTimeFormatPart[],
    language: string,
    kind: "month" | "weekday",
    value: number,
  ) => {
    const at = parts.findIndex((part) => part.type === kind);
    if (at < 0) return;
    const name = parts[at]!.value;
    learn(name, language, kind, value);
    const before = /[\p{L}\p{M}]+$/u.exec(parts[at - 1]?.value ?? "")?.[0] ?? "";
    const after = /^[\p{L}\p{M}]+/u.exec(parts[at + 1]?.value ?? "")?.[0] ?? "";
    if (before !== "" || after !== "") learn(before + name + after, language, kind, value);
  };
  const connect = (parts: readonly Intl.DateTimeFormatPart[]) => {
    for (const part of parts) {
      if (part.type !== "literal" && part.type !== "era") continue;
      for (const token of clean(part.value, locale).match(TOKEN) ?? []) ignored.add(word(token));
    }
  };

  const sample = new Date(Date.UTC(2026, 9, 13));
  // English first, so the locale's own names take any word the two share.
  for (const language of new Set(["en-US", locale])) {
    // With a day, some languages decline the month; people type either form.
    const monthFormats = (["long", "short"] as const).flatMap((month) => [
      new Intl.DateTimeFormat(language, { ...BASE, month }),
      new Intl.DateTimeFormat(language, { ...BASE, month, day: "numeric" }),
      new Intl.DateTimeFormat(language, { ...BASE, month, day: "numeric", year: "numeric" }),
    ]);
    const styleFormats =
      language === locale
        ? (["full", "long", "medium", "short"] as const).map(
            (dateStyle) => new Intl.DateTimeFormat(language, { ...BASE, dateStyle }),
          )
        : [];
    const weekdayFormats = (["long", "short"] as const).flatMap((weekday) => [
      new Intl.DateTimeFormat(language, { ...BASE, weekday }),
      ...(["long", "short", "numeric", "2-digit"] as const).map(
        (month) =>
          new Intl.DateTimeFormat(language, { ...BASE, weekday, month, day: "numeric", year: "numeric" }),
      ),
    ]);

    for (let month = 0; month < 12; month += 1) {
      const date = new Date(Date.UTC(2026, month, 15));
      for (const format of [...monthFormats, ...styleFormats]) {
        gather(format.formatToParts(date), language, "month", month + 1);
      }
    }
    for (let day = 0; day < 7; day += 1) {
      // 11 October 2026 is a Sunday.
      const date = new Date(Date.UTC(2026, 9, 11 + day));
      for (const format of [...weekdayFormats, ...styleFormats]) {
        gather(format.formatToParts(date), language, "weekday", day);
      }
    }
    if (language === locale) {
      for (const format of [...styleFormats, ...weekdayFormats]) connect(format.formatToParts(sample));
    }

    const relativeFormat = new Intl.RelativeTimeFormat(language, { numeric: "auto" });
    for (const offset of [0, 1, -1, 2, -2]) {
      const name = clean(relativeFormat.format(offset, "day"), language);
      relative.set(name, offset);
    }
  }
  for (const label of Object.keys(LABELS)) ignored.delete(label);

  const order = new Intl.DateTimeFormat(locale, { ...BASE, dateStyle: "short" })
    .formatToParts(sample)
    .map((part) => part.type)
    .filter((type): type is Field => type === "year" || type === "month" || type === "day");

  const made = { order, months, weekdays, phrases, ignored, relative };
  vocabularies.set(locale, made);
  return made;
}

/** A name people shorten their own way — "sept", "thurs" — if only one begins so. */
function abbreviated(names: ReadonlyMap<string, number>, name: string): number | undefined {
  if (name.length < 3) return undefined;
  let found: number | undefined;
  for (const [full, value] of names) {
    if (!full.startsWith(name) || value === found) continue;
    if (found !== undefined) return undefined;
    found = value;
  }
  return found;
}

/** Where a phrase stands in the text, not running on into more of a word or number; or -1. */
function find(source: string, phrase: string): number {
  for (let at = source.indexOf(phrase); at >= 0; at = source.indexOf(phrase, at + 1)) {
    const after = source[at + phrase.length];
    // `thg 1` is not found in `thg 12`.
    if (after === undefined || !WORD_CHARACTER.test(after)) return at;
  }
  return -1;
}

interface Era {
  readonly name: string;
  readonly initial: string;
  /** The Gregorian year that is its first, and the one that is its last. */
  readonly start: number;
  readonly end: number | undefined;
}

interface Eras {
  /** Newest first, back to Meiji. */
  readonly list: readonly Era[];
  readonly named: RegExp;
  readonly initialled: RegExp;
}

let eras: Eras | undefined;

const partOf = (format: Intl.DateTimeFormat, date: Date, type: string) =>
  format.formatToParts(date).find((part) => part.type === type)?.value ?? "";

/** The Japanese eras since Japan took up the Gregorian calendar, as the engine knows them. */
function japaneseEras(): Eras {
  if (eras !== undefined) return eras;
  const options = { timeZone: "UTC", year: "numeric" } as const;
  const long = new Intl.DateTimeFormat("ja-JP-u-ca-japanese", { ...options, era: "long" });
  const narrow = new Intl.DateTimeFormat("ja-JP-u-ca-japanese", { ...options, era: "narrow" });

  const list: Era[] = [];
  let end: number | undefined;
  // The last day of a year is in the era the year ends in: step back era by era.
  for (let year = new Date().getUTCFullYear(); year >= 1868;) {
    const date = new Date(Date.UTC(year, 11, 31));
    const name = partOf(long, date, "era");
    const written = partOf(long, date, "year");
    const count = written === "元" ? 1 : Number(normalizeTyped(written));
    if (name === "" || !(count >= 1)) break;
    const start = year - count + 1;
    list.push({ name, initial: partOf(narrow, date, "era").toLowerCase(), start, end });
    end = start;
    year = start - 1;
  }
  const initials = list.map((era) => era.initial).filter((initial) => /^[a-z]$/.test(initial));
  eras = {
    list,
    named: new RegExp(`(${list.map((era) => era.name).join("|") || "$^"}) ?(\\d{1,2}|元) ?年`),
    initialled: new RegExp(`^(${initials.join("|") || "$^"})\\.?(\\d{1,2})(?= ?[./\\-年])`),
  };
  return eras;
}

/** The text with an era's year rewritten as the Gregorian one; undefined if the era had no such year. */
function withoutEra(source: string): string | undefined {
  const { list, named, initialled } = japaneseEras();
  const byName = named.exec(source);
  const match = byName ?? initialled.exec(source);
  if (match === null) return source;
  const era = list.find((candidate) => candidate.name === match[1] || candidate.initial === match[1])!;
  const count = match[2] === "元" ? 1 : Number(match[2]);
  const year = era.start + count - 1;
  if (count < 1 || (era.end !== undefined && year > era.end)) return undefined;
  return (
    source.slice(0, match.index) +
    String(year).padStart(4, "0") +
    (byName === null ? "" : "年") +
    source.slice(match.index + match[0].length)
  );
}

/**
 * The date's fields as a style writes them, asked for one by one — which lets a
 * weekday be shown with them, as `Intl` will not allow beside a style, and lets
 * the year always be written in full: a year of two digits read back is a guess
 * at its century, and would move a date of 1940 to 2040 the first time it was
 * edited.
 */
function fieldsOf(
  locale: string,
  dateStyle: "short" | "medium" | "long",
  weekday: "short" | "long" | undefined,
): Intl.DateTimeFormatOptions {
  // A date whose month and day each show whether a zero is kept.
  const parts = new Intl.DateTimeFormat(locale, { ...BASE, dateStyle }).formatToParts(
    new Date(Date.UTC(2026, 0, 5)),
  );
  const digits = (type: string) => {
    const text = parts.find((part) => part.type === type)?.value ?? "";
    return /^\p{Nd}+$/u.test(text) ? text.length : 0;
  };
  const month = parts.findIndex((part) => part.type === "month");
  // Digits with a label after them — 1月 — are the month's name, where a name is asked for.
  const named = digits("month") === 0 || /^\p{L}/u.test(parts[month + 1]?.value ?? "");
  return {
    ...BASE,
    ...(weekday !== undefined && { weekday }),
    year: "numeric",
    month: named ? (dateStyle === "long" ? "long" : "short") : digits("month") === 2 ? "2-digit" : "numeric",
    day: digits("day") === 2 ? "2-digit" : "numeric",
  };
}

export function dateType({
  locale: localeOption = "en-US",
  minimum,
  maximum,
  dateStyle = "medium",
  weekday,
  today = localToday,
}: DateTypeOptions = {}): DateType {
  const locale = validLocale(localeOption);
  const { order, months, weekdays, phrases, ignored, relative } = vocabulary(locale);
  // A style is taken whole where it writes the year in full, as all but the short do.
  const formatter = new Intl.DateTimeFormat(
    locale,
    weekday === undefined && dateStyle !== "short"
      ? { ...BASE, dateStyle }
      : fieldsOf(locale, dateStyle, weekday),
  );
  const japanese = /^ja\b/i.test(locale);

  function within(value: string | undefined): ParseResult<string> {
    if (value === undefined) return INVALID;
    if (minimum !== undefined && value < minimum) return BELOW;
    if (maximum !== undefined && value > maximum) return ABOVE;
    return { ok: true, value };
  }

  function parse(text: string): ParseResult<string> {
    let source = clean(text, locale);
    if (source === "") return EMPTY;
    const now = fromISO(today());

    const days = relative.get(source);
    if (days !== undefined) return within(addDays(toISO(now), days));

    const count = OFFSET.exec(source);
    if (count !== null) {
      const [, sign, digits, unit] = count;
      // Unsigned, a letter the locale writes in its dates is no unit: `13 d.` is a day in Lithuanian.
      if (sign !== undefined || (unit !== undefined && !ignored.has(unit))) {
        const amount = (sign === "-" ? -1 : 1) * Number(digits);
        if (unit === "y") return within(addMonths(toISO(now), amount * 12));
        if (unit === "m") return within(addMonths(toISO(now), amount));
        return within(addDays(toISO(now), amount * (unit === "w" ? 7 : 1)));
      }
    }

    source = source.replace(TIME, "");
    // A colon is written in times, never in dates.
    if (source.includes(":")) return INVALID;

    if (ERA_HINT.test(source)) {
      const plain = withoutEra(source);
      if (plain === undefined) return INVALID;
      source = plain;
    }

    const known: { year?: number; month?: number; day?: number } = {};
    let shortYear = false;
    let eraYear = false;
    let weekdayCount = 0;
    let weekdayNamed = 0;
    // Words that name both a weekday and a month, until the rest says which.
    let either: { month: number; weekday: number; afterNumber: boolean }[] | undefined;

    for (const phrase of phrases) {
      const at = find(source, phrase.text);
      if (at < 0) continue;
      source = `${source.slice(0, at)} ${source.slice(at + phrase.text.length)}`;
      if (phrase.month !== undefined) {
        if (known.month !== undefined) return INVALID;
        known.month = phrase.month;
      } else {
        weekdayCount += 1;
        weekdayNamed = phrase.weekday!;
      }
    }

    // Numbers not yet assigned to a field, in the order typed.
    const numbers: string[] = [];
    // Where the last number ended, while nothing has come after it.
    let numberEnd = -1;

    for (const match of source.matchAll(TOKEN)) {
      if (match[1] !== undefined) {
        numbers.push(match[1]);
        numberEnd = match.index + match[1].length;
        continue;
      }
      let name = word(match[2]!);
      const gap = numberEnd < 0 ? undefined : source.slice(numberEnd, match.index);
      numberEnd = -1;

      const label = LABELS[name[0]!];
      if (label !== undefined) {
        // A label names the number it follows. Apart from it, or after other
        // numbers, the same character is a weekday: 10/13 月.
        const follows = gap === "" || (gap === " " && numbers.length === 1 && name.length === 1);
        const whole = name.length > 1 && (weekdays.has(name) || months.has(name) || ignored.has(name));
        if (follows && !whole) {
          const digits = numbers.pop()!;
          if (known[label] !== undefined || !fits(label, digits)) return INVALID;
          known[label] = Number(digits);
          if (label === "year") {
            shortYear = digits.length <= 2;
            eraYear = shortYear && japanese && name.startsWith("年");
          }
          name = name.slice(1);
          if (name === "") continue;
        }
      }

      const month = months.get(name);
      const day = weekdays.get(name);
      if (day !== undefined) {
        const also = month ?? abbreviated(months, name);
        if (also === undefined) {
          weekdayCount += 1;
          weekdayNamed = day;
        } else {
          (either ??= []).push({ month: also, weekday: day, afterNumber: numbers.length > 0 });
        }
      } else if (month !== undefined) {
        if (known.month !== undefined) return INVALID;
        known.month = month;
      } else if (!ignored.has(name)) {
        const shortened = abbreviated(months, name);
        if (shortened !== undefined) {
          if (known.month !== undefined) return INVALID;
          known.month = shortened;
        } else {
          const shortenedDay = abbreviated(weekdays, name);
          if (shortenedDay === undefined) return INVALID;
          weekdayCount += 1;
          weekdayNamed = shortenedDay;
        }
      }
    }

    if (either !== undefined) {
      let month: (typeof either)[number] | undefined;
      // With the month given otherwise, or no number for a month to go with, they are weekdays.
      if (known.month === undefined && numbers.length > 0 && numbers.length < 3) {
        // A weekday is written before its date; a month, in these languages, after the day.
        if (either.length === 2) month = either[1];
        else if (either.length === 1 && either[0]!.afterNumber) month = either[0];
        if (month === undefined) return INVALID;
        known.month = month.month;
      }
      for (const name of either) {
        if (name === month) continue;
        weekdayCount += 1;
        weekdayNamed = name.weekday;
      }
    }

    const labelled = known.year !== undefined || known.month !== undefined || known.day !== undefined;
    if (!labelled && numbers.length === 0) {
      // A weekday alone is the next one, today included.
      if (weekdayCount !== 1) return INVALID;
      const ahead = (weekdayNamed - toDate(now).getUTCDay() + 7) % 7;
      return within(addDays(toISO(now), ahead));
    }

    if (labelled) {
      // What is left fills the fields still open: four digits are the year.
      const open = order.filter((field) => known[field] === undefined);
      for (const digits of numbers) {
        const isYear = (digits.length > 2 || Number(digits) > 31) && open.includes("year");
        const field = isYear ? "year" : (open.find((candidate) => candidate !== "year") ?? open[0]);
        if (field === undefined || !fits(field, digits)) return INVALID;
        open.splice(open.indexOf(field), 1);
        known[field] = Number(digits);
        if (field === "year") shortYear = digits.length <= 2;
      }
    } else if (numbers.length === 1) {
      const digits = numbers[0]!;
      if (digits.length === 8) {
        known.year = Number(digits.slice(0, 4));
        known.month = Number(digits.slice(4, 6));
        known.day = Number(digits.slice(6));
      } else if (digits.length === 4) {
        const [first, second] = order.filter((field) => field !== "year");
        known[first!] = Number(digits.slice(0, 2));
        known[second!] = Number(digits.slice(2));
      } else if (digits.length <= 2) {
        known.day = Number(digits);
        known.month = now.month;
      } else {
        return INVALID;
      }
    } else if (numbers.length === 2 || numbers.length === 3) {
      // Four digits first is a year in any locale; otherwise the locale's order.
      const yearFirst = numbers[0]!.length === 4;
      const fields: readonly Field[] = yearFirst
        ? ["year", "month", "day"]
        : numbers.length === 3
          ? order
          : order.filter((field) => field !== "year");
      for (let index = 0; index < numbers.length; index += 1) {
        const field = fields[index]!;
        const digits = numbers[index]!;
        if (!fits(field, digits)) return INVALID;
        known[field] = Number(digits);
        if (field === "year") shortYear = digits.length <= 2;
      }
    } else {
      return INVALID;
    }

    let { year, month } = known;
    const { day } = known;
    if (month === undefined || day === undefined) {
      // A day alone, labelled, is a day of this month.
      if (day === undefined || !labelled || year !== undefined) return INVALID;
      month = now.month;
    }
    if (year === undefined) {
      year = now.year;
    } else if (shortYear) {
      const century = expandYear(year, now);
      if (eraYear) {
        // Japanese writes the era's year the same way: whichever is nearer today is meant.
        const era = japaneseEras().list.find((candidate) => candidate.start <= now.year);
        const inEra = era === undefined ? century : era.start + year - 1;
        year = Math.abs(inEra - now.year) < Math.abs(century - now.year) ? inEra : century;
      } else {
        year = century;
      }
    }

    // Month and day are never exchanged to make a date of numbers that are not one.
    if (year < 1 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31) return INVALID;
    if (day > daysInMonth(year, month)) return NONEXISTENT;
    return within(toISO({ year, month, day }));
  }

  function parts(value: string): Part[] {
    return formatter.formatToParts(toDate(fromISO(value))).map((part) => ({
      type:
        part.type === "year" || part.type === "month" || part.type === "day" || part.type === "weekday"
          ? part.type
          : "literal",
      text: part.value,
    }));
  }

  function step(value: string, { direction, size, part }: Step): string {
    const span = part === "year" ? 12 : part === "month" || size === "page" ? 1 : 0;
    const next =
      span > 0 ? addMonths(value, direction * span) : addDays(value, direction * (size === "large" ? 7 : 1));
    if (next === undefined) return value;
    const end = direction > 0 ? maximum : minimum;
    if (end === undefined) return next;
    const beyond = (date: string) => (direction > 0 ? date > end : date < end);
    if (!beyond(next)) return next;
    // At the end or outside it already: a step never goes further out.
    if (value === end || beyond(value)) return value;
    if (span === 0) return end;
    // A month or year the range ends in is entered at that end; one wholly outside is not.
    const length = span === 12 ? 4 : 7;
    return end.slice(0, length) === next.slice(0, length) ? end : value;
  }

  return {
    parse,
    // Only a date is shown as one: anything else a record might hold is shown as nothing.
    format: (value) => (isDate(value) ? formatter.format(toDate(fromISO(value))) : ""),
    parts,
    // A date outside the range is refused where it is read, not moved.
    constrain: (value) => value,
    step,
    get origin() {
      const now = today();
      if (minimum !== undefined && now < minimum) return minimum;
      if (maximum !== undefined && now > maximum) return maximum;
      return now;
    },
    minimum,
    maximum,
  };
}