{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gantt-1",
  "title": "Gantt 1: project timeline",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A project timeline: bars placed by real dates with a progress fill, milestones, dependency arrows, a today line, three zoom levels, a sticky task column, a details panel and a table of the same data for screen readers.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/gantt-1/gantt-1.tsx",
      "content": "// Ballmac UI: Gantt 1. https://ui.ballmac.com/blocks/gantt-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { useReducedMotion } from \"motion/react\"\nimport { CalendarRange, CircleAlert, CircleCheck, CircleDashed, CirclePlay, LocateFixed } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Gantt1Status = \"done\" | \"active\" | \"late\" | \"planned\"\n\ntype Gantt1Task = {\n  id: string\n  name: string\n  /** First day, as YYYY-MM-DD. */\n  start: string\n  /** Last day, as YYYY-MM-DD, included. A milestone has the same start and end. */\n  end: string\n  /** From 0 to 1. Decides whether it is done, and whether it is late. */\n  progress?: number\n  /** The section it is listed under. */\n  group?: string\n  /** Initials of who owns it. */\n  owner?: string\n  /** Ids of tasks that must finish first. Drawn as arrows. */\n  dependsOn?: string[]\n  /** A single day that marks a decision or a date, drawn as a diamond. */\n  milestone?: boolean\n}\n\ntype Gantt1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    tasks?: Gantt1Task[]\n    title?: string\n    description?: string\n    /** Today as YYYY-MM-DD. Pass it in so the server and browser agree; it decides what is late and where the today line goes. */\n    today?: string\n    /** How much of the timeline fits on screen at first. */\n    defaultScale?: \"days\" | \"weeks\" | \"months\"\n    /** The task whose details are open at first. */\n    defaultSelectedId?: string\n    /** BCP 47 locale for dates. */\n    locale?: string\n    /** Called when a task is chosen. */\n    onSelect?: (task: Gantt1Task) => void\n  }\n\nconst DAY = 86_400_000\nconst toT = (iso: string) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10))\nconst PX = { days: 44, weeks: 18, months: 6 } as const\nconst ROW = 40\nconst GROUP = 32\n\nconst sample: Gantt1Task[] = [\n  { id: \"t1\", group: \"Discovery\", name: \"User interviews\", start: \"2026-10-05\", end: \"2026-10-16\", progress: 1, owner: \"MA\" },\n  { id: \"t2\", group: \"Discovery\", name: \"Legal review\", start: \"2026-10-05\", end: \"2026-10-15\", progress: 0.6, owner: \"JL\" },\n  { id: \"t3\", group: \"Discovery\", name: \"Requirements\", start: \"2026-10-12\", end: \"2026-10-20\", progress: 0.8, owner: \"JL\", dependsOn: [\"t1\"] },\n  { id: \"t4\", group: \"Design\", name: \"Wireframes\", start: \"2026-10-14\", end: \"2026-10-23\", progress: 0.55, owner: \"MA\", dependsOn: [\"t1\"] },\n  { id: \"t5\", group: \"Design\", name: \"Visual design\", start: \"2026-10-26\", end: \"2026-11-06\", progress: 0.1, owner: \"MA\", dependsOn: [\"t4\"] },\n  { id: \"m1\", group: \"Design\", name: \"Design review\", start: \"2026-11-09\", end: \"2026-11-09\", milestone: true, dependsOn: [\"t5\"] },\n  { id: \"t6\", group: \"Build\", name: \"API and data model\", start: \"2026-10-19\", end: \"2026-11-13\", progress: 0.2, owner: \"PS\", dependsOn: [\"t3\"] },\n  { id: \"t7\", group: \"Build\", name: \"Web app\", start: \"2026-11-09\", end: \"2026-12-04\", progress: 0, owner: \"TN\", dependsOn: [\"m1\", \"t6\"] },\n  { id: \"t8\", group: \"Build\", name: \"Desktop app\", start: \"2026-11-16\", end: \"2026-12-11\", progress: 0, owner: \"TN\", dependsOn: [\"t6\"] },\n  { id: \"t9\", group: \"Launch\", name: \"Accessibility audit\", start: \"2026-11-30\", end: \"2026-12-11\", progress: 0, owner: \"AK\", dependsOn: [\"t7\"] },\n  { id: \"t10\", group: \"Launch\", name: \"Private beta\", start: \"2026-12-14\", end: \"2026-12-23\", progress: 0, owner: \"SO\", dependsOn: [\"t9\", \"t8\"] },\n  { id: \"m2\", group: \"Launch\", name: \"Public launch\", start: \"2027-01-12\", end: \"2027-01-12\", milestone: true, dependsOn: [\"t10\"] },\n]\n\nconst statusMeta: Record<Gantt1Status, { label: string; icon: typeof CircleCheck; bar: string; fill: string; chip: string }> = {\n  done: { label: \"Done\", icon: CircleCheck, bar: \"bg-chart-2/25 border-chart-2\", fill: \"bg-chart-2\", chip: \"bg-chart-2/15 border-chart-2/30\" },\n  active: { label: \"In progress\", icon: CirclePlay, bar: \"bg-chart-1/20 border-chart-1\", fill: \"bg-chart-1\", chip: \"bg-chart-1/15 border-chart-1/30\" },\n  late: { label: \"Late\", icon: CircleAlert, bar: \"bg-destructive/15 border-destructive\", fill: \"bg-destructive\", chip: \"bg-destructive/10 border-destructive/30\" },\n  planned: { label: \"Planned\", icon: CircleDashed, bar: \"bg-muted border-border\", fill: \"bg-foreground/30\", chip: \"bg-muted border-border\" },\n}\n\nfunction GanttSkeleton() {\n  return (\n    <div className=\"bg-card overflow-hidden rounded-2xl border\" aria-hidden=\"true\">\n      <Skeleton className=\"h-14 rounded-none\" />\n      {[0, 1, 2, 3, 4, 5, 6].map((i) => (\n        <div key={i} className=\"flex h-10 items-center gap-4 border-t px-3\">\n          <Skeleton className=\"h-3.5 w-36\" />\n          <Skeleton className=\"h-5 rounded-md\" style={{ width: `${18 + ((i * 7) % 5) * 9}%`, marginInlineStart: `${10 + ((i * 11) % 6) * 8}%` }} />\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/**\n * A project timeline (Gantt chart): tasks as bars placed by their real dates with a fill for progress, milestones as diamonds, arrows for\n * what depends on what, a line for today, three zoom levels, a task column that stays put while you scroll sideways, a details panel, and\n * a table of the same data for screen readers. Late work is marked in words as well as color.\n */\nfunction Gantt1({\n  tasks: tasksProp = sample,\n  title = \"Product launch plan\",\n  description = \"From the first interviews to public launch. Select a bar to see its dates and what it waits for.\",\n  today = \"2026-10-18\",\n  defaultScale = \"weeks\",\n  defaultSelectedId,\n  locale = \"en-US\",\n  onSelect,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Gantt1Props) {\n  const id = React.useId()\n  const reduce = useReducedMotion()\n  const scroller = React.useRef<HTMLDivElement>(null)\n  const [scale, setScale] = React.useState<\"days\" | \"weeks\" | \"months\">(defaultScale)\n  const [selectedId, setSelectedId] = React.useState<string | undefined>(defaultSelectedId)\n  const ready = state === \"ready\" || state === \"empty\"\n  const tasks = state === \"empty\" ? [] : tasksProp\n  const px = PX[scale]\n\n  const dayFmt = new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", timeZone: \"UTC\" })\n  const monthFmt = new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\", timeZone: \"UTC\" })\n  const weekdayFmt = new Intl.DateTimeFormat(locale, { weekday: \"narrow\", timeZone: \"UTC\" })\n\n  const model = React.useMemo(() => {\n    if (!tasks.length) return null\n    const todayT = toT(today)\n    let min = Math.min(...tasks.map((t) => toT(t.start)), todayT) - 3 * DAY\n    min -= ((new Date(min).getUTCDay() + 6) % 7) * DAY // start on a Monday\n    const max = Math.max(...tasks.map((t) => toT(t.end)), todayT) + 7 * DAY\n    const days = Math.round((max - min) / DAY) + 1\n    const statusOf = (t: Gantt1Task): Gantt1Status => ((t.progress ?? 0) >= 1 ? \"done\" : toT(t.end) < todayT ? \"late\" : toT(t.start) <= todayT ? \"active\" : \"planned\")\n    const rows: ({ kind: \"group\"; name: string } | { kind: \"task\"; task: Gantt1Task; status: Gantt1Status })[] = []\n    const groups = [...new Set(tasks.map((t) => t.group ?? \"\"))]\n    for (const g of groups) {\n      if (g) rows.push({ kind: \"group\", name: g })\n      for (const t of tasks.filter((x) => (x.group ?? \"\") === g)) rows.push({ kind: \"task\", task: t, status: statusOf(t) })\n    }\n    let y = 0\n    const tops: Record<string, number> = {}\n    const layout = rows.map((r) => {\n      const top = y\n      const h = r.kind === \"group\" ? GROUP : ROW\n      y += h\n      if (r.kind === \"task\") tops[r.task.id] = top\n      return { ...r, top, h }\n    })\n    return { min, days, rows: layout, tops, height: y, todayT }\n  }, [tasks, today])\n\n  const x = (iso: string) => (model ? ((toT(iso) - model.min) / DAY) * px : 0)\n  const jump = React.useCallback(\n    (smooth = true) => {\n      const el = scroller.current\n      if (!el || !model) return\n      const side = el.querySelector<HTMLElement>(\"[data-gantt-left]\")?.offsetWidth ?? 224\n      el.scrollTo?.({ left: Math.max(0, ((model.todayT - model.min) / DAY) * px - (el.clientWidth - side) / 3), behavior: smooth && !reduce ? \"smooth\" : \"auto\" })\n    },\n    [model, px, reduce]\n  )\n  // open around today, and again after a zoom change\n  React.useEffect(() => {\n    jump(false)\n  }, [jump])\n\n  const selected = tasks.find((t) => t.id === selectedId)\n  const statusOf = (t: Gantt1Task) => (model?.rows.find((r) => r.kind === \"task\" && r.task.id === t.id) as { status: Gantt1Status } | undefined)?.status ?? \"planned\"\n  const lengthOf = (t: Gantt1Task) => Math.round((toT(t.end) - toT(t.start)) / DAY) + 1\n  const pick = (t: Gantt1Task) => {\n    setSelectedId(t.id)\n    onSelect?.(t)\n  }\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    if (![\"ArrowUp\", \"ArrowDown\", \"Home\", \"End\"].includes(e.key)) return\n    const bars = [...(scroller.current?.querySelectorAll<HTMLElement>(\"[data-bar]\") ?? [])]\n    const at = bars.indexOf(e.target as HTMLElement)\n    if (at === -1) return\n    e.preventDefault()\n    const next = e.key === \"Home\" ? 0 : e.key === \"End\" ? bars.length - 1 : Math.max(0, Math.min(bars.length - 1, at + (e.key === \"ArrowDown\" ? 1 : -1)))\n    bars[next]?.focus()\n  }\n\n  const months: { label: string; w: number }[] = []\n  if (model) {\n    let i = 0\n    while (i < model.days) {\n      const d = new Date(model.min + i * DAY)\n      const left = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)).getTime()\n      const span = Math.min(model.days - i, Math.round((left - (model.min + i * DAY)) / DAY))\n      months.push({ label: monthFmt.format(model.min + i * DAY), w: span * px })\n      i += span\n    }\n  }\n\n  const chart =\n    model === null ? (\n      <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n        <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n          <CalendarRange className=\"size-6\" />\n        </span>\n        <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No tasks scheduled\"}</h2>\n        <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"Add tasks with start and end dates and the plan draws itself.\"}</p>\n        {emptyState?.action}\n      </div>\n    ) : (\n      <>\n        <div\n          ref={scroller}\n          dir=\"ltr\"\n          tabIndex={0}\n          role=\"region\"\n          aria-label=\"Project timeline. Scroll sideways for other dates.\"\n          onKeyDown={onKeyDown}\n          className=\"bg-card focus-visible:ring-ring/50 relative max-h-[42rem] overflow-auto rounded-2xl border [--gantt-left:8.5rem] outline-none focus-visible:ring-[3px] sm:[--gantt-left:14rem]\"\n        >\n          <div className=\"relative\" style={{ width: `calc(var(--gantt-left) + ${model.days * px}px)` }}>\n            <div className=\"bg-card sticky top-0 z-30 flex h-14 border-b\">\n              <div data-gantt-left className=\"bg-card sticky start-0 z-40 flex shrink-0 items-end border-e px-3 pb-2 text-xs font-medium\" style={{ width: \"var(--gantt-left)\" }}>\n                <span className=\"text-muted-foreground\">Task</span>\n              </div>\n              <div className=\"relative\" style={{ width: model.days * px }}>\n                <div className=\"flex h-7 border-b\">\n                  {months.map((m, i) => (\n                    <div key={i} className=\"text-foreground/80 border-e py-1 text-xs font-medium whitespace-nowrap\" style={{ width: m.w }}>\n                      {/* sticky, so the name stays readable beside the task column while the month is on screen */}\n                      {m.w > 70 ? (\n                        <span className=\"sticky inline-block px-2\" style={{ left: \"var(--gantt-left)\" }}>\n                          {m.label}\n                        </span>\n                      ) : null}\n                    </div>\n                  ))}\n                </div>\n                <div className=\"relative h-7\">\n                  {Array.from({ length: model.days }, (_, i) => {\n                    const t = model.min + i * DAY\n                    const dow = new Date(t).getUTCDay()\n                    const monday = dow === 1\n                    if (scale === \"days\")\n                      return (\n                        <div key={i} className={cn(\"text-muted-foreground absolute top-0 flex h-7 flex-col items-center justify-center text-[10px] leading-tight tabular-nums\", (dow === 0 || dow === 6) && \"bg-muted/50\")} style={{ left: i * px, width: px }}>\n                          <span className=\"text-foreground/80 font-medium\">{new Date(t).getUTCDate()}</span>\n                          <span>{weekdayFmt.format(t)}</span>\n                        </div>\n                      )\n                    if (scale === \"weeks\" && monday)\n                      return (\n                        <div key={i} className=\"text-muted-foreground absolute top-0 flex h-7 items-center border-s ps-1.5 text-[10px] tabular-nums\" style={{ left: i * px }}>\n                          {dayFmt.format(t)}\n                        </div>\n                      )\n                    return null\n                  })}\n                </div>\n              </div>\n            </div>\n\n            <div className=\"flex\">\n              <div className=\"bg-card sticky start-0 z-20 shrink-0 border-e\" style={{ width: \"var(--gantt-left)\" }}>\n                {model.rows.map((r, i) =>\n                  r.kind === \"group\" ? (\n                    <div key={`g-${i}`} className=\"bg-muted/60 text-muted-foreground flex items-center px-3 text-[11px] font-semibold tracking-wide uppercase\" style={{ height: r.h }}>\n                      {r.name}\n                    </div>\n                  ) : (\n                    <div key={r.task.id} className={cn(\"flex items-center gap-2 border-t px-3 text-sm\", selectedId === r.task.id && \"bg-accent/60\")} style={{ height: r.h }}>\n                      <span className=\"min-w-0 flex-1 truncate\" dir=\"auto\">\n                        {r.task.name}\n                      </span>\n                      {r.status === \"late\" ? <CircleAlert aria-hidden=\"true\" className=\"text-destructive size-3.5 shrink-0\" /> : null}\n                      {r.task.owner ? (\n                        <span aria-hidden=\"true\" className=\"bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold\">\n                          {r.task.owner}\n                        </span>\n                      ) : null}\n                    </div>\n                  )\n                )}\n              </div>\n\n              <div className=\"relative\" style={{ width: model.days * px, height: model.height }}>\n                {Array.from({ length: model.days }, (_, i) => {\n                  const t = model.min + i * DAY\n                  const d = new Date(t)\n                  const dow = d.getUTCDay()\n                  if (scale === \"days\" && (dow === 0 || dow === 6)) return <div key={`w-${i}`} aria-hidden=\"true\" className=\"bg-muted/40 absolute inset-y-0\" style={{ left: i * px, width: px }} />\n                  if (scale !== \"days\" && (scale === \"weeks\" ? dow === 1 : d.getUTCDate() === 1)) return <div key={`l-${i}`} aria-hidden=\"true\" className=\"border-border/70 absolute inset-y-0 border-s\" style={{ left: i * px }} />\n                  return null\n                })}\n                {model.rows.map((r) => (r.kind === \"group\" ? <div key={`gb-${r.top}`} aria-hidden=\"true\" className=\"bg-muted/60 absolute inset-x-0\" style={{ top: r.top, height: r.h }} /> : <div key={`rb-${r.task.id}`} aria-hidden=\"true\" className=\"absolute inset-x-0 border-t\" style={{ top: r.top }} />))}\n\n                <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-[1] overflow-visible\" width={model.days * px} height={model.height}>\n                  {model.rows.flatMap((r) =>\n                    r.kind === \"task\"\n                      ? (r.task.dependsOn ?? []).flatMap((dep) => {\n                          const from = tasks.find((t) => t.id === dep)\n                          if (!from || model.tops[dep] === undefined) return []\n                          const x1 = x(from.end) + px\n                          const y1 = model.tops[dep]! + ROW / 2\n                          const x2 = x(r.task.start)\n                          const y2 = model.tops[r.task.id]! + ROW / 2\n                          return [\n                            <path\n                              key={`${dep}-${r.task.id}`}\n                              d={`M${x1} ${y1} H${x1 + 8} V${y2} H${Math.max(x2 - 5, x1 + 8)}`}\n                              className={cn(\"fill-none stroke-[1.25]\", selectedId === r.task.id || selectedId === dep ? \"stroke-foreground\" : \"stroke-muted-foreground/50\")}\n                            />,\n                            <path key={`a-${dep}-${r.task.id}`} d={`M${x2 - 6} ${y2 - 3.5} L${x2 - 1} ${y2} L${x2 - 6} ${y2 + 3.5} Z`} className={selectedId === r.task.id || selectedId === dep ? \"fill-foreground\" : \"fill-muted-foreground/60\"} />,\n                          ]\n                        })\n                      : []\n                  )}\n                </svg>\n\n                <div aria-hidden=\"true\" className=\"bg-foreground absolute inset-y-0 z-[2] w-px\" style={{ left: ((model.todayT - model.min) / DAY) * px + px / 2 }} />\n                <div aria-hidden=\"true\" className=\"bg-foreground text-background absolute top-1 z-[4] -translate-x-1/2 rounded px-1.5 py-px text-[10px] font-semibold\" style={{ left: ((model.todayT - model.min) / DAY) * px + px / 2 }}>\n                  Today\n                </div>\n\n                {model.rows.map((r) => {\n                  if (r.kind !== \"task\") return null\n                  const t = r.task\n                  const S = statusMeta[r.status]\n                  const pct = Math.round((t.progress ?? 0) * 100)\n                  const label = `${t.name}: ${dayFmt.format(toT(t.start))}${t.milestone ? \"\" : ` to ${dayFmt.format(toT(t.end))}`}, ${t.milestone ? \"milestone\" : `${pct}% done`}, ${S.label}`\n                  if (t.milestone)\n                    return (\n                      <button\n                        key={t.id}\n                        type=\"button\"\n                        data-bar\n                        aria-label={label}\n                        aria-pressed={selectedId === t.id}\n                        onClick={() => pick(t)}\n                        className=\"focus-visible:ring-ring/60 group/bar absolute z-[3] flex items-center gap-2 outline-none\"\n                        style={{ top: r.top + ROW / 2 - 10, left: x(t.start) + px / 2 - 10, height: 20 }}\n                      >\n                        <span aria-hidden=\"true\" className={cn(\"border-background size-5 rotate-45 rounded-[4px] border-2 transition-transform duration-150 group-hover/bar:scale-110 group-focus-visible/bar:ring-[3px] group-focus-visible/bar:ring-ring/60 motion-reduce:transition-none\", S.fill, selectedId === t.id && \"ring-foreground ring-2\")} />\n                        <span aria-hidden=\"true\" className=\"text-foreground bg-card/80 absolute start-7 rounded px-1 text-xs font-medium whitespace-nowrap\">\n                          {t.name}\n                        </span>\n                      </button>\n                    )\n                  const w = (lengthOf(t)) * px\n                  return (\n                    <button\n                      key={t.id}\n                      type=\"button\"\n                      data-bar\n                      aria-label={label}\n                      aria-pressed={selectedId === t.id}\n                      onClick={() => pick(t)}\n                      className={cn(\"focus-visible:ring-ring/60 group/bar absolute z-[3] overflow-hidden rounded-md border text-start outline-none transition-[box-shadow,transform] duration-150 hover:shadow-md focus-visible:ring-[3px] motion-reduce:transition-none\", S.bar, selectedId === t.id && \"ring-foreground ring-2\")}\n                      style={{ top: r.top + 8, left: x(t.start), width: w, height: ROW - 16 }}\n                    >\n                      <span aria-hidden=\"true\" className={cn(\"absolute inset-y-0 start-0 opacity-90\", S.fill)} style={{ width: `${pct}%` }} />\n                      {w > 74 ? (\n                        <span aria-hidden=\"true\" className=\"relative flex h-full items-center ps-2 text-[11px] font-semibold whitespace-nowrap mix-blend-normal\">\n                          <span className=\"bg-card/85 text-foreground rounded px-1 tabular-nums\">{pct}%</span>\n                        </span>\n                      ) : null}\n                    </button>\n                  )\n                })}\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"sr-only\">\n<table>\n          <caption>Project schedule</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">Task</th>\n              <th scope=\"col\">Section</th>\n              <th scope=\"col\">Start</th>\n              <th scope=\"col\">End</th>\n              <th scope=\"col\">Progress</th>\n              <th scope=\"col\">Status</th>\n              <th scope=\"col\">Waits for</th>\n            </tr>\n          </thead>\n          <tbody>\n            {tasks.map((t) => (\n              <tr key={t.id}>\n                <th scope=\"row\">{t.name}</th>\n                <td>{t.group ?? \"\"}</td>\n                <td>{dayFmt.format(toT(t.start))}</td>\n                <td>{dayFmt.format(toT(t.end))}</td>\n                <td>{t.milestone ? \"Milestone\" : `${Math.round((t.progress ?? 0) * 100)}%`}</td>\n                <td>{statusMeta[statusOf(t)].label}</td>\n                <td>{(t.dependsOn ?? []).map((d) => tasks.find((x) => x.id === d)?.name).filter(Boolean).join(\", \")}</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n</div>\n\n        <div aria-live=\"polite\" className=\"bg-card mt-4 rounded-2xl border p-5\">\n          {selected ? (\n            <div className=\"grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start\">\n              <div className=\"min-w-0\">\n                <p className=\"flex flex-wrap items-center gap-x-3 gap-y-1.5\">\n                  <span className=\"text-base font-semibold tracking-tight\">{selected.name}</span>\n                  <span className={cn(\"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium\", statusMeta[statusOf(selected)].chip)}>\n                    {React.createElement(statusMeta[statusOf(selected)].icon, { \"aria-hidden\": true, className: \"size-3.5\" })}\n                    {statusMeta[statusOf(selected)].label}\n                  </span>\n                </p>\n                <p className=\"text-muted-foreground mt-1.5 text-sm tabular-nums\">\n                  {selected.milestone ? dayFmt.format(toT(selected.start)) : `${dayFmt.format(toT(selected.start))} to ${dayFmt.format(toT(selected.end))} · ${lengthOf(selected)} ${lengthOf(selected) === 1 ? \"day\" : \"days\"}`}\n                  {selected.owner ? ` · ${selected.owner}` : \"\"}\n                </p>\n                {selected.dependsOn?.length ? (\n                  <p className=\"mt-2 text-sm text-pretty\">\n                    <span className=\"text-muted-foreground\">Waits for </span>\n                    {selected.dependsOn.map((d) => tasks.find((t) => t.id === d)?.name).filter(Boolean).join(\", \")}\n                  </p>\n                ) : null}\n              </div>\n              {!selected.milestone ? (\n                <div className=\"w-full sm:w-56\">\n                  <div className=\"flex justify-between text-xs\">\n                    <span className=\"text-muted-foreground\">Progress</span>\n                    <span className=\"font-medium tabular-nums\">{Math.round((selected.progress ?? 0) * 100)}%</span>\n                  </div>\n                  <div role=\"progressbar\" aria-label={`${selected.name} progress`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round((selected.progress ?? 0) * 100)} className=\"bg-muted mt-1.5 h-2 overflow-hidden rounded-full\">\n                    <div className={cn(\"h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none\", statusMeta[statusOf(selected)].fill)} style={{ width: `${Math.round((selected.progress ?? 0) * 100)}%` }} />\n                  </div>\n                </div>\n              ) : null}\n            </div>\n          ) : (\n            <p className=\"text-muted-foreground text-sm\">Select a bar to see its dates, who owns it and what it waits for.</p>\n          )}\n        </div>\n      </>\n    )\n\n  return (\n    <section data-slot=\"gantt-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-8 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-6 flex flex-wrap items-end gap-x-6 gap-y-4\">\n        <div className=\"min-w-0 flex-1 basis-80\">\n          <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em]\">\n            {title}\n          </h1>\n          <p className=\"text-muted-foreground mt-2 max-w-xl text-sm text-pretty\">{description}</p>\n        </div>\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <SegmentedControl value={scale} onValueChange={(v) => setScale(v as typeof scale)} aria-label=\"Zoom\" size=\"sm\" disabled={!ready || !model}>\n            <SegmentedControlItem value=\"days\">Days</SegmentedControlItem>\n            <SegmentedControlItem value=\"weeks\">Weeks</SegmentedControlItem>\n            <SegmentedControlItem value=\"months\">Months</SegmentedControlItem>\n          </SegmentedControl>\n          <Button variant=\"outline\" size=\"sm\" disabled={!ready || !model} onClick={() => jump()}>\n            <LocateFixed aria-hidden=\"true\" />\n            Today\n          </Button>\n        </div>\n      </header>\n      {ready && model ? (\n        <ul aria-label=\"Legend\" className=\"text-muted-foreground mb-4 flex flex-wrap gap-x-5 gap-y-1.5 text-xs\">\n          {(Object.keys(statusMeta) as Gantt1Status[]).map((k) => {\n            const S = statusMeta[k]\n            return (\n              <li key={k} className=\"flex items-center gap-1.5\">\n                <S.icon aria-hidden=\"true\" className=\"size-3.5\" />\n                {S.label}\n              </li>\n            )\n          })}\n          <li className=\"flex items-center gap-1.5\">\n            <span aria-hidden=\"true\" className=\"bg-foreground size-2.5 rotate-45 rounded-[2px]\" />\n            Milestone\n          </li>\n        </ul>\n      ) : null}\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the timeline\" loading={<GanttSkeleton />} error={{ title: errorState?.title ?? \"The timeline could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {chart}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Gantt1, type Gantt1Props, type Gantt1Task, type Gantt1Status }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/gantt-1/gantt-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "gantt",
      "timeline",
      "project",
      "schedule",
      "roadmap",
      "planning",
      "milestones",
      "dependencies",
      "project management"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A Gantt chart. <Gantt1 tasks={[{ id, name, start: '2026-10-05', end: '2026-10-16', progress: 0..1, group, owner, dependsOn: ['id'], milestone }]} today=\"2026-10-18\" defaultScale='days' | 'weeks' | 'months' defaultSelectedId locale onSelect />. Status is worked out for you: done at progress 1, late when the end date has passed, in progress when it has started, planned otherwise. Pass today yourself (an ISO date); the block never reads the clock. A milestone has the same start and end.",
      "whenToUse": [
        "Showing a project plan with dates, progress and dependencies",
        "A read-only schedule overview in a project tool or a client report"
      ],
      "whenNotToUse": [
        "A board of cards by status (use kanban-1)",
        "Booking rooms or people by the hour (use calendar-pro-1)",
        "A simple list of dated items (use timeline or tasks-1)"
      ],
      "composesWith": [
        "tasks-1",
        "kanban-1",
        "app-shell-1",
        "calendar-1"
      ],
      "a11y": [
        {
          "keys": "Tab, then Up / Down / Home / End",
          "action": "Move between bars; each bar's name says its dates, progress and status in words"
        },
        {
          "keys": "Table",
          "action": "A hidden table lists every task with start, end, progress, status and what it waits for, so the chart has a text equivalent"
        },
        {
          "keys": "Late",
          "action": "Marked with an icon and the word Late in the task column and the details, not only by color"
        }
      ],
      "customization": [
        "defaultScale: days (44 px a day), weeks (18) or months (6)",
        "dependsOn draws arrows; a task that starts before what it depends on shows the arrow running backwards, which is the sign of a planning problem",
        "The timeline runs left to right in every language, like a chart"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "gantt-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton chart under the real heading, announced as loading; zoom and Today are disabled."
      },
      {
        "id": "empty",
        "title": "No tasks yet",
        "example": "gantt-1-empty",
        "trigger": "state=\"empty\" (or tasks={[]})",
        "note": "Says the plan draws itself once tasks have dates; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "gantt-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry)."
      }
    ],
    "examples": [
      "gantt-1-demo",
      "gantt-1-days",
      "gantt-1-months",
      "gantt-1-loading",
      "gantt-1-empty",
      "gantt-1-error"
    ],
    "url": "https://ui.ballmac.com/components/gantt-1"
  },
  "categories": [
    "blocks",
    "gantt"
  ],
  "type": "registry:block"
}