{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "date-field",
  "type": "registry:ui",
  "dependencies": [
    "date-fns",
    "lucide-react",
    "react-day-picker"
  ],
  "registryDependencies": [
    "https://ui.flagon.io/r/button.json",
    "https://ui.flagon.io/r/calendar.json",
    "https://ui.flagon.io/r/popover.json"
  ],
  "files": [
    {
      "path": "ui/date-field.tsx",
      "content": "\"use client\";\n\nimport { useId, useMemo, useState } from \"react\";\nimport { Calendar as CalendarIcon } from \"lucide-react\";\nimport type { Matcher } from \"react-day-picker\";\nimport { format as formatDate, isValid, parse } from \"date-fns\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonClasses } from \"./button\";\nimport { Calendar } from \"./calendar\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"./popover\";\n\n// Named-month spellings we accept in addition to pure-numeric input. Numeric\n// input is parsed by hand (below) so we control day/month ordering precisely.\nconst NAMED_FORMATS = [\n  \"MMMM d, yyyy\",\n  \"MMMM d yyyy\",\n  \"MMM d, yyyy\",\n  \"MMM d yyyy\",\n  \"d MMMM yyyy\",\n  \"d MMM yyyy\",\n  \"MMMM d\",\n  \"MMM d\",\n  \"d MMMM\",\n  \"d MMM\",\n];\n\nfunction build(y: number, m: number, d: number): Date | null {\n  // Reject overflow (e.g. 02/30) by round-tripping through the Date fields.\n  const dt = new Date(y, m - 1, d);\n  if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;\n  return dt;\n}\n\nfunction expandYear(raw: string): number {\n  const n = Number(raw);\n  if (raw.length <= 2) return 2000 + n; // 2-digit years land this century\n  return n;\n}\n\n/**\n * Parse a human-typed date in many shapes: ISO (2026-09-17), slashes, hyphens\n * or dots, 2- or 4-digit years, and month names (Sep 17 2026, 17 September).\n * Ambiguous numeric dates like 01/02/2026 follow `dayFirst` (EU vs US); an order\n * that is impossible (13/01) falls back to the other so it still parses.\n * Returns null when nothing sensible matches.\n */\nexport function parseFlexibleDate(input: string, opts?: { dayFirst?: boolean }): Date | null {\n  const text = input.trim();\n  if (!text) return null;\n  const dayFirst = opts?.dayFirst ?? false;\n\n  // Pure numeric with separators: 1-3 parts.\n  const numeric = text.match(/^(\\d{1,4})(?:[\\s/.\\-](\\d{1,4}))?(?:[\\s/.\\-](\\d{1,4}))?$/);\n  if (numeric) {\n    const [, aRaw, bRaw, cRaw] = numeric;\n    const a = Number(aRaw);\n\n    // Three parts.\n    if (bRaw != null && cRaw != null) {\n      // Leading 4-digit part means ISO year-month-day.\n      if (aRaw.length === 4) return build(a, Number(bRaw), Number(cRaw));\n      const year = expandYear(cRaw);\n      const first = a;\n      const second = Number(bRaw);\n      // If one position can't be a month, let the value decide the order.\n      const orderDayFirst = first > 12 ? true : second > 12 ? false : dayFirst;\n      const day = orderDayFirst ? first : second;\n      const month = orderDayFirst ? second : first;\n      return build(year, month, day);\n    }\n\n    // Two parts: day + month in the current year (no year given).\n    if (bRaw != null) {\n      const b = Number(bRaw);\n      const now = new Date();\n      const orderDayFirst = a > 12 ? true : b > 12 ? false : dayFirst;\n      const day = orderDayFirst ? a : b;\n      const month = orderDayFirst ? b : a;\n      return build(now.getFullYear(), month, day);\n    }\n\n    // Single number is too ambiguous to be a date.\n    return null;\n  }\n\n  // Month names and mixed formats.\n  const ref = new Date();\n  for (const f of NAMED_FORMATS) {\n    const dt = parse(text, f, ref);\n    if (isValid(dt)) return dt;\n  }\n  return null;\n}\n\nexport type DateFieldProps = {\n  /** Initial date (uncontrolled). The field then owns the typed text. */\n  defaultValue?: Date | null;\n  /** Fires on every edit with the parsed date, or null when unparseable/empty. */\n  onChange?: (date: Date | null) => void;\n  /** EU day-first ordering for ambiguous numeric dates (default false = US). */\n  dayFirst?: boolean;\n  /** Canonical format written when a day is picked from the calendar. */\n  displayFormat?: string;\n  /** Earliest selectable date (also bounds the calendar). */\n  min?: Date;\n  /** Latest selectable date. */\n  max?: Date;\n  /**\n   * Extra dates to disable, e.g. weekends `{ dayOfWeek: [0, 6] }`, a list of\n   * `Date`s, or any react-day-picker matcher. Merged with `min`/`max`.\n   */\n  disabledDates?: Matcher | Matcher[];\n  id?: string;\n  placeholder?: string;\n  disabled?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n};\n\nexport function DateField({\n  defaultValue = null,\n  onChange,\n  dayFirst = false,\n  displayFormat,\n  min,\n  max,\n  disabledDates,\n  id,\n  placeholder,\n  disabled,\n  className,\n  \"aria-label\": ariaLabel,\n}: DateFieldProps) {\n  // Friendly display for calendar-picked dates (\"August 25, 2026\"); typing still\n  // holds whatever the user enters.\n  const outFormat = displayFormat ?? \"MMMM d, yyyy\";\n  const autoId = useId();\n  const fieldId = id ?? autoId;\n\n  const [text, setText] = useState(() => (defaultValue ? formatDate(defaultValue, outFormat) : \"\"));\n  const [open, setOpen] = useState(false);\n  const [month, setMonth] = useState<Date>(defaultValue ?? new Date());\n\n  const parsed = useMemo(() => parseFlexibleDate(text, { dayFirst }), [text, dayFirst]);\n  const outOfRange =\n    parsed != null &&\n    ((min != null && parsed < startOf(min)) || (max != null && parsed > endOf(max)));\n  const invalid = text.trim() !== \"\" && (parsed == null || outOfRange);\n\n  function emit(value: string) {\n    setText(value);\n    const p = parseFlexibleDate(value, { dayFirst });\n    const ok = p != null && !(min != null && p < startOf(min)) && !(max != null && p > endOf(max));\n    onChange?.(ok ? p : null);\n    if (p) setMonth(p);\n  }\n\n  function pick(date: Date | undefined) {\n    if (!date) return;\n    setText(formatDate(date, outFormat));\n    setMonth(date);\n    onChange?.(date);\n    setOpen(false);\n  }\n\n  return (\n    <div className={cn(\"space-y-1.5\", className)}>\n      <div className=\"relative\">\n        <input\n          id={fieldId}\n          value={text}\n          disabled={disabled}\n          aria-label={ariaLabel}\n          aria-invalid={invalid || undefined}\n          inputMode=\"numeric\"\n          autoComplete=\"off\"\n          placeholder={placeholder ?? \"Select a date\"}\n          onChange={(e) => emit(e.target.value)}\n          className={cn(\n            \"h-10 w-full rounded-md border bg-background pr-10 pl-3 text-sm text-foreground\",\n            \"placeholder:text-muted-foreground\",\n            \"outline-none transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            \"disabled:cursor-not-allowed disabled:opacity-50\",\n            invalid ? \"border-destructive\" : \"border-input\",\n          )}\n        />\n        <Popover open={open} onOpenChange={setOpen}>\n          <PopoverTrigger\n            type=\"button\"\n            disabled={disabled}\n            aria-label=\"Open calendar\"\n            className={cn(\n              buttonClasses({ variant: \"ghost\", size: \"icon\" }),\n              \"absolute top-1/2 right-1 size-8 -translate-y-1/2\",\n            )}\n          >\n            <CalendarIcon className=\"size-4\" />\n          </PopoverTrigger>\n          <PopoverContent align=\"end\" className=\"w-auto p-0\">\n            <Calendar\n              mode=\"single\"\n              selected={parsed ?? undefined}\n              month={month}\n              onMonthChange={setMonth}\n              onSelect={pick}\n              captionLayout=\"dropdown\"\n              startMonth={min ?? yearsFromNow(-5)}\n              endMonth={max ?? yearsFromNow(10)}\n              disabled={rangeMatcher(min, max, disabledDates)}\n            />\n          </PopoverContent>\n        </Popover>\n      </div>\n      {invalid ? (\n        <p className=\"text-xs text-destructive\">\n          {outOfRange ? \"That date is out of range.\" : \"Unrecognized date.\"}\n        </p>\n      ) : parsed ? (\n        <p className=\"text-xs text-muted-foreground\">{formatDate(parsed, \"EEEE, MMMM d, yyyy\")}</p>\n      ) : null}\n    </div>\n  );\n}\n\n// Default calendar bounds so the year dropdown has a useful range even when no\n// explicit min/max is given. Narrowed by min/max when those are provided.\nfunction yearsFromNow(delta: number): Date {\n  const d = new Date();\n  return new Date(d.getFullYear() + delta, delta < 0 ? 0 : 11, delta < 0 ? 1 : 31);\n}\n\nfunction startOf(d: Date): Date {\n  return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);\n}\nfunction endOf(d: Date): Date {\n  return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);\n}\nfunction rangeMatcher(min?: Date, max?: Date, extra?: Matcher | Matcher[]): Matcher[] | undefined {\n  const m: Matcher[] = [];\n  if (min) m.push({ before: startOf(min) });\n  if (max) m.push({ after: endOf(max) });\n  if (Array.isArray(extra)) m.push(...extra);\n  else if (extra) m.push(extra);\n  return m.length ? m : undefined;\n}\n",
      "type": "registry:ui",
      "target": "components/ui/date-field.tsx"
    }
  ]
}
