{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "checklist",
  "type": "registry:ui",
  "title": "Checklist",
  "description": "Tasks that strike through, then glide into Completed, with a rolling count.",
  "categories": [
    "selection"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/checklist.tsx",
      "content": "\"use client\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronDown, Plus, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ChecklistItem = {\n  id: string;\n  label: string;\n  done: boolean;\n  /** Short trailing detail: a due date, an owner, an estimate. */\n  meta?: string;\n};\n\nexport type ChecklistProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"title\"> & {\n  items?: ChecklistItem[];\n  defaultItems?: ChecklistItem[];\n  onItemsChange?: (items: ChecklistItem[]) => void;\n  /** Heading with the rolling progress count. Without it the list is labeled by aria-label. */\n  title?: string;\n  /** Move checked tasks into a Completed section below, after the strike has drawn. */\n  moveCompleted?: boolean;\n  /** Show the inline \"Add task\" row. */\n  allowAdd?: boolean;\n  addLabel?: string;\n  placeholder?: string;\n  /** A remove button on each task (on hover and focus with a mouse, always on touch). */\n  removable?: boolean;\n  /** Milliseconds between the check and the move, so the strike is seen first. */\n  moveDelay?: number;\n};\n\nconst newId = () => (typeof crypto !== \"undefined\" && \"randomUUID\" in crypto ? crypto.randomUUID() : `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`);\n\nexport function Checklist({\n  items: itemsProp,\n  defaultItems = [],\n  onItemsChange,\n  title,\n  moveCompleted = true,\n  allowAdd = true,\n  addLabel = \"Add task\",\n  placeholder = \"What needs doing?\",\n  removable = false,\n  moveDelay = 450,\n  className,\n  \"aria-label\": ariaLabel,\n  ref,\n  ...rest\n}: ChecklistProps) {\n  const [items, setItems] = useControllableState({ value: itemsProp, defaultValue: defaultItems, onChange: onItemsChange });\n  // While a task waits to move, it is held in the section it was in. Everything else is placed by `done`.\n  const [held, setHeld] = useState<Record<string, \"open\" | \"done\">>({});\n  const [showDone, setShowDone] = useState(true);\n  const [adding, setAdding] = useState(false);\n  const [draft, setDraft] = useState(\"\");\n  const [announcement, setAnnouncement] = useState(\"\");\n  const timers = useRef(new Map<string, number>());\n  const rootRef = useRef<HTMLDivElement>(null);\n  // Our focus management needs the root; the caller's ref still gets it too.\n  const setRootRef = useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node;\n      if (typeof ref === \"function\") return ref(node);\n      if (ref) ref.current = node;\n    },\n    [ref],\n  );\n  const inputRef = useRef<HTMLInputElement>(null);\n  const reduce = useReducedMotion();\n  const titleId = useId();\n  const doneId = useId();\n\n  useEffect(() => {\n    const map = timers.current;\n    return () => map.forEach((t) => window.clearTimeout(t));\n  }, []);\n\n  const total = items.length;\n  const complete = items.filter((i) => i.done).length;\n  const section = (i: ChecklistItem) => (moveCompleted ? (held[i.id] ?? (i.done ? \"done\" : \"open\")) : \"open\");\n  const open = items.filter((i) => section(i) === \"open\");\n  const done = items.filter((i) => section(i) === \"done\");\n\n  const focusCheckbox = (id: string | undefined) => {\n    const root = rootRef.current;\n    if (!root) return;\n    const el = id ? root.querySelector<HTMLElement>(`[data-task=\"${CSS.escape(id)}\"] [role=checkbox]`) : root.querySelector<HTMLElement>(\"[data-add]\");\n    el?.focus();\n  };\n\n  const toggle = (item: ChecklistItem, next: boolean) => {\n    setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, done: next } : i)));\n    setAnnouncement(next ? `Completed ${item.label}` : `Reopened ${item.label}`);\n    if (!moveCompleted) return;\n    const from = section(item);\n    window.clearTimeout(timers.current.get(item.id));\n    setHeld((h) => ({ ...h, [item.id]: from }));\n    timers.current.set(\n      item.id,\n      window.setTimeout(() => {\n        timers.current.delete(item.id);\n        // If the keyboard is still on this task, hand focus to the next open one before it moves away.\n        const root = rootRef.current;\n        const active = document.activeElement;\n        const onIt = !!active && !!root?.querySelector(`[data-task=\"${CSS.escape(item.id)}\"]`)?.contains(active);\n        setHeld((h) => {\n          const { [item.id]: _gone, ...restHeld } = h;\n          void _gone;\n          return restHeld;\n        });\n        if (onIt && next) {\n          const list = Array.from(root?.querySelectorAll<HTMLElement>(\"[data-section=open] [data-task]\") ?? []);\n          const index = list.findIndex((el) => el.dataset.task === item.id);\n          const target = list[index + 1] ?? list[index - 1];\n          focusCheckbox(target?.dataset.task);\n        } else if (onIt) {\n          requestAnimationFrame(() => focusCheckbox(item.id));\n        }\n      }, reduce ? 150 : moveDelay),\n    );\n  };\n\n  const remove = (item: ChecklistItem) => {\n    // The button under focus is about to disappear: hand focus to the neighbouring task in the\n    // same section, or to the add row, so the keyboard user isn't dropped onto the page.\n    const root = rootRef.current;\n    const rows = Array.from(root?.querySelectorAll<HTMLElement>(`[data-section=${section(item)}] [data-task]`) ?? []);\n    const index = rows.findIndex((el) => el.dataset.task === item.id);\n    const neighbour = (rows[index + 1] ?? rows[index - 1])?.dataset.task;\n    window.clearTimeout(timers.current.get(item.id));\n    timers.current.delete(item.id);\n    setHeld((h) => {\n      if (!(item.id in h)) return h;\n      const { [item.id]: _gone, ...restHeld } = h;\n      void _gone;\n      return restHeld;\n    });\n    setItems((prev) => prev.filter((i) => i.id !== item.id));\n    setAnnouncement(`Removed ${item.label}`);\n    if (root?.querySelector(`[data-task=\"${CSS.escape(item.id)}\"]`)?.contains(document.activeElement)) {\n      requestAnimationFrame(() => focusCheckbox(neighbour));\n    }\n  };\n\n  const add = () => {\n    const label = draft.trim();\n    if (!label) return;\n    setItems((prev) => [...prev, { id: newId(), label, done: false }]);\n    setDraft(\"\");\n    setAnnouncement(`Added ${label}`);\n  };\n\n  const row = (item: ChecklistItem) => (\n    <TaskRow key={item.id} item={item} removable={removable} reduce={!!reduce} onToggle={(v) => toggle(item, v)} onRemove={() => remove(item)} />\n  );\n\n  return (\n    <div ref={setRootRef} className={cn(\"flex w-full flex-col\", className)} {...rest}>\n      {title && (\n        <div className=\"mb-3 flex flex-col gap-2.5 px-2\">\n          <div className=\"flex items-baseline justify-between gap-3\">\n            <h3 id={titleId} className=\"min-w-0 truncate text-[14px] font-medium tracking-[-0.015em] text-fg\">\n              {title}\n            </h3>\n            <span className=\"tabular shrink-0 text-[12px] text-fg-3\">\n              <NumberFlow value={complete} className={cn(\"transition-colors\", total > 0 && complete === total ? \"text-success\" : \"text-fg-2\")} /> of {total}\n            </span>\n          </div>\n          <div aria-hidden className=\"h-[3px] overflow-hidden rounded-full bg-line-2\">\n            <motion.div\n              className={cn(\"h-full origin-left rounded-full transition-colors duration-300\", total > 0 && complete === total ? \"bg-success\" : \"bg-fg\")}\n              initial={false}\n              animate={{ scaleX: total ? complete / total : 0 }}\n              transition={reduce ? { duration: 0 } : spring.soft}\n            />\n          </div>\n        </div>\n      )}\n\n      <LayoutGroup>\n        <ul data-section=\"open\" aria-labelledby={title ? titleId : undefined} aria-label={title ? undefined : ariaLabel} className=\"flex flex-col\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {open.map(row)}\n          </AnimatePresence>\n        </ul>\n\n        {total === 0 && !adding && <p className=\"px-2 py-1.5 text-[12.5px] text-fg-3\">No tasks yet</p>}\n        {total > 0 && open.length === 0 && !adding && (\n          <motion.p\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            transition={{ duration: 0.24, ease: ease.out, delay: 0.1 }}\n            className=\"px-2 py-1.5 text-[12.5px] text-fg-3\"\n          >\n            Everything’s done\n          </motion.p>\n        )}\n\n        {allowAdd && (\n          <motion.div layout={reduce ? false : \"position\"} transition={spring.soft} className=\"relative\">\n            {adding ? (\n              <form\n                className=\"flex h-9 items-center gap-2.5 rounded-lg px-2\"\n                onSubmit={(e) => {\n                  e.preventDefault();\n                  add();\n                }}\n              >\n                <span aria-hidden className=\"size-4 shrink-0 rounded-[5px] border border-dashed border-fg-4\" />\n                <input\n                  ref={inputRef}\n                  autoFocus\n                  value={draft}\n                  onChange={(e) => setDraft(e.target.value)}\n                  onKeyDown={(e) => {\n                    if (e.key === \"Escape\") {\n                      e.preventDefault();\n                      setDraft(\"\");\n                      setAdding(false);\n                      requestAnimationFrame(() => focusCheckbox(undefined));\n                    }\n                  }}\n                  onBlur={() => {\n                    if (!draft.trim()) setAdding(false);\n                  }}\n                  aria-label=\"New task\"\n                  placeholder={placeholder}\n                  enterKeyHint=\"done\"\n                  autoComplete=\"off\"\n                  className=\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n                />\n                <kbd className=\"hidden shrink-0 font-mono text-2xs text-fg-4 sm:block\">Enter</kbd>\n              </form>\n            ) : (\n              <button\n                type=\"button\"\n                data-add=\"\"\n                onClick={() => setAdding(true)}\n                className={cn(\n                  \"flex h-9 w-full items-center gap-2.5 rounded-lg px-2 text-left text-[13px] text-fg-3\",\n                  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n                  \"transition-[background-color,color] duration-150 hover:bg-hover hover:text-fg-2 active:bg-hover\",\n                )}\n              >\n                <span aria-hidden className=\"grid size-4 shrink-0 place-items-center\">\n                  <Plus size={14} />\n                </span>\n                {addLabel}\n              </button>\n            )}\n          </motion.div>\n        )}\n\n        {moveCompleted && done.length > 0 && (\n          <motion.div layout={reduce ? false : \"position\"} transition={spring.soft} className=\"mt-3 flex flex-col border-t border-line pt-2\">\n            <button\n              type=\"button\"\n              aria-expanded={showDone}\n              aria-controls={doneId}\n              onClick={() => setShowDone((s) => !s)}\n              className={cn(\n                \"flex h-7 items-center gap-1.5 self-start rounded-md px-2 font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\",\n                \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n                \"transition-[color,scale] duration-150 ease-out hover:text-fg-2 active:scale-[0.97] active:duration-75\",\n              )}\n            >\n              Completed\n              <span className=\"tabular text-fg-4\">\n                <NumberFlow value={done.length} />\n              </span>\n              <ChevronDown size={12} className={cn(\"transition-transform duration-200 ease-out-expo\", !showDone && \"-rotate-90\")} />\n            </button>\n            <ul id={doneId} data-section=\"done\" aria-label=\"Completed\" className=\"flex flex-col\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {showDone && done.map(row)}\n              </AnimatePresence>\n            </ul>\n          </motion.div>\n        )}\n      </LayoutGroup>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction TaskRow({\n  item,\n  removable,\n  reduce,\n  onToggle,\n  onRemove,\n  ref,\n}: {\n  item: ChecklistItem;\n  removable: boolean;\n  reduce: boolean;\n  onToggle: (done: boolean) => void;\n  onRemove: () => void;\n  ref?: React.Ref<HTMLLIElement>;\n}) {\n  return (\n    <motion.li\n      ref={ref}\n      layout={reduce ? false : \"position\"}\n      layoutId={reduce ? undefined : item.id}\n      data-task={item.id}\n      data-state={item.done ? \"done\" : \"open\"}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\" }}\n      animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n      transition={reduce ? { duration: 0.15 } : { ...spring.soft, opacity: { duration: 0.2 }, filter: { duration: 0.2 } }}\n      className=\"group/task relative\"\n    >\n      <label className=\"flex min-h-9 cursor-default items-start gap-2.5 rounded-lg px-2 py-2 transition-colors duration-150 hover:bg-hover\">\n        <Checkbox.Root\n          checked={item.done}\n          onCheckedChange={(v) => onToggle(v)}\n          className={cn(\n            \"relative mt-px grid size-4 shrink-0 place-items-center rounded-[5px] border\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            \"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.88] active:duration-75\",\n            \"border-fg-4 bg-raised group-hover/task:border-fg-3 data-checked:border-fg data-checked:bg-fg\",\n            // 44px touch target around a 16px box.\n            \"after:absolute after:-inset-3.5 after:content-['']\",\n          )}\n        >\n          <svg aria-hidden viewBox=\"0 0 16 16\" className=\"size-3 text-frame\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <path\n              d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n              pathLength={1}\n              strokeDasharray=\"1\"\n              className={cn(\n                \"transition-[stroke-dashoffset] motion-reduce:transition-none\",\n                item.done ? \"[stroke-dashoffset:0] delay-50 duration-[260ms] ease-out\" : \"[stroke-dashoffset:1] duration-100 ease-out\",\n              )}\n            />\n          </svg>\n        </Checkbox.Root>\n        <span className=\"min-w-0 flex-1 text-[13px] leading-[18px]\">\n          {/* The strike is a 1px background that sweeps left to right, across every wrapped line in reading order. */}\n          <span\n            className={cn(\n              \"bg-[linear-gradient(var(--fg-3),var(--fg-3))] bg-no-repeat [background-position:0_55%]\",\n              \"transition-[background-size,color] duration-300 ease-out-expo motion-reduce:transition-colors\",\n              item.done ? \"text-fg-3 [background-size:100%_1px]\" : \"text-fg [background-size:0%_1px]\",\n            )}\n          >\n            {item.label}\n          </span>\n        </span>\n        {item.meta && <span className=\"tabular mt-px shrink-0 text-[12px] leading-[18px] text-fg-3\">{item.meta}</span>}\n        {removable && <span aria-hidden className=\"w-6 shrink-0\" />}\n      </label>\n      {removable && (\n        <button\n          type=\"button\"\n          onClick={onRemove}\n          aria-label={`Remove ${item.label}`}\n          className={cn(\n            \"absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md text-fg-4\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n            \"transition-[opacity,color,background-color,scale] duration-150 ease-out hover:bg-line hover:text-fg-2 active:scale-[0.9] active:duration-75\",\n            \"opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/task:opacity-100 focus-visible:opacity-100\",\n          )}\n        >\n          <X size={14} />\n        </button>\n      )}\n    </motion.li>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/checklist"
}
