{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "date-field",
  "type": "registry:ui",
  "title": "Date Field",
  "description": "Typed date in the locale's order, with stepping digits and plain-word errors.",
  "categories": [
    "dates"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/date-field.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Seg = \"day\" | \"month\" | \"year\";\ntype Parts = Record<Seg, string>;\ntype Layout = ({ kind: \"seg\"; seg: Seg } | { kind: \"sep\"; text: string })[];\n\nconst EMPTY: Parts = { day: \"\", month: \"\", year: \"\" };\nconst pad = (n: number, w = 2) => String(n).padStart(w, \"0\");\nconst iso = (d: Date) => `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;\nconst daysIn = (y: number, m: number) => new Date(y, m, 0).getDate();\nconst fromDate = (d: Date | null): Parts => (d ? { day: String(d.getDate()), month: String(d.getMonth() + 1), year: String(d.getFullYear()) } : EMPTY);\nconst cmp = (a: Date, b: Date) => iso(a).localeCompare(iso(b));\n\n/** The date the parts spell, or null when a part is missing or the day doesn't exist in that month. */\nfunction toDate(p: Parts): Date | null {\n  if (!p.day || !p.month || p.year.length !== 4) return null;\n  const y = +p.year;\n  const m = +p.month;\n  const d = +p.day;\n  if (m < 1 || m > 12 || d < 1 || d > daysIn(y, m)) return null;\n  const date = new Date(y, m - 1, d);\n  date.setFullYear(y);\n  return date;\n}\n\nconst LIMITS: Record<Seg, [number, number]> = { day: [1, 31], month: [1, 12], year: [1, 9999] };\n\nfunction subscribeLanguage(notify: () => void) {\n  window.addEventListener(\"languagechange\", notify);\n  return () => window.removeEventListener(\"languagechange\", notify);\n}\n\nexport type DateFieldProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  value?: Date | null;\n  defaultValue?: Date | null;\n  /** Called with a complete, valid date, or null when the field is emptied or can't form one. */\n  onValueChange?: (date: Date | null) => void;\n  label?: React.ReactNode;\n  description?: React.ReactNode;\n  min?: Date;\n  max?: Date;\n  /** BCP 47 tag. Decides the segment order, separators and names. Defaults to the browser's language. */\n  locale?: string;\n  /** Placeholder letters per segment. */\n  placeholders?: Partial<Record<Seg, string>>;\n  /** A quiet weekday after a complete date, so \"is the 14th a Friday?\" is answered in place. */\n  showWeekday?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  required?: boolean;\n  invalid?: boolean;\n  errorMessage?: React.ReactNode;\n  /** Submits the value as YYYY-MM-DD under this name. */\n  name?: string;\n  size?: \"sm\" | \"md\";\n};\n\nexport function DateField({\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  label,\n  description,\n  min,\n  max,\n  locale: localeProp,\n  placeholders,\n  showWeekday = true,\n  disabled = false,\n  readOnly = false,\n  required,\n  invalid: invalidProp = false,\n  errorMessage,\n  name,\n  size = \"md\",\n  className,\n  id: idProp,\n  ...rest\n}: DateFieldProps) {\n  const uid = useId();\n  const labelId = `${uid}-label`;\n  const messageId = `${uid}-message`;\n  const reduce = useReducedMotion();\n  const browser = useSyncExternalStore(subscribeLanguage, () => navigator.language, () => \"en-US\");\n  const locale = localeProp ?? browser;\n\n  const [value, setValue] = useControllableState<Date | null>({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [parts, setParts] = useState<Parts>(() => fromDate(value));\n  // Keep the segments in step with a value set from outside, without wiping a half-typed date.\n  const vKey = value ? iso(value) : \"\";\n  const [seen, setSeen] = useState(vKey);\n  if (seen !== vKey) {\n    setSeen(vKey);\n    const shown = toDate(parts);\n    if ((shown ? iso(shown) : \"\") !== vKey) setParts(fromDate(value));\n  }\n\n  // Digits typed into the focused segment since it took focus.\n  const [entry, setEntry] = useState<{ seg: Seg; digits: string } | null>(null);\n  // The direction of the last arrow step, for the digit tick. 0 while typing.\n  const [spin, setSpin] = useState<{ seg: Seg; dir: number; n: number } | null>(null);\n  const [touched, setTouched] = useState(false);\n  const [focusedSeg, setFocusedSeg] = useState<Seg | null>(null);\n\n  const segRefs = useRef<Partial<Record<Seg, HTMLSpanElement | null>>>({});\n  const groupRef = useRef<HTMLDivElement>(null);\n\n  const { layout, names, monthName, weekday, full } = useMemo(() => {\n    const parts = new Intl.DateTimeFormat(locale, { day: \"2-digit\", month: \"2-digit\", year: \"numeric\" }).formatToParts(new Date(2026, 10, 22));\n    const layout: Layout = [];\n    for (const p of parts) {\n      if (p.type === \"day\" || p.type === \"month\" || p.type === \"year\") layout.push({ kind: \"seg\", seg: p.type });\n      else if (p.type === \"literal\" && layout.length && layout.length < 5) layout.push({ kind: \"sep\", text: p.value.trim() || p.value });\n    }\n    let names: Record<Seg, string> = { day: \"Day\", month: \"Month\", year: \"Year\" };\n    try {\n      const dn = new Intl.DisplayNames(locale, { type: \"dateTimeField\" });\n      names = { day: dn.of(\"day\") ?? \"Day\", month: dn.of(\"month\") ?? \"Month\", year: dn.of(\"year\") ?? \"Year\" };\n    } catch {}\n    return {\n      layout,\n      names,\n      monthName: new Intl.DateTimeFormat(locale, { month: \"long\" }),\n      weekday: new Intl.DateTimeFormat(locale, { weekday: \"short\" }),\n      full: new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", year: \"numeric\" }),\n    };\n  }, [locale]);\n  const order = layout.flatMap((l) => (l.kind === \"seg\" ? [l.seg] : []));\n  const ph: Record<Seg, string> = { day: \"dd\", month: \"mm\", year: \"yyyy\", ...placeholders };\n\n  const date = toDate(parts);\n  const filled = order.filter((s) => parts[s]).length;\n  const refusal = date\n    ? min && cmp(date, min) < 0\n      ? `Pick ${full.format(min)} or later`\n      : max && cmp(date, max) > 0\n        ? `Pick ${full.format(max)} or earlier`\n        : null\n    : null;\n  // A day the month doesn't have, said in words: \"February 2027 has 28 days\".\n  const noSuchDay =\n    !date && parts.day && parts.month && parts.year.length === 4 && +parts.month >= 1 && +parts.month <= 12\n      ? `${new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }).format(new Date(+parts.year, +parts.month - 1, 1))} has ${daysIn(+parts.year, +parts.month)} days`\n      : null;\n  const incomplete = touched && filled > 0 && filled < 3 && !focusedSeg ? \"Enter a full date\" : null;\n  const problem = refusal ?? noSuchDay ?? incomplete;\n  const invalid = invalidProp || !!problem;\n  const message = invalidProp && errorMessage ? { kind: \"error\", node: errorMessage } : problem ? { kind: \"error\", node: problem } : description ? { kind: \"description\", node: description } : null;\n\n  function write(next: Parts) {\n    setParts(next);\n    const d = toDate(next);\n    const ok = d && !(min && cmp(d, min) < 0) && !(max && cmp(d, max) > 0) ? d : null;\n    const key = ok ? iso(ok) : \"\";\n    if (key !== vKey) {\n      setSeen(key);\n      setValue(ok);\n    }\n  }\n\n  const focusSeg = (seg: Seg | undefined) => seg && segRefs.current[seg]?.focus();\n  const neighbor = (seg: Seg, step: number) => order[order.indexOf(seg) + step];\n\n  function typeDigit(seg: Seg, d: string) {\n    const prev = entry?.seg === seg ? entry.digits : \"\";\n    let digits = prev + d;\n    const [lo, hi] = LIMITS[seg];\n    let done = false;\n    if (seg === \"year\") {\n      done = digits.length >= 4;\n      digits = digits.slice(-4);\n    } else {\n      // A second digit that would overflow starts the segment again instead.\n      if (digits.length === 2 && (+digits > hi || +digits < lo)) digits = d;\n      const max1 = seg === \"day\" ? 3 : 1;\n      done = digits.length === 2 || +digits > max1;\n    }\n    setSpin(null);\n    setEntry(done ? null : { seg, digits });\n    write({ ...parts, [seg]: digits });\n    if (done) focusSeg(neighbor(seg, 1));\n  }\n\n  function step(seg: Seg, dir: number, big = false) {\n    const now = new Date();\n    const current = parts[seg] ? +parts[seg] : null;\n    const [lo] = LIMITS[seg];\n    let hi = LIMITS[seg][1];\n    if (seg === \"day\" && parts.month) hi = daysIn(parts.year.length === 4 ? +parts.year : 2024, +parts.month);\n    let next: number;\n    if (current === null) next = seg === \"day\" ? now.getDate() : seg === \"month\" ? now.getMonth() + 1 : now.getFullYear();\n    else if (seg === \"year\") next = Math.min(9999, Math.max(1, current + dir * (big ? 10 : 1)));\n    else {\n      const span = hi - lo + 1;\n      next = ((((current + dir * (big ? (seg === \"day\" ? 7 : 3) : 1) - lo) % span) + span) % span) + lo;\n    }\n    setEntry(null);\n    setSpin((s) => ({ seg, dir: current === null ? 0 : dir, n: (s?.n ?? 0) + 1 }));\n    write({ ...parts, [seg]: String(next) });\n  }\n\n  function onKeyDown(seg: Seg, e: React.KeyboardEvent<HTMLSpanElement>) {\n    if (e.metaKey || e.ctrlKey || e.altKey) return;\n    if (e.key === \"Tab\" || e.key === \"Escape\") return;\n    e.preventDefault();\n    if (readOnly || disabled) {\n      if (e.key === \"ArrowLeft\") focusSeg(neighbor(seg, -1));\n      if (e.key === \"ArrowRight\") focusSeg(neighbor(seg, 1));\n      return;\n    }\n    if (/^\\d$/.test(e.key)) typeDigit(seg, e.key);\n    else if (e.key === \"ArrowUp\") step(seg, 1, e.shiftKey);\n    else if (e.key === \"ArrowDown\") step(seg, -1, e.shiftKey);\n    else if (e.key === \"PageUp\") step(seg, 1, true);\n    else if (e.key === \"PageDown\") step(seg, -1, true);\n    else if (e.key === \"Home\" && seg !== \"year\") write({ ...parts, [seg]: String(LIMITS[seg][0]) });\n    else if (e.key === \"End\" && seg !== \"year\") write({ ...parts, [seg]: String(seg === \"day\" && parts.month ? daysIn(+parts.year || 2024, +parts.month) : LIMITS[seg][1]) });\n    else if (e.key === \"ArrowLeft\") focusSeg(neighbor(seg, -1));\n    else if (e.key === \"ArrowRight\") focusSeg(neighbor(seg, 1));\n    else if (e.key === \"Backspace\" || e.key === \"Delete\") {\n      if (!parts[seg]) return void (e.key === \"Backspace\" && focusSeg(neighbor(seg, -1)));\n      const digits = e.key === \"Delete\" ? \"\" : parts[seg].slice(0, -1);\n      setEntry(digits ? { seg, digits } : null);\n      setSpin(null);\n      write({ ...parts, [seg]: digits });\n    } else if (/^[\\s/.\\-,]$/.test(e.key) && parts[seg]) focusSeg(neighbor(seg, 1));\n  }\n\n  // Phone keyboards often skip keydown for digits and send beforeinput instead.\n  const handlers = useRef({ typeDigit, readOnly, disabled });\n  useEffect(() => {\n    handlers.current = { typeDigit, readOnly, disabled };\n  });\n  useEffect(() => {\n    const group = groupRef.current;\n    if (!group) return;\n    const onBeforeInput = (e: InputEvent) => {\n      const seg = ((e.target as HTMLElement).closest?.(\"[data-segment]\") as HTMLElement | null)?.dataset.segment as Seg | undefined;\n      e.preventDefault();\n      if (!seg || handlers.current.readOnly || handlers.current.disabled) return;\n      for (const ch of e.data ?? \"\") if (/\\d/.test(ch)) handlers.current.typeDigit(seg, ch);\n    };\n    group.addEventListener(\"beforeinput\", onBeforeInput);\n    return () => group.removeEventListener(\"beforeinput\", onBeforeInput);\n  }, []);\n\n  function onPaste(e: React.ClipboardEvent) {\n    e.preventDefault();\n    if (readOnly || disabled) return;\n    const text = e.clipboardData.getData(\"text\").trim();\n    const m = /^(\\d{4})-(\\d{1,2})-(\\d{1,2})/.exec(text);\n    if (m) return write({ year: m[1], month: String(+m[2]), day: String(+m[3]) });\n    const nums = text.split(/[^\\d]+/).filter(Boolean);\n    if (nums.length === 3) {\n      const next = { ...EMPTY };\n      order.forEach((s, i) => (next[s] = s === \"year\" && nums[i].length === 2 ? `20${nums[i]}` : String(+nums[i])));\n      write(next);\n    }\n  }\n\n  function display(seg: Seg) {\n    const v = parts[seg];\n    if (!v) return { typed: \"\", rest: ph[seg] };\n    if (seg === \"year\") {\n      // A year still being typed shows what's left to type as placeholder.\n      const typing = entry?.seg === \"year\" && v.length < 4;\n      return typing ? { typed: v, rest: ph.year.slice(v.length) } : { typed: pad(+v, 4), rest: \"\" };\n    }\n    return { typed: pad(+v), rest: \"\" };\n  }\n\n  const sm = size === \"sm\";\n\n  return (\n    <div data-invalid={invalid || undefined} data-disabled={disabled || undefined} className={cn(\"flex w-full min-w-0 flex-col gap-1.5\", className)} {...rest}>\n      {label && (\n        <span\n          id={labelId}\n          onClick={() => focusSeg(order.find((s) => !parts[s]) ?? order[0])}\n          className={cn(\"w-fit text-[12.5px] font-medium leading-4\", disabled ? \"text-fg-3\" : \"text-fg\")}\n        >\n          {label}\n          {required && <span aria-hidden className=\"text-fg-3\"> *</span>}\n        </span>\n      )}\n\n      <div\n        ref={groupRef}\n        id={idProp}\n        role=\"group\"\n        aria-labelledby={label ? labelId : undefined}\n        aria-describedby={message ? messageId : undefined}\n        aria-invalid={invalid || undefined}\n        aria-disabled={disabled || undefined}\n        data-invalid={invalid || undefined}\n        data-disabled={disabled || undefined}\n        data-readonly={readOnly || undefined}\n        onMouseDown={(e) => {\n          if ((e.target as HTMLElement).closest(\"[data-segment]\") || disabled) return;\n          e.preventDefault();\n          focusSeg(order.find((s) => !parts[s]) ?? order[order.length - 1]);\n        }}\n        onPaste={onPaste}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node)) {\n            setFocusedSeg(null);\n            setTouched(true);\n            // Two typed digits of year mean this century.\n            if (parts.year.length > 0 && parts.year.length <= 2) write({ ...parts, year: String(2000 + +parts.year) });\n          }\n        }}\n        className={cn(\n          \"relative flex w-full min-w-0 cursor-text items-center border border-line-2 bg-raised text-fg shadow-[var(--shadow)]\",\n          \"transition-[border-color,box-shadow,background-color] duration-150 ease-out hover:border-fg-4\",\n          \"focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3\",\n          \"data-invalid:border-danger/70 data-invalid:hover:border-danger data-invalid:focus-within:border-danger data-invalid:focus-within:ring-danger/15\",\n          \"data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:shadow-none data-disabled:hover:border-line-2\",\n          \"data-readonly:bg-frame data-readonly:shadow-none data-readonly:hover:border-line-2\",\n          // Mono, so dd, 07, yyyy and 2026 are all the same width: filling a segment never nudges the next one.\n          \"font-mono tracking-[-0.01em]\",\n          sm ? \"h-7 rounded-md px-1.5 text-base sm:text-[12px]\" : \"h-8 rounded-lg px-2 text-base sm:text-[12.5px]\",\n        )}\n      >\n        {layout.map((part, i) => {\n          if (part.kind === \"sep\") {\n            return (\n              <span key={i} aria-hidden className={cn(\"px-px transition-colors duration-150\", filled ? \"text-fg-3\" : \"text-fg-4\")}>\n                {part.text}\n              </span>\n            );\n          }\n          const { seg } = part;\n          const v = parts[seg];\n          const shown = display(seg);\n          const ticking = !reduce && spin?.seg === seg && spin.dir !== 0;\n          return (\n            <span\n              key={seg}\n              ref={(el) => {\n                segRefs.current[seg] = el;\n              }}\n              data-segment={seg}\n              data-placeholder={!v || undefined}\n              role=\"spinbutton\"\n              tabIndex={disabled ? -1 : 0}\n              contentEditable={!disabled}\n              suppressContentEditableWarning\n              inputMode=\"numeric\"\n              enterKeyHint=\"next\"\n              spellCheck={false}\n              autoCorrect=\"off\"\n              aria-label={names[seg]}\n              aria-valuenow={v ? +v : undefined}\n              aria-valuemin={LIMITS[seg][0]}\n              aria-valuemax={seg === \"day\" && parts.month ? daysIn(+parts.year || 2024, +parts.month) : LIMITS[seg][1]}\n              aria-valuetext={!v ? \"Empty\" : seg === \"month\" && +v >= 1 && +v <= 12 ? `${+v}, ${monthName.format(new Date(2026, +v - 1, 1))}` : v}\n              aria-readonly={readOnly || undefined}\n              aria-required={required || undefined}\n              onKeyDown={(e) => onKeyDown(seg, e)}\n              onFocus={() => {\n                setFocusedSeg(seg);\n                setEntry(null);\n              }}\n              className={cn(\n                \"relative inline-grid overflow-hidden rounded-[4px] px-0.5 text-center tabular leading-6 caret-transparent outline-none\",\n                \"transition-[background-color,color] duration-100 [&::selection]:bg-transparent [&_*::selection]:bg-transparent\",\n                \"focus:bg-fg focus:text-frame\",\n                \"data-placeholder:text-fg-4 data-placeholder:focus:text-frame/70\",\n              )}\n            >\n              {/* Sized by whichever is wider, the placeholder or the digits, so filling it never shifts the row. */}\n              <span aria-hidden className=\"invisible col-start-1 row-start-1\">{ph[seg]}</span>\n              <span aria-hidden className=\"invisible col-start-1 row-start-1\">{seg === \"year\" ? \"0000\" : \"00\"}</span>\n              {/* The digits tick up or down with the arrow that changed them; typing replaces them in place. */}\n              <motion.span\n                key={`${shown.typed}${shown.rest}-${ticking ? spin!.n : 0}`}\n                initial={ticking ? { y: spin!.dir * -7, opacity: 0 } : false}\n                animate={{ y: 0, opacity: 1 }}\n                transition={{ duration: 0.14, ease: ease.out }}\n                aria-hidden\n                className=\"col-start-1 row-start-1 whitespace-pre\"\n              >\n                {shown.typed}\n                {shown.rest && <span className={cn(shown.typed && \"text-fg-4 [[data-segment]:focus_&]:text-frame/60\")}>{shown.rest}</span>}\n              </motion.span>\n            </span>\n          );\n        })}\n\n        <AnimatePresence initial={false}>\n          {showWeekday && date && !refusal && (\n            <motion.span\n              key={iso(date)}\n              aria-hidden\n              initial={reduce ? { opacity: 0 } : { opacity: 0, x: -4 }}\n              animate={{ opacity: 1, x: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              className=\"ml-auto pl-2 font-sans text-[12px] text-fg-3\"\n            >\n              {weekday.format(date)}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div\n        id={messageId}\n        aria-live=\"polite\"\n        className={cn(\"relative text-[12px] leading-4\", (message || !(readOnly || disabled)) && \"min-h-4\")}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {message && (\n            <motion.p\n              key={`${message.kind}:${typeof message.node === \"string\" ? message.node : \"\"}`}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              className={cn(\"flex items-start gap-1.5\", message.kind === \"error\" ? \"text-danger\" : \"text-fg-3\")}\n            >\n              {message.kind === \"error\" && (\n                <svg width=\"12\" height=\"12\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" aria-hidden className=\"mt-0.5 shrink-0\">\n                  <circle cx=\"8\" cy=\"8\" r=\"6\" />\n                  <path d=\"M8 5v3.5\" />\n                  <circle cx=\"8\" cy=\"11\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n                </svg>\n              )}\n              <span className=\"min-w-0\">{message.node}</span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {name && <input type=\"hidden\" name={name} value={date && !refusal ? iso(date) : \"\"} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/date-field"
}
