{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "todo-list",
  "title": "To-do list",
  "description": "A compact checklist card with an n-of-m count and springy progress bar, where a done step's tick pops in and a hand-drawn scribble strikes its title through.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/todo-list/todo-list.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotionConfig, type Transition } from \"motion/react\";\nimport { ChevronDown, Sparkles } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A short to-do card: a title, an \"n of m\" count, a springy progress bar and\n * the steps. A done step gets a filled tick that pops in and a hand-drawn\n * scribble that strikes its title through; on first show, the done steps draw\n * their scribbles one after another in reading order, never all at once.\n *\n * Steps can lead somewhere (`href` or `onItemClick`), be ticked by hand\n * (`onToggle`), or both. Without `onToggle` the tick is a read-out of `done`,\n * which is the right shape when the step is marked done by doing the thing\n * elsewhere rather than by clicking a box.\n *\n * On a phone (`fold=\"mobile\"`) the card rests as its header and progress bar\n * and a tap unfolds the steps; `collapsed` turns it into a square count chip\n * for an icon-only rail.\n */\n\nexport type TodoItem = {\n  id: string;\n  title: string;\n  done: boolean;\n  /** Where the step leads. Renders the title as a link. */\n  href?: string;\n};\n\nexport type TodoListProps = {\n  items: TodoItem[];\n  /** Ticks or unticks a step. Without it the ticks are read-only. May return a Promise; the tick shows the new state until it settles, and reverts if it rejects. */\n  onToggle?: (id: string, done: boolean) => void | Promise<void>;\n  /** A step's title was activated. Fires alongside the link when the item has an href. */\n  onItemClick?: (item: TodoItem) => void;\n  /** Card heading. Defaults to \"Getting started\". */\n  title?: string;\n  /** Square count chip instead of the card, for a collapsed sidebar rail. */\n  collapsed?: boolean;\n  /** Where the collapsed chip leads. Without it the chip is a button firing onCollapsedClick. */\n  collapsedHref?: string;\n  onCollapsedClick?: () => void;\n  /**\n   * When the steps hide behind a tap on the header. \"mobile\" (the default)\n   * folds below the md breakpoint only; \"always\" folds at every width.\n   */\n  fold?: \"never\" | \"mobile\" | \"always\";\n  /** Colour of the ticks, scribbles and progress bar. Defaults to brand. */\n  tone?: \"brand\" | \"lead\" | \"go\";\n  className?: string;\n};\n\nconst TONES = {\n  brand: { fill: \"bg-brand\", stroke: \"stroke-brand\", text: \"text-brand\" },\n  lead: { fill: \"bg-lead\", stroke: \"stroke-lead\", text: \"text-lead\" },\n  go: { fill: \"bg-go\", stroke: \"stroke-go\", text: \"text-go\" },\n} as const;\n\n/** Done steps draw one after another in reading order, never all at once. */\nconst SCRIBBLE_STAGGER_S = 0.3;\nconst SCRIBBLE_DRAW_S = 0.7;\n\nconst SCRIBBLE_PATH =\n  \"M 10 16.91 s 79.8 -11.36 98.1 -11.34 c 22.2 0.02 -47.82 14.25 -33.39 22.02 c 12.61 6.77 124.18 -27.98 133.31 -17.28 c 7.52 8.38 -26.8 20.02 4.61 22.05 c 24.55 1.93 113.37 -20.36 113.37 -20.36\";\n\nconst pathAnimate = (checked: boolean) => ({\n  pathLength: checked ? 1 : 0,\n  opacity: checked ? 1 : 0,\n});\n\nconst pathTransition = (checked: boolean, delay: number): Transition => ({\n  pathLength: { duration: SCRIBBLE_DRAW_S, ease: \"easeInOut\", delay: checked ? delay : 0 },\n  opacity: { duration: 0.01, delay: checked ? delay : SCRIBBLE_DRAW_S },\n});\n\nconst TICK_SPRING = { type: \"spring\", stiffness: 400, damping: 25 } as const;\n\nexport function TodoList({\n  items,\n  onToggle,\n  onItemClick,\n  title = \"Getting started\",\n  collapsed = false,\n  collapsedHref,\n  onCollapsedClick,\n  fold = \"mobile\",\n  tone = \"brand\",\n  className,\n}: TodoListProps) {\n  const reduceMotion = useReducedMotionConfig();\n  const listId = React.useId();\n  const colours = TONES[tone];\n\n  const [expanded, setExpanded] = React.useState(false);\n  // Optimistic ticks while an onToggle promise is in flight.\n  const [pending, setPending] = React.useState<Record<string, boolean>>({});\n  // Steps ticked by hand draw at once; only the first-show sequence staggers.\n  const [touched, setTouched] = React.useState<ReadonlySet<string>>(() => new Set());\n\n  const isDone = (item: TodoItem) => pending[item.id] ?? item.done;\n  const completed = items.filter(isDone).length;\n  const total = items.length;\n  const progressPercent = total === 0 ? 0 : Math.round((completed / total) * 100);\n  const allDone = total > 0 && completed >= total;\n\n  const toggle = async (item: TodoItem) => {\n    if (!onToggle) return;\n    const next = !isDone(item);\n    setTouched((prev) => new Set(prev).add(item.id));\n    setPending((prev) => ({ ...prev, [item.id]: next }));\n    try {\n      await onToggle(item.id, next);\n    } catch {\n      // Falls back to the item's own `done`, which is the revert.\n    } finally {\n      setPending((prev) => {\n        const rest = { ...prev };\n        delete rest[item.id];\n        return rest;\n      });\n    }\n  };\n\n  if (collapsed) {\n    const label = `${title} (${completed} of ${total} complete)`;\n    const chipClass =\n      \"group relative mx-auto flex h-10 w-10 items-center justify-center rounded-xl border border-rule bg-card text-ink transition-all hover:bg-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n    const chipBody = (\n      <>\n        {allDone ? (\n          <Sparkles className={cn(\"h-4 w-4\", colours.text)} aria-hidden=\"true\" />\n        ) : (\n          <span className=\"font-body text-xs font-black\" aria-hidden=\"true\">\n            {completed}/{total}\n          </span>\n        )}\n        <span\n          className={cn(\"absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full border-2 border-card\", colours.fill)}\n          aria-hidden=\"true\"\n        />\n      </>\n    );\n    return collapsedHref ? (\n      <a href={collapsedHref} aria-label={label} title={label} className={cn(chipClass, className)}>\n        {chipBody}\n      </a>\n    ) : (\n      <button\n        type=\"button\"\n        onClick={onCollapsedClick}\n        aria-label={label}\n        title={label}\n        className={cn(chipClass, className)}\n      >\n        {chipBody}\n      </button>\n    );\n  }\n\n  // Fold classes: which width the fold button shows at, and which hides the list.\n  const foldButtonClass = { never: \"hidden\", mobile: \"md:hidden\", always: \"\" }[fold];\n  const staticSummaryClass = { never: \"\", mobile: \"max-md:hidden\", always: \"hidden\" }[fold];\n  const listHiddenClass = expanded ? \"\" : { never: \"\", mobile: \"max-md:hidden\", always: \"hidden\" }[fold];\n\n  // Title, count and progress bar: the whole card when folded, the top of it\n  // otherwise. A half-filled bar stays in the folded row as the nudge to finish.\n  const summary = (\n    <>\n      <span className=\"flex items-center justify-between gap-2\">\n        {/* A span, not a heading: on a phone this sits inside the fold's\n            button, and a button may only hold phrasing content. */}\n        <span className=\"font-body text-xs leading-[1.5] font-semibold tracking-[-0.03em] text-ink\">\n          {title}\n        </span>\n        <span className=\"flex items-center gap-1 text-[11px] font-semibold tracking-wide text-dim\">\n          {completed} of {total}\n          <ChevronDown\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-3.5 transition-transform duration-200\",\n              foldButtonClass,\n              expanded && \"rotate-180\",\n            )}\n          />\n        </span>\n      </span>\n\n      <span className=\"mt-2.5 block h-1.5 w-full overflow-hidden rounded-full bg-paper-sunk\">\n        <motion.span\n          className={cn(\"block h-full rounded-full\", colours.fill)}\n          initial={{ width: 0 }}\n          animate={{ width: `${progressPercent}%` }}\n          transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 300, damping: 30 }}\n        />\n      </span>\n    </>\n  );\n\n  return (\n    <div\n      className={cn(\n        \"relative rounded-panel border border-rule bg-card/90 p-4 font-body text-ink transition-all duration-300\",\n        fold === \"mobile\" && \"max-md:py-3\",\n        fold === \"always\" && \"py-3\",\n        className,\n      )}\n    >\n      {/* Folding widths: the summary is the fold's button. Elsewhere it is\n          plain text, since a button that does nothing would still be a tab stop. */}\n      {fold !== \"never\" && (\n        <button\n          type=\"button\"\n          onClick={() => setExpanded((open) => !open)}\n          aria-expanded={expanded}\n          aria-controls={listId}\n          className={cn(\n            \"-m-1.5 block w-[calc(100%+0.75rem)] cursor-pointer rounded-lg p-1.5 text-left focus-visible:ring-2 focus-visible:ring-lead/40 focus-visible:outline-none\",\n            foldButtonClass,\n          )}\n        >\n          {summary}\n        </button>\n      )}\n      <div className={staticSummaryClass}>{summary}</div>\n\n      {/* Keyed on the fold so each unfolding remounts the list and the done\n          steps draw their scribbles again. */}\n      <ul key={expanded ? \"unfolded\" : \"folded\"} id={listId} className={cn(\"mt-3.5 space-y-2\", listHiddenClass)}>\n        {items.map((item, index) => {\n          const done = isDone(item);\n          const delay = touched.has(item.id) ? 0 : index * SCRIBBLE_STAGGER_S;\n\n          const tick = (\n            <span\n              className={cn(\n                \"flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-all duration-200\",\n                done\n                  ? cn(colours.fill, \"text-on-solid shadow-xs\")\n                  : \"border border-faint text-transparent group-hover:border-dim\",\n              )}\n            >\n              <motion.span\n                aria-hidden=\"true\"\n                initial={false}\n                animate={{ scale: done ? 1 : 0, opacity: done ? 1 : 0 }}\n                transition={reduceMotion ? { duration: 0 } : TICK_SPRING}\n              >\n                ✓\n              </motion.span>\n            </span>\n          );\n\n          // Shrink-wrapped so the scribble ends where the text ends; truncation\n          // lives on the inner span, so a long title still ellipsizes and the\n          // scribble matches the visible width.\n          const label = (\n            <span className=\"relative inline-block max-w-full\">\n              <span\n                className={cn(\n                  \"block truncate transition-colors duration-200\",\n                  done ? \"font-normal text-faint\" : \"font-medium text-ink/85 group-hover:text-ink\",\n                )}\n              >\n                {item.title}\n              </span>\n              <motion.svg\n                width=\"220\"\n                height=\"24\"\n                viewBox=\"0 0 340 32\"\n                preserveAspectRatio=\"none\"\n                aria-hidden=\"true\"\n                // Taller than the line so the wave's loops fit; overflow-visible\n                // because the lowest loop overshoots the viewBox.\n                className=\"pointer-events-none absolute top-1/2 left-0 z-20 h-8 w-full -translate-y-1/2 overflow-visible\"\n              >\n                <motion.path\n                  d={SCRIBBLE_PATH}\n                  vectorEffect=\"non-scaling-stroke\"\n                  strokeWidth={2.4}\n                  strokeLinecap=\"round\"\n                  strokeMiterlimit={10}\n                  fill=\"none\"\n                  // Mount from undrawn so done steps draw each time the list\n                  // appears. Undone steps rest at zero, so nothing flashes.\n                  initial={reduceMotion ? false : { pathLength: 0, opacity: 0 }}\n                  animate={pathAnimate(done)}\n                  transition={reduceMotion ? { duration: 0 } : pathTransition(done, delay)}\n                  className={colours.stroke}\n                />\n              </motion.svg>\n              <span className=\"sr-only\">{done ? \" (done)\" : \" (not done)\"}</span>\n            </span>\n          );\n\n          const rowClass =\n            \"group -mx-1.5 flex items-center gap-2.5 rounded-lg px-1.5 py-2.5 text-xs transition-colors hover:bg-paper\";\n          const titleBox = <span className=\"min-w-0 flex-1\">{label}</span>;\n          const goesSomewhere = Boolean(item.href || onItemClick);\n          const focusClass =\n            \"focus-visible:ring-2 focus-visible:ring-lead/40 focus-visible:outline-none\";\n\n          // Read-only ticks: the whole row is the link, as in a nav list.\n          if (!onToggle) {\n            return (\n              <li key={item.id}>\n                {item.href ? (\n                  <a href={item.href} onClick={() => onItemClick?.(item)} className={cn(rowClass, focusClass)}>\n                    {tick}\n                    {titleBox}\n                  </a>\n                ) : onItemClick ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => onItemClick(item)}\n                    className={cn(rowClass, focusClass, \"w-[calc(100%+0.75rem)] text-left\")}\n                  >\n                    {tick}\n                    {titleBox}\n                  </button>\n                ) : (\n                  <div className={rowClass}>\n                    {tick}\n                    {titleBox}\n                  </div>\n                )}\n              </li>\n            );\n          }\n\n          // Tickable: the tick is its own checkbox, and the title leads\n          // somewhere if it can, or ticks the box if it cannot.\n          return (\n            <li key={item.id} className={rowClass}>\n              <button\n                type=\"button\"\n                role=\"checkbox\"\n                aria-checked={done}\n                aria-label={item.title}\n                onClick={() => void toggle(item)}\n                className={cn(\n                  \"relative -m-1 shrink-0 rounded-full p-1\",\n                  focusClass,\n                  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\",\n                )}\n              >\n                {tick}\n              </button>\n              {item.href ? (\n                <a\n                  href={item.href}\n                  onClick={() => onItemClick?.(item)}\n                  className={cn(\"min-w-0 flex-1 rounded-sm\", focusClass)}\n                >\n                  {label}\n                </a>\n              ) : goesSomewhere ? (\n                <button\n                  type=\"button\"\n                  onClick={() => onItemClick?.(item)}\n                  className={cn(\"min-w-0 flex-1 rounded-sm text-left\", focusClass)}\n                >\n                  {label}\n                </button>\n              ) : (\n                <span\n                  className=\"min-w-0 flex-1 cursor-pointer\"\n                  onClick={() => void toggle(item)}\n                  aria-hidden=\"true\"\n                >\n                  {label}\n                </span>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use for a short list of steps to finish, such as onboarding or prep for a task, in a sidebar or beside the work; ticks can be read-only (marked done elsewhere) or tickable. Do not use for long or sortable task lists, or for multi-select in a form; use a table or the checkbox primitive.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/sidebar-checklist.tsx"
    },
    "credits": []
  },
  "type": "registry:component"
}
