{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comments-1",
  "title": "Comments 1: threaded discussion",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A threaded discussion: a composer where @ offers people, replies, emoji reactions that count and toggle, editing and deleting your own, resolving a thread, and a comment that appears at once and stays with Retry if it cannot be saved.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/textarea.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/comments-1/comments-1.tsx",
      "content": "// Ballmac UI: Comments 1. https://ui.ballmac.com/blocks/comments-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, CircleAlert, CornerDownRight, Loader2, MessageSquare, Pencil, SmilePlus, Trash2, Undo2 } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Textarea } from \"@/components/ballmac/textarea\"\nimport { cn } from \"@/lib/utils\"\n\ntype Comments1Person = { id: string; name: string }\ntype Comments1Comment = {\n  id: string\n  author: string\n  body: string\n  /** ISO date and time in UTC. */\n  at: string\n  /** Emoji to the names of the people who reacted. */\n  reactions?: Record<string, string[]>\n  edited?: boolean\n  replies?: Comments1Comment[]\n  resolvedBy?: string\n}\n\ntype Comments1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    comments?: Comments1Comment[]\n    people?: Comments1Person[]\n    /** Your name, so your comments can be edited and your reactions toggled. */\n    me?: string\n    /** An ISO date and time in UTC for \"now\". The block never reads the clock. */\n    now?: string\n    title?: string\n    /** Called to post a comment or a reply. Return a promise; reject and it stays with Not posted and Retry. */\n    onPost?: (input: { body: string; replyTo?: string }) => void | Promise<void>\n    onEdit?: (id: string, body: string) => void\n    onDelete?: (id: string) => void\n    onReact?: (id: string, emoji: string, on: boolean) => void\n    onResolve?: (id: string, resolved: boolean) => void\n  }\n\nconst team: Comments1Person[] = [{ id: \"p1\", name: \"Priya Shah\" }, { id: \"p2\", name: \"Theo Nakamura\" }, { id: \"p3\", name: \"Noor Haddad\" }, { id: \"p4\", name: \"Sam Okafor\" }, { id: \"p5\", name: \"Mara Ellison\" }]\n\nconst sample: Comments1Comment[] = [\n  { id: \"c1\", author: \"Noor Haddad\", at: \"2026-10-18T08:15:00Z\", body: \"The new onboarding flow reads much better. @Priya Shah could we try the shorter headline on step two?\", reactions: { \"👍\": [\"Priya Shah\", \"Sam Okafor\", \"Theo Nakamura\"], \"🎉\": [\"Mara Ellison\"] }, replies: [\n    { id: \"c1a\", author: \"Priya Shah\", at: \"2026-10-18T08:40:00Z\", body: \"Yes, shipping it to ten percent this afternoon.\", reactions: { \"👍\": [\"Noor Haddad\"] } },\n    { id: \"c1b\", author: \"Sam Okafor\", at: \"2026-10-18T09:05:00Z\", body: \"Support will watch the queue for confusion. @Noor Haddad send me the new copy and I will update the help article.\" },\n  ] },\n  { id: \"c2\", author: \"Theo Nakamura\", at: \"2026-10-17T16:30:00Z\", body: \"Heads up: the import step times out above 20,000 rows. Not a blocker for this release.\", reactions: { \"👀\": [\"Priya Shah\"] }, edited: true, replies: [] },\n  { id: \"c3\", author: \"Mara Ellison\", at: \"2026-10-15T11:00:00Z\", body: \"Pricing page copy is approved. Closing this thread.\", resolvedBy: \"Mara Ellison\", replies: [{ id: \"c3a\", author: \"Sam Okafor\", at: \"2026-10-15T11:20:00Z\", body: \"Thanks, publishing now.\" }] },\n]\n\nconst emojis = [\"👍\", \"🎉\", \"❤️\", \"👀\", \"🙏\"]\n\nfunction ago(iso: string, now: string) {\n  const s = Math.round((Date.parse(now) - Date.parse(iso)) / 1000)\n  if (s < 60) return \"just now\"\n  const m = Math.round(s / 60)\n  if (m < 60) return `${m} min ago`\n  const h = Math.round(m / 60)\n  if (h < 24) return `${h} ${h === 1 ? \"hour\" : \"hours\"} ago`\n  const d = Math.round(h / 24)\n  return d === 1 ? \"yesterday\" : `${d} days ago`\n}\n\nfunction Body({ text, people }: { text: string; people: Comments1Person[] }) {\n  // Highlights each @Name that is a real person, keeping the rest as plain text.\n  const names = people.map((p) => p.name).sort((a, b) => b.length - a.length)\n  const out: React.ReactNode[] = []\n  let rest = text\n  let k = 0\n  while (rest) {\n    const at = rest.indexOf(\"@\")\n    if (at === -1) { out.push(rest); break }\n    if (at > 0) out.push(rest.slice(0, at))\n    const hit = names.find((n) => rest.slice(at + 1).startsWith(n))\n    if (hit) { out.push(<span key={k++} className=\"bg-chart-1/15 rounded px-1 font-medium\">@{hit}</span>); rest = rest.slice(at + 1 + hit.length) } else { out.push(\"@\"); rest = rest.slice(at + 1) }\n  }\n  return <>{out}</>\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-5\">\n      <Skeleton className=\"h-32 rounded-2xl\" />\n      {[0, 1, 2].map((i) => (\n        <div key={i} className=\"flex gap-3\">\n          <Skeleton className=\"size-10 rounded-full\" />\n          <Skeleton className=\"h-24 flex-1 rounded-2xl\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n\nfunction Composer({ people, label, placeholder, onSubmit, onCancel, initial = \"\", cta = \"Comment\", autoFocus }: { people: Comments1Person[]; label: string; placeholder: string; onSubmit: (body: string) => void; onCancel?: () => void; initial?: string; cta?: string; autoFocus?: boolean }) {\n  const id = React.useId()\n  const [text, setText] = React.useState(initial)\n  const [menu, setMenu] = React.useState<{ at: number; q: string } | null>(null)\n  const [active, setActive] = React.useState(0)\n  const ref = React.useRef<HTMLTextAreaElement>(null)\n  const matches = menu ? people.filter((p) => p.name.toLowerCase().includes(menu.q.toLowerCase())).slice(0, 5) : []\n\n  function change(v: string, caret: number) {\n    setText(v)\n    const m = v.slice(0, caret).match(/(?:^|\\s)@([\\p{L} ]{0,20})$/u)\n    setMenu(m ? { at: caret - m[1]!.length - 1, q: m[1]! } : null)\n    setActive(0)\n  }\n  function pick(p: Comments1Person) {\n    if (!menu) return\n    const caret = ref.current?.selectionStart ?? text.length\n    const next = `${text.slice(0, menu.at)}@${p.name} ${text.slice(caret)}`\n    setText(next)\n    setMenu(null)\n    requestAnimationFrame(() => { const pos = menu.at + p.name.length + 2; ref.current?.focus(); ref.current?.setSelectionRange(pos, pos) })\n  }\n  function submit() {\n    const b = text.trim()\n    if (!b) return\n    onSubmit(b)\n    setText(\"\")\n    setMenu(null)\n  }\n  return (\n    <form onSubmit={(e) => { e.preventDefault(); submit() }} className=\"relative\">\n      <label htmlFor={`${id}-t`} className=\"sr-only\">\n        {label}\n      </label>\n      <Textarea\n        id={`${id}-t`}\n        ref={ref}\n        autoFocus={autoFocus}\n        rows={3}\n        value={text}\n        placeholder={placeholder}\n        role=\"combobox\"\n        aria-expanded={!!matches.length}\n        aria-controls={`${id}-m`}\n        aria-autocomplete=\"list\"\n        aria-activedescendant={matches.length ? `${id}-o${active}` : undefined}\n        onChange={(e) => change(e.target.value, e.target.selectionStart)}\n        onKeyDown={(e) => {\n          if (matches.length) {\n            if (e.key === \"ArrowDown\") { e.preventDefault(); setActive((a) => (a + 1) % matches.length) }\n            else if (e.key === \"ArrowUp\") { e.preventDefault(); setActive((a) => (a - 1 + matches.length) % matches.length) }\n            else if (e.key === \"Enter\" || e.key === \"Tab\") { e.preventDefault(); pick(matches[active]!); return }\n            else if (e.key === \"Escape\") { e.preventDefault(); setMenu(null); return }\n          }\n          if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submit() }\n        }}\n      />\n      {matches.length ? (\n        <ul id={`${id}-m`} role=\"listbox\" aria-label=\"People to mention\" className=\"bg-popover absolute start-2 z-20 mt-1 w-60 rounded-xl border p-1 shadow-lg\">\n          {matches.map((p, i) => (\n            <li key={p.id} id={`${id}-o${i}`} role=\"option\" aria-selected={i === active} onMouseDown={(e) => { e.preventDefault(); pick(p) }} className={cn(\"flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm\", i === active && \"bg-accent\")}>\n              <span aria-hidden=\"true\" className=\"relative flex size-6 items-center justify-center overflow-hidden rounded-full border text-[9px] font-semibold\">\n                <CoverArt seed={p.name} variant=\"mesh\" className=\"absolute inset-0\" />\n                <span className=\"relative\">{p.name.split(\" \").map((w) => w[0]).join(\"\")}</span>\n              </span>\n              {p.name}\n            </li>\n          ))}\n        </ul>\n      ) : null}\n      <div className=\"mt-2 flex items-center justify-between gap-3\">\n        <p className=\"text-muted-foreground text-xs\">Type @ to mention someone. Ctrl or Command + Enter sends.</p>\n        <div className=\"flex gap-2\">\n          {onCancel ? (\n            <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={onCancel}>\n              Cancel\n            </Button>\n          ) : null}\n          <Button type=\"submit\" size=\"sm\" disabled={!text.trim()}>\n            {cta}\n          </Button>\n        </div>\n      </div>\n    </form>\n  )\n}\n\n/**\n * A comment thread you would want to work in: a composer where typing @ offers people (arrow keys and Enter pick one), comments and replies with how long\n * ago they were written, emoji reactions that count and toggle, editing and deleting your own, and a thread you can resolve and bring back. A comment you post\n * appears at once, and if it cannot be saved it stays with Not posted and Retry.\n */\nfunction Comments1({ comments: commentsProp = sample, people = team, me = \"Sam Okafor\", now = \"2026-10-18T10:00:00Z\", title = \"Discussion\", onPost, onEdit, onDelete, onReact, onResolve, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Comments1Props) {\n  const id = React.useId()\n  const [items, setItems] = React.useState<(Comments1Comment & { pending?: boolean; failed?: boolean })[]>(commentsProp)\n  React.useEffect(() => setItems(commentsProp), [commentsProp])\n  const list = state === \"empty\" ? [] : items\n  const [replyTo, setReplyTo] = React.useState<string | null>(null)\n  const [editing, setEditing] = React.useState<string | null>(null)\n  const [openResolved, setOpenResolved] = React.useState<Set<string>>(new Set())\n  const [picker, setPicker] = React.useState<string | null>(null)\n  const [announce, setAnnounce] = React.useState(\"\")\n  const seq = React.useRef(0)\n\n  const update = (cid: string, fn: (c: Comments1Comment) => Comments1Comment) => setItems((xs) => xs.map((c) => (c.id === cid ? fn(c) : { ...c, replies: c.replies?.map((r) => (r.id === cid ? fn(r) : r)) })))\n  async function deliver(cid: string, body: string, parent?: string) {\n    try {\n      await onPost?.({ body, replyTo: parent })\n      update(cid, (c) => ({ ...c, pending: false, failed: false }) as Comments1Comment)\n    } catch {\n      update(cid, (c) => ({ ...c, pending: false, failed: true }) as Comments1Comment)\n    }\n  }\n  function post(body: string, parent?: string) {\n    const c: Comments1Comment & { pending?: boolean } = { id: `n${++seq.current}`, author: me, at: now, body, replies: [], pending: true }\n    if (parent) setItems((xs) => xs.map((x) => (x.id === parent ? { ...x, replies: [...(x.replies ?? []), c] } : x)))\n    else setItems((xs) => [c, ...xs])\n    setReplyTo(null)\n    setAnnounce(parent ? \"Reply posted\" : \"Comment posted\")\n    void deliver(c.id, body, parent)\n  }\n  function react(c: Comments1Comment, emoji: string) {\n    const names = c.reactions?.[emoji] ?? []\n    const on = !names.includes(me)\n    update(c.id, (x) => ({ ...x, reactions: { ...x.reactions, [emoji]: on ? [...names, me] : names.filter((n) => n !== me) } }))\n    onReact?.(c.id, emoji, on)\n    setPicker(null)\n  }\n\n  const one = (c: Comments1Comment & { pending?: boolean; failed?: boolean }, reply = false) => {\n    const mine = c.author === me\n    const r = Object.entries(c.reactions ?? {}).filter(([, n]) => n.length)\n    return (\n      <li key={c.id} className=\"flex gap-3\">\n        <span aria-hidden=\"true\" className={cn(\"relative mt-0.5 flex shrink-0 items-center justify-center overflow-hidden rounded-full border font-semibold\", reply ? \"size-8 text-[10px]\" : \"size-10 text-xs\")}>\n          <CoverArt seed={c.author} variant=\"mesh\" className=\"absolute inset-0\" />\n          <span className=\"relative\">{c.author.split(\" \").map((w) => w[0]).join(\"\")}</span>\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"bg-card rounded-2xl border px-4 py-3\">\n            <p className=\"flex flex-wrap items-baseline gap-x-2 text-sm\">\n              <span className=\"font-semibold\">{c.author}</span>\n              <span className=\"text-muted-foreground text-xs\">\n                {ago(c.at, now)}\n                {c.edited ? \" · edited\" : \"\"}\n              </span>\n            </p>\n            {editing === c.id ? (\n              <div className=\"mt-2\">\n                <Composer people={people} label=\"Edit your comment\" placeholder=\"\" initial={c.body} cta=\"Save\" autoFocus onCancel={() => setEditing(null)} onSubmit={(b) => { update(c.id, (x) => ({ ...x, body: b, edited: true })); onEdit?.(c.id, b); setEditing(null) }} />\n              </div>\n            ) : (\n              <p className=\"mt-1 text-sm leading-relaxed text-pretty whitespace-pre-line\">\n                <Body text={c.body} people={people} />\n              </p>\n            )}\n          </div>\n          {c.pending ? (\n            <p className=\"text-muted-foreground mt-1.5 flex items-center gap-1.5 ps-2 text-xs\">\n              <Loader2 aria-hidden=\"true\" className=\"size-3 animate-spin motion-reduce:animate-none\" />\n              Posting\n            </p>\n          ) : null}\n          {c.failed ? (\n            <p role=\"alert\" className=\"mt-1.5 flex items-center gap-2 ps-2 text-xs\">\n              <CircleAlert aria-hidden=\"true\" className=\"size-3.5\" />\n              Not posted.\n              <button type=\"button\" onClick={() => { update(c.id, (x) => ({ ...x, pending: true, failed: false }) as Comments1Comment); void deliver(c.id, c.body) }} className=\"focus-visible:ring-ring/50 rounded-sm font-semibold underline underline-offset-2 outline-none focus-visible:ring-[3px]\">\n                Retry\n              </button>\n            </p>\n          ) : null}\n          <div className=\"mt-1.5 flex flex-wrap items-center gap-1.5 ps-1\">\n            {r.map(([emoji, names]) => {\n              const on = names.includes(me)\n              return (\n                <button key={emoji} type=\"button\" aria-pressed={on} aria-label={`${emoji} ${names.length}: ${names.join(\", \")}`} onClick={() => react(c, emoji)} className={cn(\"focus-visible:ring-ring/50 flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs outline-none focus-visible:ring-[3px]\", on ? \"bg-chart-1/15 border-chart-1/40\" : \"hover:bg-accent\")}>\n                  <span aria-hidden=\"true\">{emoji}</span>\n                  <span className=\"tabular-nums\">{names.length}</span>\n                </button>\n              )\n            })}\n            <span className=\"relative\">\n              <Button variant=\"ghost\" size=\"icon\" aria-label=\"Add a reaction\" aria-expanded={picker === c.id} onClick={() => setPicker(picker === c.id ? null : c.id)}>\n                <SmilePlus aria-hidden=\"true\" />\n              </Button>\n              {picker === c.id ? (\n                <span role=\"group\" aria-label=\"Choose a reaction\" className=\"bg-popover absolute start-0 top-full z-20 mt-1 flex gap-0.5 rounded-xl border p-1 shadow-lg\">\n                  {emojis.map((e) => (\n                    <button key={e} type=\"button\" aria-label={`React with ${e}`} onClick={() => react(c, e)} className=\"hover:bg-accent focus-visible:ring-ring/50 rounded-lg px-2 py-1.5 text-lg outline-none focus-visible:ring-[3px]\">\n                      {e}\n                    </button>\n                  ))}\n                </span>\n              ) : null}\n            </span>\n            {!reply ? (\n              <Button variant=\"ghost\" size=\"sm\" onClick={() => setReplyTo(replyTo === c.id ? null : c.id)}>\n                <CornerDownRight aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />\n                Reply\n              </Button>\n            ) : null}\n            {mine ? (\n              <>\n                <Button variant=\"ghost\" size=\"icon\" aria-label={`Edit your comment from ${ago(c.at, now)}`} onClick={() => setEditing(c.id)}>\n                  <Pencil aria-hidden=\"true\" />\n                </Button>\n                <Button variant=\"ghost\" size=\"icon\" aria-label={`Delete your comment from ${ago(c.at, now)}`} onClick={() => { setItems((xs) => (reply ? xs.map((x) => ({ ...x, replies: x.replies?.filter((y) => y.id !== c.id) })) : xs.filter((x) => x.id !== c.id))); onDelete?.(c.id); setAnnounce(\"Comment deleted\") }}>\n                  <Trash2 aria-hidden=\"true\" />\n                </Button>\n              </>\n            ) : null}\n          </div>\n        </div>\n      </li>\n    )\n  }\n\n  const content = (\n    <div className=\"grid gap-8\">\n      <div className=\"bg-card rounded-2xl border p-4 sm:p-5\">\n        <Composer people={people} label=\"Write a comment\" placeholder=\"Add to the discussion…\" onSubmit={(b) => post(b)} />\n      </div>\n      {!list.length ? (\n        <div className=\"rounded-2xl border border-dashed px-6 py-14 text-center\">\n          <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n            <MessageSquare className=\"size-6\" />\n          </span>\n          <h3 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No comments yet\"}</h3>\n          <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"Start the conversation. Mention a teammate with @ to bring them in.\"}</p>\n          {emptyState?.action}\n        </div>\n      ) : (\n        <ul className=\"grid gap-7\">\n          {list.map((c) => {\n            const resolved = !!c.resolvedBy\n            const open = openResolved.has(c.id)\n            return (\n              <li key={c.id}>\n                {resolved && !open ? (\n                  <div className=\"bg-muted/50 flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3 text-sm\">\n                    <p className=\"flex items-center gap-2\">\n                      <Check aria-hidden=\"true\" className=\"text-chart-2 size-4\" />\n                      Resolved by {c.resolvedBy} · {c.author}&rsquo;s thread, {(c.replies?.length ?? 0) + 1} comments\n                    </p>\n                    <Button variant=\"outline\" size=\"sm\" aria-expanded={false} onClick={() => setOpenResolved(new Set(openResolved).add(c.id))}>\n                      Show\n                    </Button>\n                  </div>\n                ) : (\n                  <article aria-label={`Thread by ${c.author}`}>\n                    <ul className=\"grid gap-4\">\n                      {one(c)}\n                      {c.replies?.length ? (\n                        <li>\n                          <ul className=\"border-border ms-5 grid gap-4 border-s ps-6\">{c.replies.map((r) => one(r, true))}</ul>\n                        </li>\n                      ) : null}\n                    </ul>\n                    {replyTo === c.id ? (\n                      <div className=\"ms-11 mt-4\">\n                        <Composer people={people} label={`Reply to ${c.author}`} placeholder={`Reply to ${c.author.split(\" \")[0]}…`} cta=\"Reply\" autoFocus onCancel={() => setReplyTo(null)} onSubmit={(b) => post(b, c.id)} />\n                      </div>\n                    ) : null}\n                    <div className=\"ms-11 mt-3\">\n                      <Button variant=\"ghost\" size=\"sm\" onClick={() => { const next = !resolved; update(c.id, (x) => ({ ...x, resolvedBy: next ? me : undefined })); if (!next) setOpenResolved((s) => { const n = new Set(s); n.delete(c.id); return n }); onResolve?.(c.id, next); setAnnounce(next ? \"Thread resolved\" : \"Thread reopened\") }}>\n                        {resolved ? <Undo2 aria-hidden=\"true\" className=\"rtl:-scale-x-100\" /> : <Check aria-hidden=\"true\" />}\n                        {resolved ? \"Reopen thread\" : \"Resolve thread\"}\n                      </Button>\n                    </div>\n                  </article>\n                )}\n              </li>\n            )\n          })}\n        </ul>\n      )}\n    </div>\n  )\n\n  return (\n    <section data-slot=\"comments-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-3xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <h2 id={`${id}-title`} className=\"mb-6 text-2xl font-semibold tracking-[-0.03em]\">\n        {title} <span className=\"text-muted-foreground font-normal tabular-nums\">· {list.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)}</span>\n      </h2>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the discussion\" loading={<Skel />} error={{ title: errorState?.title ?? \"The discussion could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Comments1, type Comments1Props, type Comments1Comment, type Comments1Person }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/comments-1/comments-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "comments",
      "discussion",
      "thread",
      "replies",
      "mentions",
      "reactions",
      "collaboration",
      "feedback",
      "conversation"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "Threaded comments. <Comments1 comments={[{ id, author, body, at: ISO, reactions: { '👍': ['Priya Shah'] }, edited, resolvedBy, replies: [...] }]} people={[{ id, name }]} me='Sam Okafor' now='2026-10-18T10:00:00Z' onPost={({ body, replyTo }) => Promise} onEdit onDelete onReact={(id, emoji, on) => ...} onResolve={(id, resolved) => ...} />. Typing @ opens a people list (arrows, Enter or Tab pick, Escape closes); only @Name that matches a person is highlighted. Times are 'N min ago' from now; pass now yourself, the block never reads the clock. Only comments whose author equals me can be edited or deleted. Reject onPost to show Not posted with Retry.",
      "whenToUse": [
        "Comments on a document, task, design or post",
        "A team discussion with mentions and reactions"
      ],
      "whenNotToUse": [
        "Real-time chat (use chat-1)",
        "A support conversation with private notes (use tickets-1)"
      ],
      "composesWith": [
        "tickets-1",
        "tasks-1",
        "editor-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "@, Up / Down, Enter or Tab, Escape",
          "action": "The composer is a combobox; the people list is a listbox with the active option announced, and Escape closes it without losing the text"
        },
        {
          "keys": "Reactions",
          "action": "Each is a toggle button that says its emoji, count and who reacted ('👍 3: Priya Shah, Sam Okafor, Theo Nakamura')"
        },
        {
          "keys": "Actions",
          "action": "Edit and Delete say which comment they act on; posting, replying, deleting and resolving are announced politely"
        }
      ],
      "customization": [
        "Replies nest one level deep, drawn with a rule on the reading edge so it mirrors in right-to-left",
        "A resolved thread collapses to one line with Show, and Reopen brings it back",
        "Your own comments get Edit and Delete; reactions toggle for you"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "comments-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton composer and comments under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No comments yet",
        "example": "comments-1-empty",
        "trigger": "state=\"empty\" (or comments={[]})",
        "note": "Invites the first comment and mentions @; the composer stays usable. emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "comments-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A comment that fails to post stays with Not posted and Retry."
      }
    ],
    "examples": [
      "comments-1-demo",
      "comments-1-failed",
      "comments-1-loading",
      "comments-1-empty",
      "comments-1-error"
    ],
    "url": "https://ui.ballmac.com/components/comments-1"
  },
  "categories": [
    "blocks",
    "comments"
  ],
  "type": "registry:block"
}