{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gantt-chart",
  "type": "registry:ui",
  "title": "Gantt Chart",
  "description": "A project timeline with draggable, resizable bars, dependency arrows, milestones, progress and day / week / month zoom.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/gantt-chart.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst noopSubscribe = () => () => {};\nconst localTodayISO = () => {\n  const d = new Date();\n  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\n};\n\nexport interface GanttTask {\n  id: string;\n  name: string;\n  /** First day, \"YYYY-MM-DD\". */\n  start: string;\n  /** Last day (inclusive), \"YYYY-MM-DD\". Milestones use the same day as `start`. */\n  end: string;\n  /** 0–100. */\n  progress?: number;\n  /** Render as a diamond on `start`. */\n  milestone?: boolean;\n  /** Ids of tasks that must finish before this one starts (finish-to-start). */\n  dependencies?: string[];\n}\n\nexport type GanttZoom = \"day\" | \"week\" | \"month\";\n\nexport interface GanttChartProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n  tasks: GanttTask[];\n  /** Called with the updated list after a drag or keyboard edit. Without it the chart is read-only. */\n  onChange?: (tasks: GanttTask[], changed: GanttTask) => void;\n  /** Controlled zoom level. */\n  zoom?: GanttZoom;\n  defaultZoom?: GanttZoom;\n  onZoomChange?: (zoom: GanttZoom) => void;\n  /** \"YYYY-MM-DD\" for the today marker. When omitted it is computed in the browser after hydration. */\n  today?: string;\n  showToday?: boolean;\n  /** Draw finish-to-start dependency arrows. */\n  showDependencies?: boolean;\n  /** Fill bars by `progress`. */\n  showProgress?: boolean;\n  /** Shade Saturdays and Sundays (day and week zoom). */\n  showWeekends?: boolean;\n  /** Row height in px. */\n  rowHeight?: number;\n  /** Width of the sticky task-name column in px. */\n  labelWidth?: number;\n  /** Disable dragging and keyboard edits (navigation still works). */\n  readOnly?: boolean;\n  /** Bars, progress, today marker and focus rings. */\n  accent?: string;\n  /** Accessible name of the chart. */\n  label?: string;\n}\n\nconst DAY_MS = 86_400_000;\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst PX_PER_DAY: Record<GanttZoom, number> = { day: 36, week: 12, month: 4.5 };\nconst HEADER_BAND = 24;\n\n/** Dates are handled as whole UTC day numbers so time zones never shift a bar. */\nconst toDay = (iso: string) => {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  return Math.round(Date.UTC(y, m - 1, d) / DAY_MS);\n};\nconst toISO = (day: number) => new Date(day * DAY_MS).toISOString().slice(0, 10);\nconst dateOf = (day: number) => new Date(day * DAY_MS);\nconst fmt = (day: number) => `${dateOf(day).getUTCDate()} ${MONTHS[dateOf(day).getUTCMonth()]}`;\nconst r2 = (n: number) => Math.round(n * 100) / 100;\nconst firstOfMonth = (day: number) => {\n  const d = dateOf(day);\n  return Math.round(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / DAY_MS);\n};\nconst nextMonth = (day: number) => {\n  const d = dateOf(day);\n  return Math.round(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) / DAY_MS);\n};\n\ntype DragMode = \"move\" | \"start\" | \"end\";\n\n/** True when `task` starts before its predecessor `pred` has finished. */\nconst startsTooEarly = (task: GanttTask, pred: GanttTask) =>\n  pred.milestone ? toDay(task.start) < toDay(pred.start) : toDay(task.start) <= toDay(pred.end);\ninterface Draft {\n  id: string;\n  mode: DragMode;\n  delta: number;\n}\n\nfunction shift(task: GanttTask, mode: DragMode, delta: number): GanttTask {\n  const s = toDay(task.start);\n  const e = toDay(task.end);\n  if (mode === \"move\" || task.milestone) return { ...task, start: toISO(s + delta), end: toISO(e + delta) };\n  if (mode === \"start\") return { ...task, start: toISO(Math.min(e, s + delta)) };\n  return { ...task, end: toISO(Math.max(s, e + delta)) };\n}\n\n/**\n * A project timeline: bars positioned by start / end dates, a today marker, dependency arrows,\n * progress fills and milestones. Bars drag to move and their edges drag to resize, snapping to\n * whole days; with the keyboard, arrows move a focused bar by a day and Shift + arrows resize it.\n */\nexport function GanttChart({\n  tasks,\n  onChange,\n  zoom: zoomProp,\n  defaultZoom = \"week\",\n  onZoomChange,\n  today: todayProp,\n  showToday = true,\n  showDependencies = true,\n  showProgress = true,\n  showWeekends = true,\n  rowHeight = 40,\n  labelWidth = 168,\n  readOnly: readOnlyProp = false,\n  accent = \"#FF7448\",\n  label = \"Project timeline\",\n  className = \"\",\n  style,\n  ...props\n}: GanttChartProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const readOnly = readOnlyProp || !onChange;\n  const browserToday = React.useSyncExternalStore(noopSubscribe, localTodayISO, () => null);\n  const todayISO = todayProp ?? browserToday;\n\n  const [innerZoom, setInnerZoom] = React.useState<GanttZoom>(defaultZoom);\n  const zoom = zoomProp ?? innerZoom;\n  const ppd = PX_PER_DAY[zoom];\n\n  const [draft, setDraft] = React.useState<Draft | null>(null);\n  const [focusIndex, setFocusIndex] = React.useState(0);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const barRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const dragRef = React.useRef<{ id: string; mode: DragMode; startX: number; delta: number; moved: boolean } | null>(null);\n  const pendingCenter = React.useRef<number | null>(null);\n\n  // Tasks as drawn: the one being dragged shows its snapped draft position.\n  const shown = tasks.map((t) => (draft && t.id === draft.id ? shift(t, draft.mode, draft.delta) : t));\n  const index = new Map(shown.map((t, i) => [t.id, i]));\n\n  /* ------------------------------- range -------------------------------- */\n\n  const starts = shown.map((t) => toDay(t.start));\n  const ends = shown.map((t) => toDay(t.end));\n  const todayDay = todayISO ? toDay(todayISO) : null;\n  const minDay = Math.min(...starts, ...(todayDay !== null ? [todayDay] : []));\n  const maxDay = Math.max(...ends, ...(todayDay !== null ? [todayDay] : []));\n  let rangeStart: number;\n  let rangeEnd: number; // exclusive\n  if (zoom === \"month\") {\n    rangeStart = minDay - 4; // a few days of air so the first bar or milestone isn't clipped\n    rangeEnd = nextMonth(maxDay + 6);\n  } else if (zoom === \"week\") {\n    const back = (dateOf(minDay - 3).getUTCDay() + 6) % 7; // days since Monday\n    rangeStart = minDay - 3 - back;\n    const end = maxDay + 2;\n    rangeEnd = end + (7 - ((dateOf(end).getUTCDay() + 6) % 7));\n  } else {\n    rangeStart = minDay - 2;\n    rangeEnd = maxDay + 6;\n  }\n  const dayCount = rangeEnd - rangeStart;\n  const timelineWidth = r2(dayCount * ppd);\n  const x = (day: number) => r2((day - rangeStart) * ppd);\n\n  /* ------------------------------- scale -------------------------------- */\n\n  const top: { key: string; x: number; w: number; text: string }[] = [];\n  const bottom: { key: string; x: number; w: number; text: string; weekend?: boolean }[] = [];\n  if (zoom === \"month\") {\n    for (let d = rangeStart; d < rangeEnd; d = nextMonth(d)) {\n      const end = Math.min(nextMonth(d), rangeEnd);\n      const w = r2((end - d) * ppd);\n      bottom.push({ key: `m${d}`, x: x(d), w, text: w >= 28 ? MONTHS[dateOf(d).getUTCMonth()] : \"\" });\n    }\n    let y = rangeStart;\n    while (y < rangeEnd) {\n      const year = dateOf(y).getUTCFullYear();\n      const next = Math.min(rangeEnd, Math.round(Date.UTC(year + 1, 0, 1) / DAY_MS));\n      top.push({ key: `y${y}`, x: x(y), w: r2((next - y) * ppd), text: String(year) });\n      y = next;\n    }\n  } else {\n    for (let d = rangeStart; d < rangeEnd; d = nextMonth(d)) {\n      const from = d === rangeStart ? d : firstOfMonth(d);\n      const end = Math.min(nextMonth(from), rangeEnd);\n      const dt = dateOf(from);\n      top.push({ key: `m${from}`, x: x(from), w: r2((end - from) * ppd), text: `${MONTHS[dt.getUTCMonth()]} ${dt.getUTCFullYear()}` });\n    }\n    if (zoom === \"day\") {\n      for (let d = rangeStart; d < rangeEnd; d++) {\n        const wd = dateOf(d).getUTCDay();\n        bottom.push({ key: `d${d}`, x: x(d), w: ppd, text: String(dateOf(d).getUTCDate()), weekend: wd === 0 || wd === 6 });\n      }\n    } else {\n      for (let d = rangeStart; d < rangeEnd; d += 7) {\n        bottom.push({ key: `w${d}`, x: x(d), w: r2(7 * ppd), text: fmt(d) });\n      }\n    }\n  }\n  const weekendCols: number[] = [];\n  if (showWeekends && zoom !== \"month\") {\n    for (let d = rangeStart; d < rangeEnd; d++) {\n      const wd = dateOf(d).getUTCDay();\n      if (wd === 0 || wd === 6) weekendCols.push(d);\n    }\n  }\n\n  /* ------------------------------- zoom --------------------------------- */\n\n  const setZoom = (z: GanttZoom) => {\n    if (z === zoom) return;\n    const el = scrollerRef.current;\n    // Remember the date in the middle of the view so the new zoom can keep it centred.\n    if (el) pendingCenter.current = rangeStart + (el.scrollLeft + (el.clientWidth - labelWidth) / 2) / ppd;\n    if (zoomProp === undefined) setInnerZoom(z);\n    onZoomChange?.(z);\n  };\n\n  React.useLayoutEffect(() => {\n    const el = scrollerRef.current;\n    if (!el || pendingCenter.current === null) return;\n    el.scrollLeft = Math.max(0, (pendingCenter.current - rangeStart) * ppd - (el.clientWidth - labelWidth) / 2);\n    pendingCenter.current = null;\n  }, [zoom, ppd, rangeStart, labelWidth]);\n\n  const scrollToToday = () => {\n    const el = scrollerRef.current;\n    if (!el || todayDay === null) return;\n    el.scrollTo({ left: Math.max(0, x(todayDay) - (el.clientWidth - labelWidth) / 2), behavior: reduceMotion ? \"auto\" : \"smooth\" });\n  };\n\n  /* ------------------------------- edits -------------------------------- */\n\n  const describe = (t: GanttTask) =>\n    t.milestone\n      ? `${t.name}, milestone, ${fmt(toDay(t.start))}`\n      : `${t.name}, ${fmt(toDay(t.start))} to ${fmt(toDay(t.end))}${showProgress ? `, ${Math.round(t.progress ?? 0)}% complete` : \"\"}`;\n\n  const commit = (id: string, mode: DragMode, delta: number) => {\n    if (!delta || !onChange) return;\n    const original = tasks.find((t) => t.id === id);\n    if (!original) return;\n    const changed = shift(original, mode, delta);\n    onChange(\n      tasks.map((t) => (t.id === id ? changed : t)),\n      changed,\n    );\n    setAnnouncement(\n      changed.milestone\n        ? `${changed.name} moved to ${fmt(toDay(changed.start))}.`\n        : `${changed.name}: ${fmt(toDay(changed.start))} to ${fmt(toDay(changed.end))}.`,\n    );\n  };\n\n  const onBarPointerDown = (task: GanttTask, mode: DragMode, event: React.PointerEvent<HTMLElement>) => {\n    if (readOnly || event.button !== 0) return;\n    event.stopPropagation();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    dragRef.current = { id: task.id, mode, startX: event.clientX, delta: 0, moved: false };\n  };\n\n  // Pointer moves only touch state when the snapped day offset changes.\n  const onBarPointerMove = (event: React.PointerEvent<HTMLElement>) => {\n    const d = dragRef.current;\n    if (!d) return;\n    event.stopPropagation(); // edge handles sit inside the bar; handle each move once\n    const dx = event.clientX - d.startX;\n    if (!d.moved && Math.abs(dx) < 3) return;\n    d.moved = true;\n    const delta = Math.round(dx / ppd);\n    if (delta !== d.delta) {\n      d.delta = delta;\n      setDraft({ id: d.id, mode: d.mode, delta });\n    } else if (!draft) setDraft({ id: d.id, mode: d.mode, delta });\n  };\n\n  const onBarPointerUp = (event: React.PointerEvent<HTMLElement>) => {\n    const d = dragRef.current;\n    if (!d) return;\n    dragRef.current = null;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n    setDraft(null);\n    if (event.type === \"pointerup\") commit(d.id, d.mode, d.delta);\n  };\n\n  const focusBar = (i: number) => {\n    const next = Math.max(0, Math.min(shown.length - 1, i));\n    setFocusIndex(next);\n    barRefs.current[next]?.focus();\n  };\n\n  const onBarKeyDown = (i: number, task: GanttTask, event: React.KeyboardEvent<HTMLDivElement>) => {\n    switch (event.key) {\n      case \"ArrowUp\":\n        event.preventDefault();\n        return focusBar(i - 1);\n      case \"ArrowDown\":\n        event.preventDefault();\n        return focusBar(i + 1);\n      case \"Home\":\n        event.preventDefault();\n        return focusBar(0);\n      case \"End\":\n        event.preventDefault();\n        return focusBar(shown.length - 1);\n      case \"ArrowLeft\":\n      case \"ArrowRight\": {\n        if (readOnly) return;\n        event.preventDefault();\n        const delta = event.key === \"ArrowLeft\" ? -1 : 1;\n        // Shift resizes the end; Shift + Alt resizes the start; plain arrows move.\n        const mode: DragMode = event.shiftKey && !task.milestone ? (event.altKey ? \"start\" : \"end\") : \"move\";\n        // A bar never shrinks below one day.\n        const oneDay = toDay(task.start) >= toDay(task.end);\n        if (oneDay && ((mode === \"end\" && delta < 0) || (mode === \"start\" && delta > 0))) return;\n        return commit(task.id, mode, delta);\n      }\n    }\n  };\n\n  /* ---------------------------- dependencies ---------------------------- */\n\n  const bodyHeight = shown.length * rowHeight;\n  const barHeight = Math.max(14, Math.round(rowHeight * 0.56));\n  const arrows: { key: string; d: string; conflict: boolean }[] = [];\n  if (showDependencies) {\n    const half = r2(barHeight * 0.66 * Math.SQRT1_2); // half-diagonal of the milestone diamond\n    shown.forEach((t, row) => {\n      for (const depId of t.dependencies ?? []) {\n        const from = index.get(depId);\n        if (from === undefined) continue;\n        const p = shown[from];\n        const pEnd = p.milestone ? x(toDay(p.start)) + ppd / 2 + half : x(toDay(p.end) + 1);\n        const tStart = t.milestone ? x(toDay(t.start)) + ppd / 2 - half : x(toDay(t.start));\n        const y1 = from * rowHeight + rowHeight / 2;\n        const y2 = row * rowHeight + rowHeight / 2;\n        const conflict = startsTooEarly(t, p);\n        const gap = 8;\n        let d: string;\n        if (tStart - pEnd >= gap * 2) {\n          const mx = r2(pEnd + gap);\n          d = `M${r2(pEnd)} ${y1} H${mx} V${y2} H${r2(tStart - 1)}`;\n        } else {\n          // Not enough room: step out, drop to the row boundary, come back left, then in.\n          const yMid = r2(y2 > y1 ? row * rowHeight : (row + 1) * rowHeight);\n          d = `M${r2(pEnd)} ${y1} H${r2(pEnd + gap)} V${yMid} H${r2(tStart - gap)} V${y2} H${r2(tStart - 1)}`;\n        }\n        arrows.push({ key: `${depId}-${t.id}`, d, conflict });\n      }\n    });\n  }\n\n  /* ------------------------------- render ------------------------------- */\n\n  const tabStop = Math.min(focusIndex, shown.length - 1);\n  const focusRing =\n    \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--gc-accent)]\";\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={`flex w-full min-w-0 select-none flex-col overflow-hidden rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-950 ${className}`}\n      style={{ [\"--gc-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div className=\"flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-zinc-900/10 px-3 py-1.5 dark:border-white/10\">\n        <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {tasks.length} tasks · {fmt(Math.min(...tasks.map((t) => toDay(t.start))))} – {fmt(Math.max(...tasks.map((t) => toDay(t.end))))}\n        </span>\n        <div className=\"flex items-center gap-2\">\n          {showToday && todayDay !== null && (\n            <button\n              type=\"button\"\n              onClick={scrollToToday}\n              className={`h-7 rounded-md px-2 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-600 transition-colors hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-white ${focusRing}`}\n            >\n              Today\n            </button>\n          )}\n          <div role=\"group\" aria-label=\"Zoom\" className=\"flex rounded-lg border border-zinc-900/10 p-0.5 dark:border-white/10\">\n            {([\"day\", \"week\", \"month\"] as const).map((z) => (\n              <button\n                key={z}\n                type=\"button\"\n                aria-pressed={zoom === z}\n                onClick={() => setZoom(z)}\n                className={`rounded-md px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors ${focusRing} ${\n                  zoom === z ? \"bg-zinc-900 text-white dark:bg-white dark:text-zinc-950\" : \"text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white\"\n                }`}\n              >\n                {z}\n              </button>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <div ref={scrollerRef} className=\"relative min-w-0 overflow-x-auto overscroll-x-contain\">\n        <div className=\"relative\" style={{ width: labelWidth + timelineWidth, minWidth: \"100%\" }}>\n          {/* Header scale (decorative: every bar carries its dates in its accessible name). */}\n          <div aria-hidden=\"true\" className=\"sticky top-0 z-20 flex border-b border-zinc-900/10 dark:border-white/10\" style={{ height: HEADER_BAND * 2 }}>\n            <div\n              className=\"sticky left-0 z-30 flex shrink-0 items-end border-r border-zinc-900/10 bg-white px-3 pb-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-500\"\n              style={{ width: labelWidth }}\n            >\n              Task\n            </div>\n            <div className=\"relative flex-1 bg-zinc-50 dark:bg-zinc-900/60\" style={{ minWidth: timelineWidth }}>\n              {top.map((u) => (\n                <div\n                  key={u.key}\n                  className=\"absolute top-0 truncate border-l border-zinc-900/10 px-2 font-mono text-[10px] uppercase leading-6 tracking-[0.12em] text-zinc-600 dark:border-white/10 dark:text-zinc-300\"\n                  style={{ left: u.x, width: u.w, height: HEADER_BAND }}\n                >\n                  {/* Partial months at the edges are too narrow for a label. */}\n                  {u.w >= 56 ? u.text : \"\"}\n                </div>\n              ))}\n              {bottom.map((u) => (\n                <div\n                  key={u.key}\n                  className={`absolute truncate border-l border-t border-zinc-900/[0.07] font-mono text-[10px] leading-6 tabular-nums dark:border-white/[0.07] ${\n                    zoom === \"day\" ? \"text-center\" : \"px-1.5\"\n                  } ${u.weekend ? \"text-zinc-400 dark:text-zinc-600\" : \"text-zinc-500 dark:text-zinc-400\"}`}\n                  style={{ left: u.x, top: HEADER_BAND, width: u.w, height: HEADER_BAND }}\n                >\n                  {u.text}\n                </div>\n              ))}\n              {showToday && todayDay !== null && todayDay >= rangeStart && todayDay < rangeEnd && (\n                <span\n                  className=\"absolute top-1 z-10 -translate-x-1/2 rounded-sm bg-[var(--gc-accent)] px-1 font-mono text-[9px] uppercase leading-4 tracking-[0.1em] text-zinc-950\"\n                  style={{ left: r2(x(todayDay) + ppd / 2) }}\n                >\n                  Today\n                </span>\n              )}\n            </div>\n          </div>\n\n          {/* Body */}\n          <div className=\"relative\" style={{ height: bodyHeight }}>\n            {/* Grid, weekends and today line. */}\n            <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0\" style={{ left: labelWidth, width: timelineWidth }}>\n              {weekendCols.map((d) => (\n                <div key={d} className=\"absolute inset-y-0 bg-zinc-900/[0.025] dark:bg-white/[0.025]\" style={{ left: x(d), width: ppd }} />\n              ))}\n              {bottom.map((u) => (\n                <div key={u.key} className=\"absolute inset-y-0 border-l border-zinc-900/[0.06] dark:border-white/[0.06]\" style={{ left: u.x }} />\n              ))}\n              {showToday && todayDay !== null && todayDay >= rangeStart && todayDay < rangeEnd && (\n                <div className=\"absolute inset-y-0 z-[6] w-px bg-[var(--gc-accent)]\" style={{ left: r2(x(todayDay) + ppd / 2) }} />\n              )}\n            </div>\n\n            {/* Dependency arrows sit above the grid and below the bars. */}\n            {arrows.length > 0 && (\n              <svg\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute top-0 z-[5] overflow-visible\"\n                style={{ left: labelWidth }}\n                width={timelineWidth}\n                height={bodyHeight}\n              >\n                <defs>\n                  <marker id={`${uid}-arrow`} viewBox=\"0 0 8 8\" refX=\"7\" refY=\"4\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto\">\n                    <path d=\"M0 0.5 7.5 4 0 7.5z\" className=\"fill-zinc-400 dark:fill-zinc-500\" />\n                  </marker>\n                  <marker id={`${uid}-arrow-bad`} viewBox=\"0 0 8 8\" refX=\"7\" refY=\"4\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto\">\n                    <path d=\"M0 0.5 7.5 4 0 7.5z\" className=\"fill-rose-500\" />\n                  </marker>\n                </defs>\n                {arrows.map((a) => (\n                  <path\n                    key={a.key}\n                    d={a.d}\n                    fill=\"none\"\n                    strokeWidth=\"1.25\"\n                    strokeLinejoin=\"round\"\n                    strokeDasharray={a.conflict ? \"3 3\" : undefined}\n                    markerEnd={`url(#${a.conflict ? `${uid}-arrow-bad` : `${uid}-arrow`})`}\n                    className={a.conflict ? \"stroke-rose-500\" : \"stroke-zinc-400 dark:stroke-zinc-500\"}\n                  />\n                ))}\n              </svg>\n            )}\n\n            {shown.map((task, i) => {\n              const s = toDay(task.start);\n              const e = toDay(task.end);\n              const progress = Math.max(0, Math.min(100, task.progress ?? 0));\n              const left = x(s);\n              const width = r2((e - s + 1) * ppd);\n              const dragging = draft?.id === task.id;\n              const deps = (task.dependencies ?? []).map((id) => shown[index.get(id) ?? -1]?.name).filter(Boolean);\n              const conflict = (task.dependencies ?? []).some((id) => {\n                const p = shown[index.get(id) ?? -1];\n                return Boolean(p) && startsTooEarly(task, p);\n              });\n              const name = `${describe(task)}${deps.length ? `, after ${deps.join(\" and \")}` : \"\"}${conflict ? \", starts before a dependency ends\" : \"\"}`;\n\n              return (\n                <div key={task.id} className=\"group/row absolute inset-x-0 flex\" style={{ top: i * rowHeight, height: rowHeight }}>\n                  <div\n                    aria-hidden=\"true\"\n                    className=\"sticky left-0 z-20 flex shrink-0 flex-col justify-center border-b border-r border-zinc-900/[0.07] bg-white px-3 group-hover/row:bg-zinc-50 dark:border-white/[0.07] dark:bg-zinc-950 dark:group-hover/row:bg-zinc-900\"\n                    style={{ width: labelWidth }}\n                  >\n                    <span className=\"truncate text-[13px] font-medium tracking-tight text-zinc-900 dark:text-zinc-100\">{task.name}</span>\n                    {rowHeight >= 40 && (\n                      <span className=\"truncate font-mono text-[10px] tabular-nums text-zinc-400 dark:text-zinc-500\">\n                        {task.milestone ? fmt(s) : `${fmt(s)} – ${fmt(e)}`}\n                      </span>\n                    )}\n                  </div>\n                  <div className=\"relative flex-1 border-b border-zinc-900/[0.07] group-hover/row:bg-zinc-900/[0.02] dark:border-white/[0.07] dark:group-hover/row:bg-white/[0.02]\" style={{ minWidth: timelineWidth }}>\n                    <div\n                      ref={(el) => {\n                        barRefs.current[i] = el;\n                      }}\n                      role=\"button\"\n                      aria-roledescription={task.milestone ? \"milestone\" : \"task bar\"}\n                      aria-label={name}\n                      aria-describedby={`${uid}-help`}\n                      aria-disabled={readOnly || undefined}\n                      tabIndex={i === tabStop ? 0 : -1}\n                      onFocus={() => setFocusIndex(i)}\n                      onKeyDown={(ev) => onBarKeyDown(i, task, ev)}\n                      onPointerDown={(ev) => onBarPointerDown(task, \"move\", ev)}\n                      onPointerMove={onBarPointerMove}\n                      onPointerUp={onBarPointerUp}\n                      onPointerCancel={onBarPointerUp}\n                      data-dragging={dragging || undefined}\n                      className={`absolute top-1/2 z-10 -translate-y-1/2 ${readOnly ? \"\" : \"cursor-grab touch-pan-y active:cursor-grabbing\"} ${focusRing} ${\n                        task.milestone ? \"grid place-items-center\" : \"overflow-hidden rounded-md\"\n                      } ${dragging ? \"shadow-[0_6px_16px_-6px_rgba(0,0,0,0.35)]\" : \"\"}`}\n                      style={\n                        task.milestone\n                          ? { left: r2(left + ppd / 2 - barHeight / 2), width: barHeight, height: barHeight }\n                          : { left, width, height: barHeight }\n                      }\n                    >\n                      {task.milestone ? (\n                        <span\n                          aria-hidden=\"true\"\n                          className=\"block rotate-45 rounded-[2px] border border-zinc-950/20 bg-[var(--gc-accent)] dark:border-white/30\"\n                          style={{ width: r2(barHeight * 0.66), height: r2(barHeight * 0.66) }}\n                        />\n                      ) : (\n                        <>\n                          <span\n                            aria-hidden=\"true\"\n                            className={`absolute inset-0 rounded-[inherit] border ${\n                              conflict\n                                ? \"border-rose-500/70 bg-rose-500/10\"\n                                : \"border-[color-mix(in_srgb,var(--gc-accent)_55%,transparent)] bg-[color-mix(in_srgb,var(--gc-accent)_16%,transparent)]\"\n                            }`}\n                          />\n                          {showProgress && progress > 0 && (\n                            <span\n                              aria-hidden=\"true\"\n                              className=\"absolute inset-y-0 left-0 bg-[color-mix(in_srgb,var(--gc-accent)_80%,transparent)]\"\n                              style={{ width: `${progress}%` }}\n                            />\n                          )}\n                          {width >= 56 && (\n                            <span\n                              aria-hidden=\"true\"\n                              className=\"relative flex h-full items-center gap-1.5 truncate px-2 text-[11px] font-medium leading-none text-zinc-950 dark:text-white\"\n                            >\n                              <span className=\"truncate\">{task.name}</span>\n                              {showProgress && width >= 110 && <span className=\"font-mono text-[9px] opacity-60\">{Math.round(progress)}%</span>}\n                            </span>\n                          )}\n                          {!readOnly && (\n                            <>\n                              <span\n                                aria-hidden=\"true\"\n                                onPointerDown={(ev) => onBarPointerDown(task, \"start\", ev)}\n                                onPointerMove={onBarPointerMove}\n                                onPointerUp={onBarPointerUp}\n                                onPointerCancel={onBarPointerUp}\n                                className=\"absolute inset-y-0 left-0 w-2 cursor-ew-resize after:absolute after:inset-y-1 after:left-0.5 after:w-[2px] after:rounded-full after:bg-zinc-950/30 after:opacity-0 after:transition-opacity hover:after:opacity-100 dark:after:bg-white/50\"\n                              />\n                              <span\n                                aria-hidden=\"true\"\n                                onPointerDown={(ev) => onBarPointerDown(task, \"end\", ev)}\n                                onPointerMove={onBarPointerMove}\n                                onPointerUp={onBarPointerUp}\n                                onPointerCancel={onBarPointerUp}\n                                className=\"absolute inset-y-0 right-0 w-2 cursor-ew-resize after:absolute after:inset-y-1 after:right-0.5 after:w-[2px] after:rounded-full after:bg-zinc-950/30 after:opacity-0 after:transition-opacity hover:after:opacity-100 dark:after:bg-white/50\"\n                              />\n                            </>\n                          )}\n                        </>\n                      )}\n                    </div>\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <p id={`${uid}-help`} hidden>\n        {readOnly\n          ? \"Up and Down arrows move between tasks.\"\n          : \"Left and Right arrows move the task by a day. Shift with arrows changes the end date, Shift and Alt with arrows changes the start date. Up and Down arrows move between tasks.\"}\n      </p>\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/gantt-chart"
}
