{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "availability-grid",
  "type": "registry:ui",
  "title": "Availability Grid",
  "description": "A weekly availability picker: drag to paint or erase time slots, merged ranges, per-day summaries and spreadsheet keys.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/availability-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ---------------------------------- types ---------------------------------- */\n\nexport type Weekday = \"mon\" | \"tue\" | \"wed\" | \"thu\" | \"fri\" | \"sat\" | \"sun\";\n\n/** A half-open time range on one day, as 24-hour \"HH:mm\" strings (end is exclusive). */\nexport interface AvailabilityRange {\n  start: string;\n  end: string;\n}\n\n/** Available ranges per weekday, merged and sorted. Days without ranges may be omitted. */\nexport type AvailabilityValue = Partial<Record<Weekday, AvailabilityRange[]>>;\n\nexport interface AvailabilityGridProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Controlled availability. */\n  value?: AvailabilityValue;\n  /** Initial availability when uncontrolled. */\n  defaultValue?: AvailabilityValue;\n  /** Called with merged ranges per day after every paint, toggle or action. */\n  onChange?: (value: AvailabilityValue) => void;\n  /** Columns, in order. */\n  days?: Weekday[];\n  /** First slot start, \"HH:mm\". */\n  startTime?: string;\n  /** Last slot end, \"HH:mm\". */\n  endTime?: string;\n  /** Slot length in minutes (a divisor of 60, e.g. 15, 30, 60). */\n  slotMinutes?: number;\n  /** Time zone shown in the toolbar, e.g. \"Kuala Lumpur · GMT+8\". Display only. */\n  timeZoneLabel?: string;\n  /** BCP 47 locale for weekday names. */\n  locale?: string;\n  /** Clock used for time labels. */\n  hourCycle?: \"12\" | \"24\";\n  /** Accessible name and visible title. */\n  label?: string;\n  /** Per-day summary under the grid. */\n  showSummary?: boolean;\n  /** \"Copy Monday to weekdays\" and \"Clear\" actions. */\n  showActions?: boolean;\n  /** Row height in px. */\n  rowHeight?: number;\n  /** Painted ranges, focus ring and drag highlight colour. */\n  accent?: string;\n}\n\n/* --------------------------------- helpers --------------------------------- */\n\nconst ALL_DAYS: Weekday[] = [\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"];\nconst WEEKDAYS: Weekday[] = [\"mon\", \"tue\", \"wed\", \"thu\", \"fri\"];\n/** 2024-01-01 was a Monday — used only to ask Intl for weekday names. */\nconst DAY_INDEX: Record<Weekday, number> = { mon: 0, tue: 1, wed: 2, thu: 3, fri: 4, sat: 5, sun: 6 };\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\nfunction toMinutes(time: string) {\n  const match = /^(\\d{1,2}):(\\d{2})$/.exec(time);\n  return match ? Math.min(24 * 60, Number(match[1]) * 60 + Number(match[2])) : 0;\n}\n\nconst toTime = (minutes: number) => `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;\n\nfunction formatTime(minutes: number, hourCycle: \"12\" | \"24\") {\n  const h = Math.floor(minutes / 60) % 24;\n  const m = pad(minutes % 60);\n  if (hourCycle === \"24\") return `${h}:${m}`;\n  return `${h % 12 || 12}:${m} ${h < 12 ? \"AM\" : \"PM\"}`;\n}\n\nfunction weekdayNames(locale: string, style: \"short\" | \"long\") {\n  let fmt: Intl.DateTimeFormat;\n  try {\n    fmt = new Intl.DateTimeFormat(locale, { weekday: style, timeZone: \"UTC\" });\n  } catch {\n    fmt = new Intl.DateTimeFormat(\"en\", { weekday: style, timeZone: \"UTC\" });\n  }\n  return (day: Weekday) => fmt.format(new Date(Date.UTC(2024, 0, 1 + DAY_INDEX[day]))).replace(/[  ]/g, \" \");\n}\n\ntype Slots = Record<Weekday, boolean[]>;\n\ninterface SlotWindow {\n  start: number;\n  step: number;\n  count: number;\n}\n\nfunction toSlots(value: AvailabilityValue, days: Weekday[], w: SlotWindow): Slots {\n  const slots = {} as Slots;\n  for (const day of days) {\n    const row = new Array<boolean>(w.count).fill(false);\n    for (const range of value[day] ?? []) {\n      const a = toMinutes(range.start);\n      const b = toMinutes(range.end);\n      for (let i = 0; i < w.count; i++) {\n        const s = w.start + i * w.step;\n        if (s >= a && s + w.step <= b) row[i] = true;\n      }\n    }\n    slots[day] = row;\n  }\n  return slots;\n}\n\nfunction rangesOf(row: boolean[], w: SlotWindow): AvailabilityRange[] {\n  const out: AvailabilityRange[] = [];\n  let i = 0;\n  while (i < row.length) {\n    if (!row[i]) {\n      i++;\n      continue;\n    }\n    const from = i;\n    while (i < row.length && row[i]) i++;\n    out.push({ start: toTime(w.start + from * w.step), end: toTime(w.start + i * w.step) });\n  }\n  return out;\n}\n\nfunction toValue(slots: Slots, days: Weekday[], w: SlotWindow): AvailabilityValue {\n  const value: AvailabilityValue = {};\n  for (const day of days) {\n    const ranges = rangesOf(slots[day], w);\n    if (ranges.length) value[day] = ranges;\n  }\n  return value;\n}\n\n/** Copy of `base` with the rectangle between two cells set to `on`. */\nfunction paintRect(base: Slots, days: Weekday[], a: Cell, b: Cell, on: boolean): Slots {\n  const next = {} as Slots;\n  for (const day of days) next[day] = [...base[day]];\n  const [d0, d1] = [Math.min(a.day, b.day), Math.max(a.day, b.day)];\n  const [s0, s1] = [Math.min(a.slot, b.slot), Math.max(a.slot, b.slot)];\n  for (let d = d0; d <= d1; d++) for (let s = s0; s <= s1; s++) next[days[d]][s] = on;\n  return next;\n}\n\ninterface Cell {\n  day: number;\n  slot: number;\n}\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * A weekly availability picker: days across, time slots down. Click-and-drag paints or erases a\n * rectangle (the first cell decides which), the keyboard works like a spreadsheet (Space toggles,\n * Shift + arrows extend), adjacent slots merge into ranges, and each day is summarised in words.\n */\nexport function AvailabilityGrid({\n  value,\n  defaultValue,\n  onChange,\n  days = ALL_DAYS,\n  startTime = \"08:00\",\n  endTime = \"20:00\",\n  slotMinutes = 30,\n  timeZoneLabel,\n  locale = \"en\",\n  hourCycle = \"24\",\n  label = \"Weekly availability\",\n  showSummary = true,\n  showActions = true,\n  rowHeight = 18,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: AvailabilityGridProps) {\n  const uid = React.useId();\n  const titleId = `${uid}-title`;\n  const hintId = `${uid}-hint`;\n\n  const step = [5, 10, 15, 20, 30, 60].includes(slotMinutes) ? slotMinutes : 30;\n  const startMin = Math.floor(toMinutes(startTime) / step) * step;\n  const endMin = Math.max(startMin + step, Math.ceil(toMinutes(endTime) / step) * step);\n  const w: SlotWindow = { start: startMin, step, count: (endMin - startMin) / step };\n\n  const [inner, setInner] = React.useState<AvailabilityValue>(() => defaultValue ?? {});\n  const current = value ?? inner;\n  const slots = toSlots(current, days, w);\n\n  const [focus, setFocus] = React.useState<Cell>({ day: 0, slot: Math.min(w.count - 1, Math.max(0, (9 * 60 - startMin) / step)) });\n  const [dragging, setDragging] = React.useState(false);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const gridRef = React.useRef<HTMLDivElement>(null);\n  const pendingFocus = React.useRef(false);\n  /** Pointer paint in progress: the anchor cell, paint or erase, the slots before the gesture and the last cell. */\n  const drag = React.useRef<{ anchor: Cell; on: boolean; base: Slots; last: Cell; pointerId: number } | null>(null);\n  /** Keyboard selection anchor for Shift + arrows. */\n  const kbAnchor = React.useRef<{ anchor: Cell; on: boolean; base: Slots } | null>(null);\n\n  const shortName = weekdayNames(locale, \"short\");\n  const longName = weekdayNames(locale, \"long\");\n  const clampCell = (c: Cell): Cell => ({\n    day: Math.max(0, Math.min(days.length - 1, c.day)),\n    slot: Math.max(0, Math.min(w.count - 1, c.slot)),\n  });\n  const fc = clampCell(focus);\n\n  const commit = (next: Slots) => {\n    const v = toValue(next, days, w);\n    if (value === undefined) setInner(v);\n    onChange?.(v);\n  };\n\n  const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));\n  const slotText = (slot: number) => `${formatTime(startMin + slot * step, hourCycle)} to ${formatTime(startMin + (slot + 1) * step, hourCycle)}`;\n  const daySummary = (row: boolean[]) => {\n    const ranges = rangesOf(row, w);\n    return ranges.length\n      ? ranges.map((r) => `${formatTime(toMinutes(r.start), hourCycle)}–${formatTime(toMinutes(r.end), hourCycle)}`).join(\", \")\n      : \"Unavailable\";\n  };\n\n  /* --------------------------------- pointer -------------------------------- */\n\n  const cellFromPoint = (x: number, y: number): Cell | null => {\n    const el = document.elementFromPoint(x, y)?.closest<HTMLElement>(\"[data-cell]\");\n    if (!el || !gridRef.current?.contains(el)) return null;\n    return { day: Number(el.dataset.day), slot: Number(el.dataset.slot) };\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0) return;\n    const cell = cellFromPoint(event.clientX, event.clientY);\n    if (!cell) return;\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const on = !slots[days[cell.day]][cell.slot];\n    drag.current = { anchor: cell, on, base: slots, last: cell, pointerId: event.pointerId };\n    kbAnchor.current = null;\n    setDragging(true);\n    setFocus(cell);\n    // preventDefault (no text selection) also blocks focus, so move it explicitly.\n    pendingFocus.current = true;\n    commit(paintRect(slots, days, cell, cell, on));\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.pointerId !== event.pointerId) return;\n    const cell = cellFromPoint(event.clientX, event.clientY);\n    // Only re-paint when the pointer crosses into a different cell.\n    if (!cell || (cell.day === d.last.day && cell.slot === d.last.slot)) return;\n    d.last = cell;\n    setFocus(cell);\n    commit(paintRect(d.base, days, d.anchor, cell, d.on));\n  };\n\n  const endDrag = () => {\n    const d = drag.current;\n    if (!d) return;\n    drag.current = null;\n    setDragging(false);\n    pendingFocus.current = true;\n    const d0 = Math.min(d.anchor.day, d.last.day);\n    const d1 = Math.max(d.anchor.day, d.last.day);\n    const s0 = Math.min(d.anchor.slot, d.last.slot);\n    const s1 = Math.max(d.anchor.slot, d.last.slot);\n    const span = d0 === d1 ? longName(days[d0]) : `${longName(days[d0])} to ${longName(days[d1])}`;\n    announce(`${span}, ${formatTime(startMin + s0 * step, hourCycle)}–${formatTime(startMin + (s1 + 1) * step, hourCycle)} ${d.on ? \"available\" : \"cleared\"}.`);\n  };\n\n  /* -------------------------------- keyboard -------------------------------- */\n\n  React.useEffect(() => {\n    if (!pendingFocus.current) return;\n    pendingFocus.current = false;\n    gridRef.current?.querySelector<HTMLElement>(`[data-day=\"${fc.day}\"][data-slot=\"${fc.slot}\"]`)?.focus();\n  });\n\n  const toggleCell = (cell: Cell) => {\n    const on = !slots[days[cell.day]][cell.slot];\n    const next = paintRect(slots, days, cell, cell, on);\n    kbAnchor.current = { anchor: cell, on, base: slots };\n    commit(next);\n    announce(`${longName(days[cell.day])} ${slotText(cell.slot)} ${on ? \"available\" : \"unavailable\"}.`);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const ctrl = event.ctrlKey || event.metaKey;\n    const perHour = Math.max(1, 60 / step);\n    const moves: Record<string, () => Cell> = {\n      ArrowUp: () => ({ ...fc, slot: fc.slot - 1 }),\n      ArrowDown: () => ({ ...fc, slot: fc.slot + 1 }),\n      ArrowLeft: () => ({ ...fc, day: fc.day - 1 }),\n      ArrowRight: () => ({ ...fc, day: fc.day + 1 }),\n      PageUp: () => ({ ...fc, slot: fc.slot - perHour * 2 }),\n      PageDown: () => ({ ...fc, slot: fc.slot + perHour * 2 }),\n      Home: () => (ctrl ? { day: 0, slot: 0 } : { ...fc, day: 0 }),\n      End: () => (ctrl ? { day: days.length - 1, slot: w.count - 1 } : { ...fc, day: days.length - 1 }),\n    };\n    if (event.key === \" \" || event.key === \"Enter\") {\n      event.preventDefault();\n      toggleCell(fc);\n      return;\n    }\n    const move = moves[event.key];\n    if (!move) return;\n    event.preventDefault();\n    const target = clampCell(move());\n    if (event.shiftKey && event.key.startsWith(\"Arrow\")) {\n      // Extend from the anchor (the last toggled cell, or here) with the anchor's state.\n      const k = kbAnchor.current ?? { anchor: fc, on: true, base: slots };\n      kbAnchor.current = k;\n      commit(paintRect(k.base, days, k.anchor, target, k.on));\n      const s0 = Math.min(k.anchor.slot, target.slot);\n      const s1 = Math.max(k.anchor.slot, target.slot);\n      announce(`${formatTime(startMin + s0 * step, hourCycle)}–${formatTime(startMin + (s1 + 1) * step, hourCycle)} ${k.on ? \"available\" : \"cleared\"}.`);\n    } else {\n      kbAnchor.current = null;\n    }\n    setFocus(target);\n    pendingFocus.current = true;\n  };\n\n  /* --------------------------------- actions -------------------------------- */\n\n  const copyFirstToWeekdays = () => {\n    const source = days.find((d) => WEEKDAYS.includes(d));\n    if (!source) return;\n    const next = { ...slots };\n    for (const day of days) if (WEEKDAYS.includes(day) && day !== source) next[day] = [...slots[source]];\n    commit(next);\n    announce(`${longName(source)} copied to all weekdays.`);\n  };\n\n  const clearAll = () => {\n    const next = {} as Slots;\n    for (const day of days) next[day] = new Array<boolean>(w.count).fill(false);\n    commit(next);\n    announce(\"All availability cleared.\");\n  };\n\n  /* --------------------------------- render --------------------------------- */\n\n  const totalSlots = days.reduce((n, d) => n + slots[d].filter(Boolean).length, 0);\n  const totalHours = Math.round(((totalSlots * step) / 60) * 10) / 10;\n  const firstWeekday = days.find((d) => WEEKDAYS.includes(d));\n  const gridTemplate = `3.5rem repeat(${days.length}, minmax(3rem, 1fr))`;\n  const rootStyle = { [\"--ag-accent\" as string]: accent, ...style };\n\n  const actionClass =\n    \"rounded-md border border-zinc-900/15 px-2.5 py-1 text-[12px] font-medium outline-hidden transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ag-accent)] disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/15 dark:hover:bg-white/[0.06]\";\n\n  return (\n    <div\n      className={`flex w-full min-w-0 flex-col gap-3 text-zinc-900 dark:text-zinc-100 ${className}`}\n      style={rootStyle}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-end justify-between gap-x-4 gap-y-2\">\n        <div className=\"flex flex-col gap-0.5\">\n          <h3 id={titleId} className=\"text-[14px] font-semibold tracking-[-0.01em]\">\n            {label}\n          </h3>\n          <p className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            {totalHours} h / week{timeZoneLabel ? ` · ${timeZoneLabel}` : \"\"}\n          </p>\n        </div>\n        {showActions ? (\n          <div className=\"flex flex-wrap gap-2\">\n            <button type=\"button\" onClick={copyFirstToWeekdays} disabled={!firstWeekday} className={actionClass}>\n              Copy {firstWeekday ? shortName(firstWeekday) : \"Mon\"} to weekdays\n            </button>\n            <button type=\"button\" onClick={clearAll} disabled={totalSlots === 0} className={actionClass}>\n              Clear\n            </button>\n          </div>\n        ) : null}\n      </div>\n\n      <div className=\"min-w-0 overflow-x-auto rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900\">\n        <div\n          ref={gridRef}\n          role=\"grid\"\n          aria-labelledby={titleId}\n          aria-describedby={hintId}\n          aria-multiselectable=\"true\"\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={endDrag}\n          onPointerCancel={endDrag}\n          onLostPointerCapture={endDrag}\n          onKeyDown={handleKeyDown}\n          className={`min-w-max select-none touch-pan-x p-2 pr-3 sm:min-w-0 ${dragging ? \"cursor-crosshair\" : \"cursor-cell\"}`}\n        >\n          <div role=\"row\" className=\"grid pb-1.5\" style={{ gridTemplateColumns: gridTemplate }}>\n            <div role=\"columnheader\">\n              <span className=\"sr-only\">Time</span>\n            </div>\n            {days.map((day) => (\n              <div\n                key={day}\n                role=\"columnheader\"\n                aria-label={longName(day)}\n                className=\"text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\"\n              >\n                {shortName(day)}\n              </div>\n            ))}\n          </div>\n\n          {Array.from({ length: w.count }, (_, slot) => {\n            const minutes = startMin + slot * step;\n            const onHour = minutes % 60 === 0;\n            return (\n              <div key={slot} role=\"row\" className=\"grid\" style={{ gridTemplateColumns: gridTemplate, height: rowHeight }}>\n                <div\n                  role=\"rowheader\"\n                  aria-label={slotText(slot)}\n                  className=\"relative -top-[0.45em] pr-2 text-right font-mono text-[10px] leading-none tabular-nums text-zinc-400 dark:text-zinc-500\"\n                >\n                  {onHour ? formatTime(minutes, hourCycle) : \"\"}\n                </div>\n                {days.map((day, d) => {\n                  const row = slots[day];\n                  const on = row[slot];\n                  const prevOn = slot > 0 && row[slot - 1];\n                  const nextOn = slot < w.count - 1 && row[slot + 1];\n                  const isFocus = fc.day === d && fc.slot === slot;\n                  const rangeStart = on && !prevOn;\n                  // Label a range at its first cell when it is at least an hour long.\n                  let runLength = 0;\n                  if (rangeStart) while (row[slot + runLength]) runLength++;\n                  return (\n                    <div\n                      key={day}\n                      role=\"gridcell\"\n                      data-cell=\"\"\n                      data-day={d}\n                      data-slot={slot}\n                      tabIndex={isFocus ? 0 : -1}\n                      aria-selected={on}\n                      aria-label={`${longName(day)} ${slotText(slot)}`}\n                      onFocus={() => {\n                        if (!isFocus) setFocus({ day: d, slot });\n                      }}\n                      className={`group/cell relative px-[3px] outline-hidden ${onHour && slot > 0 ? \"border-t border-zinc-900/[0.07] dark:border-white/[0.07]\" : \"border-t border-transparent\"}`}\n                    >\n                      <span\n                        aria-hidden=\"true\"\n                        className={`absolute inset-x-[3px] transition-colors duration-100 motion-reduce:transition-none ${\n                          on\n                            ? `bg-[var(--ag-accent)] ${prevOn ? \"-top-[2px]\" : \"top-[1px] rounded-t-[5px]\"} ${nextOn ? \"-bottom-px\" : \"bottom-[1px] rounded-b-[5px]\"}`\n                            : \"inset-y-[1px] rounded-[4px] bg-zinc-900/[0.035] group-hover/cell:bg-zinc-900/[0.08] dark:bg-white/[0.04] dark:group-hover/cell:bg-white/[0.09]\"\n                        }`}\n                      />\n                      {rangeStart && runLength * step >= 60 ? (\n                        <span\n                          aria-hidden=\"true\"\n                          className=\"pointer-events-none absolute left-[7px] top-[3px] font-mono text-[9px] font-semibold leading-none tabular-nums text-zinc-950/80\"\n                        >\n                          {formatTime(minutes, hourCycle)}\n                        </span>\n                      ) : null}\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"pointer-events-none absolute inset-x-[1px] inset-y-0 rounded-[6px] opacity-0 ring-2 ring-[var(--ag-accent)] ring-offset-1 ring-offset-white group-focus-visible/cell:opacity-100 dark:ring-offset-zinc-900\"\n                      />\n                    </div>\n                  );\n                })}\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      {showSummary ? (\n        <dl className=\"grid grid-cols-1 gap-x-6 gap-y-1 text-[12px] sm:grid-cols-2\">\n          {days.map((day) => {\n            const text = daySummary(slots[day]);\n            return (\n              <div key={day} className=\"flex min-w-0 gap-3 border-b border-zinc-900/[0.06] py-1 dark:border-white/[0.06]\">\n                <dt className=\"w-10 shrink-0 font-mono text-[10px] uppercase leading-[18px] tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n                  {shortName(day)}\n                </dt>\n                <dd className={`min-w-0 tabular-nums ${text === \"Unavailable\" ? \"text-zinc-400 dark:text-zinc-500\" : \"text-zinc-800 dark:text-zinc-200\"}`}>\n                  {text}\n                </dd>\n              </div>\n            );\n          })}\n        </dl>\n      ) : null}\n\n      <p id={hintId} className=\"sr-only\">\n        Arrow keys move between slots. Space toggles a slot; Shift plus arrow keys extend from the last toggled slot. Drag with a pointer to paint or erase.\n      </p>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/availability-grid"
}
