{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "p-calendar-25",
  "description": "Calendar with 24-hour autocomplete time input",
  "dependencies": [
    "date-fns",
    "lucide-react"
  ],
  "registryDependencies": [
    "@coss/calendar",
    "@coss/autocomplete",
    "@coss/field"
  ],
  "files": [
    {
      "path": "registry/default/particles/p-calendar-25.tsx",
      "content": "\"use client\";\n\nimport { format } from \"date-fns\";\nimport { ClockIcon } from \"lucide-react\";\nimport { useState } from \"react\";\nimport {\n  Autocomplete,\n  AutocompleteInput,\n  AutocompleteItem,\n  AutocompleteList,\n  AutocompletePopup,\n} from \"@/registry/default/ui/autocomplete\";\nimport { Calendar } from \"@/registry/default/ui/calendar\";\nimport { Field, FieldLabel } from \"@/registry/default/ui/field\";\n\nconst times = Array.from({ length: 96 }, (_, i) => {\n  const hours = String(Math.floor(i / 4)).padStart(2, \"0\");\n  const minutes = String((i % 4) * 15).padStart(2, \"0\");\n  return `${hours}:${minutes}`;\n});\n\nfunction parseTime(value: string): string | null {\n  const trimmed = value.trim();\n  if (!trimmed) {\n    return null;\n  }\n\n  const colonMatch = /^(\\d{1,2}):(\\d{1,2})$/.exec(trimmed);\n  if (colonMatch) {\n    const hours = Number(colonMatch[1]);\n    const minutes = Number(colonMatch[2]);\n    if (hours > 23 || minutes > 59) {\n      return null;\n    }\n    return `${String(hours).padStart(2, \"0\")}:${String(minutes).padStart(2, \"0\")}`;\n  }\n\n  const digits = trimmed.replace(/\\D/g, \"\");\n  if (digits.length === 0 || digits.length > 4) {\n    return null;\n  }\n\n  let hours: number;\n  let minutes: number;\n\n  if (digits.length <= 2) {\n    hours = Number(digits);\n    minutes = 0;\n  } else if (digits.length === 3) {\n    // HMM → H:MM (215 → 02:15)\n    hours = Number(digits[0]);\n    minutes = Number(digits.slice(1));\n  } else {\n    // HHMM → HH:MM (2150 → 21:50)\n    hours = Number(digits.slice(0, 2));\n    minutes = Number(digits.slice(2));\n  }\n\n  if (hours > 23 || minutes > 59) {\n    return null;\n  }\n\n  return `${String(hours).padStart(2, \"0\")}:${String(minutes).padStart(2, \"0\")}`;\n}\n\n/**\n * Live input formatting from a digit buffer:\n * - ≤2 digits: as-is\n * - 3 digits: H:MM (unpadded hour so a 4th digit can still become HHMM)\n * - 4 digits: HH:mm\n */\nfunction formatTimeInput(value: string) {\n  const digits = value.replace(/\\D/g, \"\").slice(0, 4);\n\n  if (digits.length <= 2) {\n    return digits;\n  }\n\n  if (digits.length === 3) {\n    const minutes = Number(digits.slice(1));\n    if (minutes <= 59) {\n      return `${digits[0]}:${digits.slice(1)}`;\n    }\n    return digits;\n  }\n\n  return parseTime(digits) ?? digits;\n}\n\nfunction filterTime(item: string, query: string) {\n  const trimmed = query.trim().toLowerCase();\n  if (!trimmed) {\n    return true;\n  }\n\n  if (item.toLowerCase().startsWith(trimmed)) {\n    return true;\n  }\n\n  const itemDigits = item.replace(/\\D/g, \"\");\n  const queryDigits = trimmed.replace(/\\D/g, \"\");\n  if (!queryDigits) {\n    return false;\n  }\n\n  const itemHour = Number(itemDigits.slice(0, 2));\n  const itemMinutes = itemDigits.slice(2);\n  // Padded \"0615\" and unpadded \"615\" so \"61\" matches 06:15 while typing toward 615\n  const itemDigitsUnpadded = `${itemHour}${itemMinutes}`;\n\n  if (\n    itemDigits.startsWith(queryDigits) ||\n    itemDigitsUnpadded.startsWith(queryDigits)\n  ) {\n    return true;\n  }\n\n  if (queryDigits.length >= 3) {\n    const normalized = parseTime(queryDigits);\n    return normalized === item;\n  }\n\n  const queryHour = Number(queryDigits);\n\n  if (trimmed.includes(\":\")) {\n    const minuteQuery = trimmed.split(\":\")[1]?.replace(/\\D/g, \"\") ?? \"\";\n    return (\n      itemHour === queryHour &&\n      (!minuteQuery || itemMinutes.startsWith(minuteQuery))\n    );\n  }\n\n  if (queryDigits.length === 1) {\n    return itemHour === queryHour || String(itemHour).startsWith(queryDigits);\n  }\n\n  return queryHour <= 23 && itemHour === queryHour;\n}\n\nfunction applyTime(date: Date, time: string) {\n  const parsed = parseTime(time);\n  if (!parsed) {\n    return date;\n  }\n  const hours = Number(parsed.slice(0, 2));\n  const minutes = Number(parsed.slice(3, 5));\n  const next = new Date(date);\n  next.setHours(hours, minutes, 0, 0);\n  return next;\n}\n\nexport default function Particle() {\n  const [date, setDate] = useState<Date | undefined>(() => {\n    const initial = new Date();\n    initial.setHours(12, 0, 0, 0);\n    return initial;\n  });\n  const [month, setMonth] = useState<Date>(() => new Date());\n  const [time, setTime] = useState(\"12:00\");\n  const [isEditing, setIsEditing] = useState(false);\n\n  const handleDaySelect = (selectedDate: Date | undefined) => {\n    if (!selectedDate) {\n      setDate(undefined);\n      return;\n    }\n    const next = applyTime(selectedDate, time);\n    setDate(next);\n    setMonth(next);\n  };\n\n  const handleTimeChange = (value: string) => {\n    setIsEditing(true);\n    const next = formatTimeInput(value);\n    setTime(next);\n\n    // List picks and completed HH:mm only — avoid committing mid-typing (\"2:1\" → 02:01)\n    if (date && /^\\d{2}:\\d{2}$/.test(next)) {\n      setDate(applyTime(date, next));\n    }\n  };\n\n  const handleTimeBlur = () => {\n    const normalized = parseTime(time);\n    if (normalized) {\n      setTime(normalized);\n      if (date) {\n        setDate(applyTime(date, normalized));\n      }\n    } else if (date) {\n      setTime(format(date, \"HH:mm\"));\n    }\n    setIsEditing(false);\n  };\n\n  const filterQuery = isEditing ? time : \"\";\n  const matchingTimes = times.filter((item) => filterTime(item, filterQuery));\n\n  return (\n    <div className=\"flex w-fit flex-col gap-2\">\n      <Calendar\n        mode=\"single\"\n        month={month}\n        onMonthChange={setMonth}\n        onSelect={handleDaySelect}\n        selected={date}\n      />\n      <Field className=\"w-0 min-w-full flex-row items-center gap-3\">\n        <FieldLabel className=\"whitespace-nowrap text-xs\">\n          Enter time\n        </FieldLabel>\n        <Autocomplete\n          autoHighlight\n          filter={(item, query) => filterTime(item, isEditing ? query : \"\")}\n          items={times}\n          onValueChange={handleTimeChange}\n          openOnInputClick\n          value={time}\n        >\n          <AutocompleteInput\n            aria-label=\"Enter time\"\n            inputMode=\"numeric\"\n            maxLength={5}\n            onBlur={handleTimeBlur}\n            onFocus={(event) => {\n              setIsEditing(false);\n              event.currentTarget.select();\n            }}\n            placeholder=\"HH:mm\"\n            startAddon={<ClockIcon aria-hidden=\"true\" />}\n          />\n          <AutocompletePopup\n            className={matchingTimes.length === 0 ? \"hidden\" : undefined}\n          >\n            <AutocompleteList>\n              {(item: string) => (\n                <AutocompleteItem key={item} value={item}>\n                  {item}\n                </AutocompleteItem>\n              )}\n            </AutocompleteList>\n          </AutocompletePopup>\n        </Autocomplete>\n      </Field>\n    </div>\n  );\n}\n",
      "type": "registry:block"
    }
  ],
  "categories": [
    "calendar",
    "autocomplete",
    "field",
    "time"
  ],
  "type": "registry:block"
}