{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "date-input",
  "title": "Date Input",
  "description": "A date field you type into, one segment at a time, that emits an ISO \"YYYY-MM-DD\" string. Use it for date of birth and signup forms, booking and check-in/check-out dates, card expiry, invoice and due dates, report ranges, and admin filters — anywhere the reader already knows the date and wants to type it rather than hunt for it in a month grid. Common asks it answers: \"date input\", \"date field\", \"typed date entry\", \"dd/mm/yyyy input\", \"segmented date field\", \"date of birth input\", \"birthday field\", \"keyboard accessible date picker\", \"date picker without a calendar\", \"react-day-picker alternative\", \"input type=date replacement\", \"styled native date input\". shadcn/ui ships no typed date entry: its calendar is a month grid you click, and the Date Picker page composes that calendar into a popover behind a read-only trigger button, so the only keyboard route is arrow-keying around a grid; input-otp is segmented but for fixed-length codes with no date meaning. This is the typing half, and it composes with calendar rather than replacing it. The work is in the parts that are easy to get wrong. Segment order comes from the locale through Intl, so en-US renders month/day/year, en-GB and de-DE day/month/year, and ja-JP year/month/day, instead of the hardcoded M/D/Y that silently means the wrong day for most of the world; the calendar is pinned to Gregorian, so a Buddhist or Japanese-era locale cannot hand back the year 2569 or 8 to be emitted as though it were Gregorian. The day is clamped whenever the month or year changes, so January 31 switched to February becomes the 28th — or the 29th in a leap year, by the full 4/100/400 rule — instead of the silent rollover into March that a raw Date gives you. Auto-advance is decided by range rather than by a fixed two-digit count: typing 5 into the month jumps straight to the next segment because no month starts with 5, while 1 waits for a possible 10, 11 or 12, and a pair that cannot exist starts a new number instead of dropping the keystroke. Arrow keys step a segment, wrapping month and day, clamping the year, and seeding an empty segment from today; Backspace clears and steps back; Home, End and left/right move between segments. Each segment is a spinbutton with its own label and value range, and the month is announced by name rather than as a bare number. Values outside min/max are flagged with aria-invalid without ever blocking typing, the way a native date input behaves. Works controlled or uncontrolled, forwards a ref to the first segment so a shortcut can focus it, and mirrors the ISO value into a hidden input for native form submit. Theme-aware via shadcn tokens; no dependencies — no date library, no react-day-picker.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/date-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype SegmentName = \"year\" | \"month\" | \"day\"\n\ntype Part = { type: SegmentName } | { type: \"literal\"; value: string }\n\ninterface DateInputProps {\n  /** Controlled value as an ISO date string (\"YYYY-MM-DD\"), or \"\" while the date is incomplete. */\n  value?: string\n  /** Initial value when uncontrolled. */\n  defaultValue?: string\n  /** Fires with an ISO \"YYYY-MM-DD\" string, or \"\" while any segment is still empty. */\n  onChange?: (value: string) => void\n  /** Earliest valid date, ISO \"YYYY-MM-DD\". Typing is never blocked; out-of-range dates are flagged with aria-invalid. */\n  min?: string\n  /** Latest valid date, ISO \"YYYY-MM-DD\". */\n  max?: string\n  /** BCP-47 locale deciding segment order and the spoken month name. Defaults to the runtime's. */\n  locale?: string\n  /** Disable every segment. */\n  disabled?: boolean\n  /** Accessible label for the field group (default \"Date\"). */\n  \"aria-label\"?: string\n  /** When set, a hidden input mirrors the ISO value so it submits with a native form. */\n  name?: string\n  className?: string\n}\n\ntype Fields = { year: number | null; month: number | null; day: number | null }\n\nconst EMPTY_FIELDS: Fields = { year: null, month: null, day: null }\nconst EMPTY_BUF: Record<SegmentName, string> = { year: \"\", month: \"\", day: \"\" }\nconst LABELS: Record<SegmentName, string> = {\n  year: \"Year\",\n  month: \"Month\",\n  day: \"Day\",\n}\nconst PLACEHOLDERS: Record<SegmentName, string> = {\n  year: \"yyyy\",\n  month: \"mm\",\n  day: \"dd\",\n}\n\nconst FALLBACK_PARTS: Part[] = [\n  { type: \"year\" },\n  { type: \"literal\", value: \"-\" },\n  { type: \"month\" },\n  { type: \"literal\", value: \"-\" },\n  { type: \"day\" },\n]\n\n// Ask Intl for the segment order rather than hardcoding month/day/year: most of the world writes\n// D/M/Y and ja/ko write Y/M/D, so a hardcoded order makes \"03/04\" mean the wrong day half the time.\nfunction buildParts(locale: string | undefined): Part[] {\n  try {\n    // The calendar is pinned to Gregorian because everything else here is: the leap rule, the month\n    // lengths, and the ISO output. Left to the locale, th-TH would hand back the Buddhist year 2569\n    // and ja-JP-u-ca-japanese the era-relative year 8, and either would be emitted verbatim as a\n    // Gregorian year. The resolved calendar is re-checked because a runtime too old to know the\n    // option would silently ignore it.\n    const fmt = new Intl.DateTimeFormat(locale, {\n      calendar: \"gregory\",\n      year: \"numeric\",\n      month: \"2-digit\",\n      day: \"2-digit\",\n      timeZone: \"UTC\",\n    })\n    if (fmt.resolvedOptions().calendar !== \"gregory\") return FALLBACK_PARTS\n    const parts = fmt.formatToParts(new Date(Date.UTC(2026, 2, 14)))\n    const out: Part[] = []\n    for (const p of parts) {\n      if (p.type === \"year\" || p.type === \"month\" || p.type === \"day\") {\n        out.push({ type: p.type })\n      } else if (p.type === \"literal\") {\n        out.push({ type: \"literal\", value: p.value })\n      }\n    }\n    // Unreachable as written: pinning the calendar above means all three fields are always emitted\n    // (it is what stops -u-ca-chinese from returning relatedYear instead of year). Kept as a last\n    // resort only because rendering a field that is silently missing a segment is worse than\n    // falling back, and the mutation run confirms nothing else depends on it.\n    const named = new Set(out.filter((p) => p.type !== \"literal\").map((p) => p.type))\n    if (named.size === 3) return out\n  } catch {\n    // Intl missing, or the locale tag is malformed.\n  }\n  return FALLBACK_PARTS\n}\n\nconst isLeapYear = (y: number) => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0\n\nconst MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]\n\n// Arithmetic instead of `new Date(y, m, 0)`: Date maps years 0-99 onto 1900-1999, so year 0042 would\n// report February from 1942. An unknown year is treated as leap so a typed 29 survives until the\n// year arrives and clampDay can settle it.\nfunction daysInMonth(year: number | null, month: number | null) {\n  if (month === null) return 31\n  if (month === 2) return year === null || isLeapYear(year) ? 29 : 28\n  return MONTH_LENGTHS[month - 1]\n}\n\nfunction setField(f: Fields, seg: SegmentName, value: number | null): Fields {\n  if (seg === \"year\") return { ...f, year: value }\n  if (seg === \"month\") return { ...f, month: value }\n  return { ...f, day: value }\n}\n\n// Feb 29 with the year changed to a non-leap one, or Jan 31 with the month switched to February:\n// the day has to be pulled back. Skipping this is the classic bug where the field accepts a date\n// that does not exist and `new Date(2025, 1, 31)` silently reports March 3.\nfunction clampDay(f: Fields): Fields {\n  if (f.day === null) return f\n  const max = daysInMonth(f.year, f.month)\n  return f.day > max ? { ...f, day: max } : f\n}\n\n// One typed digit. `advance` reports that no further digit could extend this segment, which is what\n// makes \"3\" jump straight to the next segment while \"1\" waits for a possible 10/11/12. When the\n// pair would be out of range (\"3\" then \"9\" in a 31-day month) the digit starts a new number instead\n// of being dropped, so no keystroke is ever lost.\nfunction typeDigit(buf: string, digit: string, max: number, width: 2 | 4) {\n  if (width === 4) {\n    const next = buf + digit\n    const done = next.length === 4\n    // The buffer is cleared on the fourth digit, so retyping a year starts a fresh number rather\n    // than rolling digits through the old one (typing 1999 over 2026 would read 0261, 2619, 6199).\n    // That also bounds the buffer at three characters, which is why nothing here has to trim it.\n    return { buf: done ? \"\" : next, value: done ? Number(next) : null, advance: done }\n  }\n  const combined = Number(buf + digit)\n  if (buf !== \"\" && combined >= 1 && combined <= max) {\n    return { buf: \"\", value: combined, advance: true }\n  }\n  const d = Number(digit)\n  if (d === 0) return { buf: \"0\", value: null, advance: false }\n  if (d * 10 > max) return { buf: \"\", value: d, advance: true }\n  return { buf: String(d), value: d, advance: false }\n}\n\n// Arrows wrap month and day (December steps to January) but clamp the year, because a year that\n// wraps from 9999 to 1 is never what the reader meant.\nfunction stepSegment(\n  f: Fields,\n  seg: SegmentName,\n  delta: number,\n  today: Fields\n): Fields {\n  const cur = f[seg]\n  // The first press on an empty segment seeds from today instead of jumping to 1, which is what\n  // makes the arrows usable for dates near now.\n  if (cur === null) return clampDay(setField(f, seg, today[seg]))\n  if (seg === \"year\") {\n    return clampDay(setField(f, \"year\", Math.min(9999, Math.max(1, cur + delta))))\n  }\n  const max = seg === \"month\" ? 12 : daysInMonth(f.year, f.month)\n  let next = cur + delta\n  if (next > max) next = 1\n  else if (next < 1) next = max\n  return clampDay(setField(f, seg, next))\n}\n\nconst pad = (n: number, width: number) => String(n).padStart(width, \"0\")\n\nfunction toISO(f: Fields) {\n  if (f.year === null || f.month === null || f.day === null) return \"\"\n  if (f.year < 1) return \"\"\n  return `${pad(f.year, 4)}-${pad(f.month, 2)}-${pad(f.day, 2)}`\n}\n\n// Accepts a plain \"YYYY-MM-DD\" only. A full ISO timestamp is rejected rather than guessed at,\n// because parsing one would apply a time zone and can land on the wrong calendar day.\nfunction fromISO(iso: string | undefined): Fields {\n  const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec((iso ?? \"\").trim())\n  if (!m) return EMPTY_FIELDS\n  const year = Number(m[1])\n  const month = Number(m[2])\n  const day = Number(m[3])\n  if (month < 1 || month > 12) return EMPTY_FIELDS\n  if (day < 1 || day > daysInMonth(year, month)) return EMPTY_FIELDS\n  return { year, month, day }\n}\n\n// A pasted date arrives either as ISO or written the way the field displays it. The ISO shape is\n// ruled out first, because \"2026-03-14\" dropped into a month/day/year field would otherwise be read\n// positionally as month 20. Returns null when the text is not a date, so the paste is ignored\n// rather than half-applied.\nfunction parsePasted(text: string, order: SegmentName[]): Fields | null {\n  const iso = fromISO(text)\n  if (iso.day !== null) return iso\n  const groups = text.match(/\\d+/g)\n  if (!groups || groups.length !== 3) return null\n  let out: Fields = { year: null, month: null, day: null }\n  order.forEach((seg, i) => {\n    out = setField(out, seg, Number(groups[i]))\n  })\n  if (out.year === null || out.month === null || out.day === null) return null\n  if (out.year < 1 || out.year > 9999) return null\n  if (out.month < 1 || out.month > 12) return null\n  if (out.day < 1 || out.day > daysInMonth(out.year, out.month)) return null\n  return out\n}\n\n// ISO dates are zero-padded to a fixed width, so lexicographic order is chronological order and no\n// Date object (or time zone) needs to be involved.\nfunction outOfRange(iso: string, min: string | undefined, max: string | undefined) {\n  if (iso === \"\") return false\n  const bound = /^\\d{4}-\\d{2}-\\d{2}$/\n  if (min && bound.test(min) && iso < min) return true\n  if (max && bound.test(max) && iso > max) return true\n  return false\n}\n\nexport const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(\n  function DateInput(\n    {\n      value,\n      defaultValue,\n      onChange,\n      min,\n      max,\n      locale,\n      disabled,\n      name,\n      className,\n      \"aria-label\": ariaLabel = \"Date\",\n    },\n    forwardedRef\n  ) {\n    const isControlled = value !== undefined\n    const [fields, setFields] = React.useState<Fields>(() =>\n      fromISO(isControlled ? value : defaultValue)\n    )\n    const [buf, setBuf] = React.useState<Record<SegmentName, string>>(EMPTY_BUF)\n\n    const parts = React.useMemo(() => buildParts(locale), [locale])\n    const order = React.useMemo(\n      () =>\n        parts\n          .filter((p): p is { type: SegmentName } => p.type !== \"literal\")\n          .map((p) => p.type),\n      [parts]\n    )\n\n    const segRefs = React.useRef<Record<SegmentName, HTMLInputElement | null>>({\n      year: null,\n      month: null,\n      day: null,\n    })\n\n    // Forward the first segment in reading order so callers can focus the field from a shortcut.\n    React.useImperativeHandle(\n      forwardedRef,\n      () => segRefs.current[order[0]] as HTMLInputElement,\n      [order]\n    )\n\n    const fieldsRef = React.useRef(fields)\n    fieldsRef.current = fields\n\n    // Re-seed from the prop only when it disagrees with what is on screen. A controlled parent\n    // stores \"\" for an incomplete date, and echoing that back blindly would erase the half-typed\n    // segments on every keystroke.\n    React.useEffect(() => {\n      if (!isControlled) return\n      if ((value ?? \"\") !== toISO(fieldsRef.current)) {\n        setFields(fromISO(value))\n        setBuf(EMPTY_BUF)\n      }\n    }, [isControlled, value])\n\n    const monthName = React.useMemo(() => {\n      try {\n        const fmt = new Intl.DateTimeFormat(locale, {\n          month: \"long\",\n          timeZone: \"UTC\",\n        })\n        return (m: number) => fmt.format(new Date(Date.UTC(2000, m - 1, 1)))\n      } catch {\n        return (m: number) => String(m)\n      }\n    }, [locale])\n\n    const iso = toISO(fields)\n    const invalid = outOfRange(iso, min, max)\n\n    function setBufFor(seg: SegmentName, next: string) {\n      setBuf((b) => ({ ...b, [seg]: next }))\n    }\n\n    function focusIndex(i: number) {\n      const seg = order[Math.max(0, Math.min(i, order.length - 1))]\n      const el = segRefs.current[seg]\n      el?.focus()\n      el?.select()\n    }\n\n    function focusRelative(seg: SegmentName, delta: number) {\n      focusIndex(order.indexOf(seg) + delta)\n    }\n\n    // Single commit path. The segments stay local state even when the value is controlled, because a\n    // half-typed date has no value to flow back through the prop: a parent holding \"\" for an\n    // incomplete date would drop every keystroke but the last, and the field could never be filled\n    // in at all. The effect above is what keeps the parent authoritative — as soon as the segments\n    // read as a complete date that disagrees with the prop, they are pulled back to it.\n    function commit(next: Fields) {\n      setFields(next)\n      onChange?.(toISO(next))\n    }\n\n    function input(seg: SegmentName, digit: string) {\n      const width = seg === \"year\" ? 4 : 2\n      const segMax =\n        seg === \"year\" ? 9999 : seg === \"month\" ? 12 : daysInMonth(fields.year, fields.month)\n      const r = typeDigit(buf[seg], digit, segMax, width)\n      setBufFor(seg, r.buf)\n      commit(clampDay(setField(fields, seg, r.value)))\n      if (r.advance) focusRelative(seg, 1)\n    }\n\n    function handleKeyDown(seg: SegmentName, e: React.KeyboardEvent<HTMLInputElement>) {\n      if (disabled) return\n      switch (e.key) {\n        case \"ArrowUp\":\n        case \"ArrowDown\": {\n          e.preventDefault()\n          // `new Date()` is read here in the handler and never during render: seeding \"today\" while\n          // rendering would disagree between the server and the client and break hydration.\n          const now = new Date()\n          const today: Fields = {\n            year: now.getFullYear(),\n            month: now.getMonth() + 1,\n            day: now.getDate(),\n          }\n          setBufFor(seg, \"\")\n          commit(stepSegment(fields, seg, e.key === \"ArrowUp\" ? 1 : -1, today))\n          break\n        }\n        case \"ArrowLeft\":\n          e.preventDefault()\n          focusRelative(seg, -1)\n          break\n        case \"ArrowRight\":\n          e.preventDefault()\n          focusRelative(seg, 1)\n          break\n        case \"Home\":\n          e.preventDefault()\n          focusIndex(0)\n          break\n        case \"End\":\n          e.preventDefault()\n          focusIndex(order.length - 1)\n          break\n        case \"Backspace\":\n        case \"Delete\": {\n          e.preventDefault()\n          if (buf[seg] !== \"\" || fields[seg] !== null) {\n            setBufFor(seg, \"\")\n            commit(setField(fields, seg, null))\n          } else if (e.key === \"Backspace\") {\n            focusRelative(seg, -1)\n          }\n          break\n        }\n        default:\n          if (/^\\d$/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {\n            e.preventDefault()\n            input(seg, e.key)\n          }\n      }\n    }\n\n    // Virtual keyboards often report key=\"Unidentified\" on keydown, so the typed character is read\n    // from the input event instead. Desktop never reaches here because keydown already consumed it.\n    // Only the first digit is taken: `input` reads the current state from this render, so applying\n    // several in a row would have each one overwrite the last. Multi-character text is a paste, and\n    // onPaste handles it in one commit.\n    function handleBeforeInput(seg: SegmentName, e: React.FormEvent<HTMLInputElement>) {\n      if (disabled) return\n      e.preventDefault()\n      const data = (e.nativeEvent as InputEvent).data\n      if (!data) return\n      const digit = Array.from(data).find((ch) => ch >= \"0\" && ch <= \"9\")\n      if (digit) input(seg, digit)\n    }\n\n    function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {\n      e.preventDefault()\n      if (disabled) return\n      const parsed = parsePasted(e.clipboardData.getData(\"text\"), order)\n      if (!parsed) return\n      setBuf(EMPTY_BUF)\n      commit(parsed)\n      focusIndex(order.length - 1)\n    }\n\n    function display(seg: SegmentName) {\n      if (buf[seg] !== \"\") return buf[seg]\n      const v = fields[seg]\n      if (v === null) return PLACEHOLDERS[seg]\n      return pad(v, seg === \"year\" ? 4 : 2)\n    }\n\n    function valueText(seg: SegmentName) {\n      const v = fields[seg]\n      if (v === null) return \"Empty\"\n      // A bare spinbutton would have the reader hear the month as \"3\"; the name is what identifies it.\n      return seg === \"month\" ? monthName(v) : String(v)\n    }\n\n    return (\n      <div\n        role=\"group\"\n        aria-label={ariaLabel}\n        className={cn(\n          \"inline-flex h-9 items-center rounded-md border border-input bg-transparent px-3 py-1 font-mono text-sm shadow-sm transition-colors\",\n          \"focus-within:outline-none focus-within:ring-1 focus-within:ring-ring\",\n          invalid && \"border-destructive focus-within:ring-destructive\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className\n        )}\n      >\n        {parts.map((p, i) =>\n          p.type === \"literal\" ? (\n            <span key={i} aria-hidden=\"true\" className=\"text-muted-foreground\">\n              {p.value}\n            </span>\n          ) : (\n            <input\n              key={i}\n              ref={(el) => {\n                segRefs.current[p.type] = el\n              }}\n              type=\"text\"\n              inputMode=\"numeric\"\n              autoComplete=\"off\"\n              spellCheck={false}\n              role=\"spinbutton\"\n              disabled={disabled}\n              aria-label={LABELS[p.type]}\n              aria-valuenow={fields[p.type] ?? undefined}\n              aria-valuemin={1}\n              aria-valuemax={\n                p.type === \"year\"\n                  ? 9999\n                  : p.type === \"month\"\n                    ? 12\n                    : daysInMonth(fields.year, fields.month)\n              }\n              aria-valuetext={valueText(p.type)}\n              aria-invalid={invalid || undefined}\n              value={display(p.type)}\n              // Every mutation goes through onKeyDown/onBeforeInput. This keeps React from warning\n              // about a controlled input with no change handler, and anything an exotic IME slips\n              // past both is discarded by the next render.\n              onChange={() => {}}\n              onKeyDown={(e) => handleKeyDown(p.type, e)}\n              onBeforeInput={(e) => handleBeforeInput(p.type, e)}\n              onPaste={handlePaste}\n              onFocus={(e) => e.currentTarget.select()}\n              onBlur={() => setBufFor(p.type, \"\")}\n              className={cn(\n                \"rounded-sm bg-transparent text-center tabular-nums caret-transparent outline-none\",\n                \"focus:bg-accent focus:text-accent-foreground\",\n                \"disabled:cursor-not-allowed\",\n                fields[p.type] === null && \"text-muted-foreground\"\n              )}\n              style={{ width: p.type === \"year\" ? \"4ch\" : \"2ch\" }}\n            />\n          )\n        )}\n        {/* Disabled too, or the field still posts its value from a control the reader was\n            not allowed to touch — a native date input barred from submission does not. */}\n        {name ? (\n          <input type=\"hidden\" name={name} value={iso} disabled={disabled} />\n        ) : null}\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}