{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mail-1",
  "title": "Mail 1: three-pane inbox",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A working inbox: folders with an unread count, a searchable message list with stars and keyboard navigation, a reading pane with archive, delete, mark unread and reply, and a compose dialog. One pane at a time on phones.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/avatar.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/dialog.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/search-field.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/textarea.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/mail-1/mail-1.tsx",
      "content": "// Ballmac UI: Mail 1. https://ui.ballmac.com/blocks/mail-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Archive, ArrowLeft, Inbox, MailOpen, Paperclip, PenSquare, Reply, Send, Star, Trash2 } from \"lucide-react\"\n\nimport { Avatar, AvatarFallback } from \"@/components/ballmac/avatar\"\nimport { Button, buttonVariants } from \"@/components/ballmac/button\"\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from \"@/components/ballmac/dialog\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { SearchField } from \"@/components/ballmac/search-field\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Textarea } from \"@/components/ballmac/textarea\"\nimport { cn } from \"@/lib/utils\"\n\ntype Mail1Folder = \"inbox\" | \"sent\" | \"archive\"\n\ntype Mail1Message = {\n  id: string\n  folder: Mail1Folder\n  from: { name: string; email: string }\n  subject: string\n  /** Paragraphs of the message body. */\n  body: string[]\n  /** When it arrived, in words (e.g. \"9:41 AM\" or \"Yesterday\"). */\n  time: string\n  unread?: boolean\n  starred?: boolean\n  attachments?: { name: string; size: string }[]\n}\n\ntype Mail1Props = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The messages. */\n  messages?: Mail1Message[]\n  /** Name shown on messages you send. */\n  me?: { name: string; email: string }\n  /** Called with the new message when something is sent or replied to. */\n  onSend?: (to: string, subject: string, body: string) => void\n  /** Height of the frame. */\n  height?: string\n}\n\nconst defaults: Mail1Message[] = [\n  {\n    id: \"m1\", folder: \"inbox\", unread: true, starred: true, time: \"9:41 AM\",\n    from: { name: \"Maya Kim\", email: \"maya@northwind.co\" },\n    subject: \"Q4 budget: can you review before Friday?\",\n    body: [\"Hi Jordan,\", \"I’ve put the draft Q4 budget in the shared folder. The main change is the extra headcount in support, which moves total spend up about 6%.\", \"Could you look at the assumptions tab and flag anything that looks off? I’d like to send it to finance on Friday morning.\", \"Thanks,\\nMaya\"],\n    attachments: [{ name: \"Q4-budget-draft.xlsx\", size: \"184 KB\" }],\n  },\n  {\n    id: \"m2\", folder: \"inbox\", unread: true, time: \"8:15 AM\",\n    from: { name: \"Stripe\", email: \"receipts@stripe.com\" },\n    subject: \"Your payout of $12,480.00 is on the way\",\n    body: [\"Your payout was initiated and should arrive in your bank account by Thursday.\", \"You can follow its status any time from the Payouts page in your dashboard.\"],\n  },\n  {\n    id: \"m3\", folder: \"inbox\", time: \"Yesterday\",\n    from: { name: \"Jonas Ortiz\", email: \"jonas@acme.com\" },\n    subject: \"Launch checklist is ready\",\n    body: [\"Everything is green except the pricing page copy. Elena is finishing it this afternoon.\", \"I’ll post the final checklist in the channel at 5pm. Shout if you want anything added.\"],\n  },\n  {\n    id: \"m4\", folder: \"inbox\", time: \"Yesterday\",\n    from: { name: \"Amara Singh\", email: \"amara@acme.com\" },\n    subject: \"Postmortem: the slow Tuesday\",\n    body: [\"Draft attached. The short version: a retry storm after a deploy, fixed in two places.\", \"Comments welcome before we publish it internally.\"],\n    attachments: [{ name: \"postmortem-tuesday.pdf\", size: \"92 KB\" }],\n  },\n  {\n    id: \"m5\", folder: \"inbox\", time: \"Mon\",\n    from: { name: \"Northwind Billing\", email: \"billing@northwind.co\" },\n    subject: \"Invoice INV-1042 is overdue\",\n    body: [\"This is a friendly reminder that invoice INV-1042 for $4,200.00 was due on September 25.\", \"You can pay online with the link in your customer portal.\"],\n  },\n  {\n    id: \"m6\", folder: \"inbox\", time: \"Mon\",\n    from: { name: \"Elena Fischer\", email: \"elena@acme.com\" },\n    subject: \"New empty states, take a look\",\n    body: [\"I redrew the empty states for inbox, projects and reports. They’re calmer and give one clear next step.\", \"Figma link is in the design channel.\"],\n  },\n  {\n    id: \"m7\", folder: \"sent\", time: \"Sep 26\",\n    from: { name: \"Jordan Lee\", email: \"jordan@acme.com\" },\n    subject: \"Re: Hiring plan for next quarter\",\n    body: [\"Thanks for sending this over. I’m on board with two engineers and a designer. Let’s hold the third role until we see the October numbers.\"],\n  },\n  {\n    id: \"m8\", folder: \"archive\", time: \"Sep 24\",\n    from: { name: \"Tomás Herrera\", email: \"tomas@acme.com\" },\n    subject: \"Customer story: Northwind goes paperless\",\n    body: [\"The interview is done and the write-up is approved. It goes live on Thursday.\"],\n  },\n]\n\nconst folderMeta: { id: Mail1Folder; label: string; icon: typeof Inbox }[] = [\n  { id: \"inbox\", label: \"Inbox\", icon: Inbox },\n  { id: \"sent\", label: \"Sent\", icon: Send },\n  { id: \"archive\", label: \"Archive\", icon: Archive },\n]\n\nconst tones = [\"bg-chart-1/25\", \"bg-chart-3/25\", \"bg-chart-5/25\", \"bg-chart-2/25\", \"bg-chart-4/25\"]\nconst initials = (name: string) => name.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2)\nconst toneOf = (name: string) => tones[name.split(\"\").reduce((n, c) => n + c.charCodeAt(0), 0) % tones.length]\n\nfunction Mail1({\n  messages: initial = defaults,\n  me = { name: \"Jordan Lee\", email: \"jordan@acme.com\" },\n  onSend,\n  height = \"44rem\",\n  className,\n  style,\n  ...props\n}: Mail1Props) {\n  // The message that opens first counts as read.\n  const firstOpen = initial.find((m) => m.folder === \"inbox\")?.id ?? null\n  const [mails, setMails] = React.useState(() => initial.map((m) => (m.id === firstOpen ? { ...m, unread: false } : m)))\n  const [folder, setFolder] = React.useState<Mail1Folder | \"starred\">(\"inbox\")\n  const [openId, setOpenId] = React.useState<string | null>(firstOpen)\n  const [query, setQuery] = React.useState(\"\")\n  const [mobileView, setMobileView] = React.useState<\"list\" | \"read\">(\"list\")\n  const [reply, setReply] = React.useState<string | null>(null)\n  const [announce, setAnnounce] = React.useState(\"\")\n  const [compose, setCompose] = React.useState(false)\n  const [draft, setDraft] = React.useState({ to: \"\", subject: \"\", body: \"\" })\n  const listRef = React.useRef<HTMLUListElement>(null)\n\n  const q = query.trim().toLowerCase()\n  const visible = mails.filter((m) => (folder === \"starred\" ? m.starred : m.folder === folder) && (!q || `${m.from.name} ${m.subject} ${m.body.join(\" \")}`.toLowerCase().includes(q)))\n  const open = mails.find((m) => m.id === openId)\n  const shown = open && visible.some((m) => m.id === open.id) ? open : undefined\n  const unread = mails.filter((m) => m.folder === \"inbox\" && m.unread).length\n\n  const update = (id: string, patch: Partial<Mail1Message>) => setMails((all) => all.map((m) => (m.id === id ? { ...m, ...patch } : m)))\n  function select(id: string) {\n    setOpenId(id)\n    update(id, { unread: false })\n    setMobileView(\"read\")\n    setReply(null)\n  }\n  function after(removedId: string, text: string) {\n    const idx = visible.findIndex((m) => m.id === removedId)\n    const next = visible[idx + 1] ?? visible[idx - 1]\n    setOpenId(next && next.id !== removedId ? next.id : null)\n    setMobileView(\"list\")\n    setAnnounce(text)\n  }\n  function keyNav(e: React.KeyboardEvent<HTMLUListElement>) {\n    const keys = [\"ArrowDown\", \"ArrowUp\", \"j\", \"k\"]\n    if (!keys.includes(e.key)) return\n    const items = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>(\"[data-mail]\") ?? [])\n    const i = items.indexOf(document.activeElement as HTMLButtonElement)\n    if (i === -1) return\n    e.preventDefault()\n    const next = e.key === \"ArrowDown\" || e.key === \"j\" ? items[i + 1] : items[i - 1]\n    next?.focus()\n  }\n  function send(to: string, subject: string, body: string, from: Mail1Folder = \"sent\") {\n    setMails((all) => [{ id: `sent-${all.length + 1}`, folder: from, from: me, subject, body: body.split(\"\\n\").filter(Boolean), time: \"Just now\" }, ...all])\n    onSend?.(to, subject, body)\n    setAnnounce(\"Message sent.\")\n  }\n\n  return (\n    <div\n      data-slot=\"mail-1\"\n      className={cn(\"bg-background @container flex overflow-hidden rounded-2xl border shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)]\", className)}\n      style={{ height, ...style }}\n      {...props}\n    >\n      <p role=\"status\" className=\"sr-only\">{announce}</p>\n\n      {/* Folders */}\n      <nav aria-label=\"Folders\" className=\"bg-muted/30 hidden w-52 shrink-0 flex-col border-e p-3 md:flex\">\n        <Dialog open={compose} onOpenChange={setCompose}>\n          <DialogTrigger className={buttonVariants({ className: \"w-full\" })}><PenSquare /> Compose</DialogTrigger>\n          <DialogContent>\n            <form onSubmit={(e) => { e.preventDefault(); if (!draft.to.trim() || !draft.subject.trim()) return; send(draft.to, draft.subject, draft.body); setDraft({ to: \"\", subject: \"\", body: \"\" }); setCompose(false) }}>\n              <DialogHeader>\n                <DialogTitle>New message</DialogTitle>\n                <DialogDescription>It will appear in Sent.</DialogDescription>\n              </DialogHeader>\n              <div className=\"my-5 grid gap-3\">\n                <Input aria-label=\"To\" type=\"email\" placeholder=\"To\" value={draft.to} onChange={(e) => setDraft({ ...draft, to: e.target.value })} required />\n                <Input aria-label=\"Subject\" placeholder=\"Subject\" value={draft.subject} onChange={(e) => setDraft({ ...draft, subject: e.target.value })} required />\n                <Textarea aria-label=\"Message\" rows={6} placeholder=\"Write your message…\" value={draft.body} onChange={(e) => setDraft({ ...draft, body: e.target.value })} />\n              </div>\n              <DialogFooter>\n                <Button type=\"button\" variant=\"ghost\" onClick={() => setCompose(false)}>Discard</Button>\n                <Button type=\"submit\"><Send  className=\"rtl:-scale-x-100\"/> Send</Button>\n              </DialogFooter>\n            </form>\n          </DialogContent>\n        </Dialog>\n        <ul className=\"mt-4 space-y-0.5\">\n          {folderMeta.map((f) => (\n            <li key={f.id}>\n              <button type=\"button\" aria-current={folder === f.id ? \"true\" : undefined} onClick={() => { setFolder(f.id); setOpenId(null); setMobileView(\"list\") }} className={cn(\"focus-visible:ring-ring/50 flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]\", folder === f.id ? \"bg-accent text-accent-foreground\" : \"text-muted-foreground hover:bg-accent/60 hover:text-foreground\")}>\n                <f.icon className=\"size-4\" aria-hidden=\"true\" />\n                <span className=\"flex-1 text-start\">{f.label}</span>\n                {f.id === \"inbox\" && unread > 0 && <span className=\"bg-foreground text-background rounded-full px-1.5 py-px text-[11px] font-semibold tabular-nums\">{unread}<span className=\"sr-only\"> unread</span></span>}\n              </button>\n            </li>\n          ))}\n          <li>\n            <button type=\"button\" aria-current={folder === \"starred\" ? \"true\" : undefined} onClick={() => { setFolder(\"starred\"); setOpenId(null); setMobileView(\"list\") }} className={cn(\"focus-visible:ring-ring/50 flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]\", folder === \"starred\" ? \"bg-accent text-accent-foreground\" : \"text-muted-foreground hover:bg-accent/60 hover:text-foreground\")}>\n              <Star className=\"size-4\" aria-hidden=\"true\" /><span className=\"flex-1 text-start\">Starred</span>\n            </button>\n          </li>\n        </ul>\n      </nav>\n\n      {/* Message list */}\n      <section aria-label=\"Messages\" className={cn(\"flex w-full min-w-0 flex-col border-e md:w-80 md:shrink-0 lg:w-96\", mobileView === \"read\" && \"hidden md:flex\")}>\n        <div className=\"space-y-2 border-b p-3\">\n          <div className=\"flex items-center gap-2 md:hidden\">\n            <Select value={folder} onValueChange={(v) => { setFolder(v as Mail1Folder | \"starred\"); setOpenId(null) }}>\n              <SelectTrigger aria-label=\"Folder\" size=\"sm\" className=\"flex-1\"><SelectValue /></SelectTrigger>\n              <SelectContent>\n                {folderMeta.map((f) => <SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>)}\n                <SelectItem value=\"starred\">Starred</SelectItem>\n              </SelectContent>\n            </Select>\n            <Button size=\"icon\" className=\"size-8\" aria-label=\"Compose\" onClick={() => setCompose(true)}><PenSquare /></Button>\n          </div>\n          <SearchField label=\"Search mail\" placeholder=\"Search mail\" value={query} onValueChange={setQuery} />\n        </div>\n        <ul ref={listRef} onKeyDown={keyNav} aria-label={`${folder === \"starred\" ? \"Starred\" : folderMeta.find((f) => f.id === folder)?.label} messages`} className=\"min-h-0 flex-1 divide-y overflow-y-auto\">\n          {visible.map((m) => (\n            <li key={m.id} className=\"relative\">\n              <button type=\"button\" data-mail=\"\" aria-current={shown?.id === m.id ? \"true\" : undefined} onClick={() => select(m.id)} className={cn(\"focus-visible:ring-ring/50 flex w-full gap-3 p-4 pe-11 text-start outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-inset\", shown?.id === m.id ? \"bg-accent/70\" : \"hover:bg-accent/40\")}>\n                <Avatar size=\"sm\"><AvatarFallback className={cn(\"text-foreground text-[11px] font-semibold\", toneOf(m.from.name))}>{initials(m.from.name)}</AvatarFallback></Avatar>\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"flex items-baseline justify-between gap-2\">\n                    <span className={cn(\"truncate text-sm\", m.unread ? \"font-semibold\" : \"font-medium\")}>{m.unread && <span className=\"sr-only\">Unread, </span>}{m.from.name}</span>\n                    <span className=\"text-muted-foreground shrink-0 text-xs\">{m.time}</span>\n                  </span>\n                  <span className={cn(\"mt-0.5 block truncate text-sm\", m.unread ? \"text-foreground font-medium\" : \"text-muted-foreground\")}>{m.subject}</span>\n                  <span className=\"text-muted-foreground mt-0.5 line-clamp-1 block text-xs\">{m.body[0]}</span>\n                </span>\n                {m.unread && <span aria-hidden=\"true\" className=\"bg-chart-1 mt-1.5 size-2 shrink-0 rounded-full\" />}\n              </button>\n              <button type=\"button\" aria-label={m.starred ? \"Remove star\" : \"Star\"} aria-pressed={!!m.starred} onClick={() => update(m.id, { starred: !m.starred })} className=\"hover:bg-accent focus-visible:ring-ring/50 absolute end-2 bottom-2 flex size-8 items-center justify-center rounded-lg outline-none focus-visible:ring-[3px]\">\n                <Star className={cn(\"size-4\", m.starred ? \"fill-chart-3 text-chart-3\" : \"text-muted-foreground\")} aria-hidden=\"true\" />\n              </button>\n            </li>\n          ))}\n          {visible.length === 0 && (\n            <li className=\"text-muted-foreground flex h-full flex-col items-center justify-center px-6 py-16 text-center text-sm\">\n              <MailOpen className=\"size-8\" aria-hidden=\"true\" />\n              <p className=\"mt-3 font-medium\">{q ? \"No messages match\" : \"Nothing here\"}</p>\n              <p className=\"mt-1 text-xs\">{q ? \"Try a different search.\" : \"Messages you move here will show up.\"}</p>\n            </li>\n          )}\n        </ul>\n      </section>\n\n      {/* Reading pane */}\n      <article aria-label={shown ? shown.subject : \"Message\"} className={cn(\"min-w-0 flex-1 flex-col\", mobileView === \"read\" ? \"flex\" : \"hidden md:flex\")}>\n        {shown ? (\n          <>\n            <div className=\"flex items-center gap-1 border-b p-2\">\n              <Button variant=\"ghost\" size=\"icon\" className=\"md:hidden\" aria-label=\"Back to messages\" onClick={() => setMobileView(\"list\")}><ArrowLeft  className=\"rtl:rotate-180\"/></Button>\n              <Button variant=\"ghost\" size=\"icon\" aria-label=\"Archive\" onClick={() => { update(shown.id, { folder: \"archive\" }); after(shown.id, \"Conversation archived.\") }}><Archive /></Button>\n              <Button variant=\"ghost\" size=\"icon\" aria-label=\"Delete\" onClick={() => { setMails((all) => all.filter((m) => m.id !== shown.id)); after(shown.id, \"Conversation deleted.\") }}><Trash2 /></Button>\n              <Button variant=\"ghost\" size=\"icon\" aria-label=\"Mark as unread\" onClick={() => { update(shown.id, { unread: true }); after(shown.id, \"Marked as unread.\") }}><MailOpen /></Button>\n              <Button variant=\"ghost\" size=\"sm\" className=\"ms-auto\" onClick={() => setReply(\"\")}><Reply  className=\"rtl:-scale-x-100\"/> Reply</Button>\n            </div>\n            <div className=\"min-h-0 flex-1 overflow-y-auto p-5 sm:p-8\" tabIndex={0}>\n              <h2 className=\"text-xl font-semibold tracking-[-0.02em] text-balance sm:text-2xl\">{shown.subject}</h2>\n              <div className=\"mt-5 flex items-center gap-3\">\n                <Avatar><AvatarFallback className={cn(\"text-foreground text-xs font-semibold\", toneOf(shown.from.name))}>{initials(shown.from.name)}</AvatarFallback></Avatar>\n                <div className=\"min-w-0 text-sm leading-tight\">\n                  <p className=\"font-medium\">{shown.from.name}</p>\n                  <p className=\"text-muted-foreground truncate\">{shown.from.email}</p>\n                </div>\n                <p className=\"text-muted-foreground ms-auto text-xs\">{shown.time}</p>\n              </div>\n              <div className=\"mt-6 space-y-4 text-[15px] leading-7\">\n                {shown.body.map((p, i) => <p key={i} className=\"whitespace-pre-line\">{p}</p>)}\n              </div>\n              {shown.attachments && (\n                <ul className=\"mt-6 flex flex-wrap gap-2\">\n                  {shown.attachments.map((a) => (\n                    <li key={a.name} className=\"bg-muted/50 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm\"><Paperclip className=\"size-4\" aria-hidden=\"true\" />{a.name}<span className=\"text-muted-foreground text-xs\">{a.size}</span></li>\n                  ))}\n                </ul>\n              )}\n              {reply !== null && (\n                <form\n                  className=\"bg-card mt-8 rounded-2xl border p-3\"\n                  onSubmit={(e) => { e.preventDefault(); if (!reply.trim()) return; send(shown.from.email, `Re: ${shown.subject}`, reply); setReply(null) }}\n                >\n                  <Textarea aria-label={`Reply to ${shown.from.name}`} autoFocus rows={4} placeholder={`Reply to ${shown.from.name}…`} value={reply} onChange={(e) => setReply(e.target.value)} className=\"border-0 shadow-none focus-visible:ring-0\" />\n                  <div className=\"flex justify-end gap-2 pt-2\">\n                    <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setReply(null)}>Discard</Button>\n                    <Button type=\"submit\" size=\"sm\" disabled={!reply.trim()}><Send  className=\"rtl:-scale-x-100\"/> Send</Button>\n                  </div>\n                </form>\n              )}\n            </div>\n          </>\n        ) : (\n          <div className=\"text-muted-foreground flex h-full flex-col items-center justify-center p-8 text-center\">\n            <MailOpen className=\"size-10\" aria-hidden=\"true\" />\n            <p className=\"mt-4 font-medium\">Select a message</p>\n            <p className=\"mt-1 text-sm\">Use the arrow keys or J and K to move through your list.</p>\n          </div>\n        )}\n      </article>\n    </div>\n  )\n}\n\nexport { Mail1, type Mail1Props, type Mail1Message, type Mail1Folder }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/mail-1/mail-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "mail",
      "inbox",
      "email",
      "three pane",
      "messages",
      "compose",
      "reply"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "An email client UI. Pass messages=[{ id, folder, from, subject, body[], time, unread?, starred?, attachments? }], me and onSend(to, subject, body); archive, delete, star, read state, search and reply are handled in state.",
      "whenToUse": [
        "Webmail, support inboxes and notification centres",
        "A realistic product screenshot"
      ],
      "whenNotToUse": [
        "Chat (use ai-chat-2 or the chat components)"
      ],
      "composesWith": [
        "app-shell-1",
        "kanban-1",
        "calendar-1"
      ],
      "a11y": [
        {
          "keys": "Arrow Up / Down or J / K",
          "action": "Moves focus between messages; Enter or Space opens one"
        },
        {
          "keys": "Unread",
          "action": "Unread messages say 'Unread' to screen readers as well as being bold with a dot"
        },
        {
          "keys": "Actions",
          "action": "Archive, delete and mark-unread results are announced politely"
        }
      ],
      "customization": [
        "messages, me, height",
        "onSend(to, subject, body)",
        "folders are inbox, sent and archive plus Starred"
      ]
    },
    "examples": [
      "mail-1-demo",
      "mail-1-empty"
    ],
    "url": "https://ui.ballmac.com/components/mail-1"
  },
  "categories": [
    "blocks",
    "mail"
  ],
  "type": "registry:block"
}