{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "chat-thread",
  "title": "Chat Thread",
  "type": "registry:block",
  "description": "Single-pane conversation surface. Peer header (avatar + online dot + call/video/more), grouped messages with day separators (Today/Yesterday/date), own vs. peer bubbles, read/delivered/sent ticks, typing indicator, composer with attach/emoji and Enter-to-send. Stateful demo; swap the `messages` ref for your transport.",
  "categories": [
    "communication"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/blocks/chat-thread/chat-thread.ts",
      "content": "import { LitElement, css, html } from 'lit'\nimport {\n  ArrowRight,\n  AtSign,\n  Check,\n  CheckCheck,\n  MoreVertical,\n  Paperclip,\n  Phone,\n  Smile,\n  Video,\n} from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n// Side-effect imports: register the composed elements for standalone use\n// (consumers who install only this block still get working primitives).\nimport '../../components/avatar/avatar'\nimport '../../components/button/button'\nimport '../../components/textarea/textarea'\n\ntype ChatStatus = 'sent' | 'delivered' | 'read'\n\ninterface ChatMessage {\n  id: string\n  author: 'me' | 'them'\n  body: string\n  sentAt: Date\n  status?: ChatStatus\n}\n\nfunction seedMessages(): ChatMessage[] {\n  const now = new Date()\n  const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n  const yMorning = yesterday.getTime() + 9 * 60 * 60 * 1000\n  return [\n    { id: 'm1', author: 'them', body: 'Hey — did you get a chance to look at the onboarding doc?', sentAt: new Date(yMorning + 14 * 60 * 1000) },\n    {\n      id: 'm2',\n      author: 'me',\n      body: 'Yeah, just finished. Two small notes on the time-off flow but otherwise looks solid.',\n      sentAt: new Date(yMorning + 21 * 60 * 1000),\n      status: 'read',\n    },\n    {\n      id: 'm3',\n      author: 'them',\n      body: 'Perfect. Drop them in the doc and I will action this afternoon.',\n      sentAt: new Date(yMorning + 23 * 60 * 1000),\n    },\n    {\n      id: 'm4',\n      author: 'me',\n      body: 'Done. Also — are we still on for the dashboard review tomorrow at 10?',\n      sentAt: new Date(now.getTime() - 42 * 60 * 1000),\n      status: 'read',\n    },\n    {\n      id: 'm5',\n      author: 'them',\n      body: 'Yes! I will bring the kanban deck. Want to push it to 10:30 so we can join from the standup?',\n      sentAt: new Date(now.getTime() - 8 * 60 * 1000),\n    },\n  ]\n}\n\nfunction formatTime(d: Date): string {\n  return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n}\n\n/**\n * <uip-chat-thread> — the registry ChatThread block as a web component.\n *\n * Class strings are React's ChatThread verbatim. React state becomes @state\n * fields. Enter-to-send listens to `@keydown` on the composer (keydown is\n * composed, so it reaches the host; React attached a native listener because\n * its `onKeyDown` prop fires with no event — Lit has no such limitation).\n * React's injected `<style>` keyframes (typing-bounce) are copied into\n * `static styles` (the allowed keyframes exception); the `.chat-scroll`\n * scrollbar rules are dropped (extra CSS beyond :host/keyframes is banned).\n */\nexport class UipChatThread extends LitElement {\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  // typing-bounce is copied from React's injected <style> (keyframes exception).\n  static styles = [\n    tailwind,\n    css`:host { display: block; }`,\n    css`\n      .typing-dot {\n        display: inline-block;\n        width: 6px;\n        height: 6px;\n        border-radius: 9999px;\n        background: currentColor;\n        opacity: 0.5;\n        animation: typing-bounce 1s infinite ease-in-out;\n      }\n      @keyframes typing-bounce {\n        0%, 80%, 100% { transform: translateY(0); opacity: 0.35; }\n        40% { transform: translateY(-3px); opacity: 0.9; }\n      }\n    `,\n  ]\n\n  static properties = {\n    messages: { state: true },\n    draft: { state: true },\n    peerTyping: { state: true },\n  }\n\n  private messages: ChatMessage[] = []\n  private draft = ''\n  private peerTyping = false\n  private typingTimer?: number\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'chat-thread')\n    if (this.messages.length === 0) this.messages = seedMessages()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    if (this.typingTimer) window.clearTimeout(this.typingTimer)\n  }\n\n  updated() {\n    const root = this.renderRoot.querySelector('[data-scroll-root]')\n    if (root) root.scrollTop = root.scrollHeight\n  }\n\n  private dayLabel(d: Date): string {\n    const now = new Date()\n    if (d.toDateString() === now.toDateString()) return 'Today'\n    const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n    if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'\n    return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })\n  }\n\n  private get grouped(): Array<{ label: string; items: ChatMessage[] }> {\n    const groups: Array<{ label: string; items: ChatMessage[] }> = []\n    for (const m of this.messages) {\n      const label = this.dayLabel(m.sentAt)\n      const last = groups[groups.length - 1]\n      if (last && last.label === label) last.items.push(m)\n      else groups.push({ label, items: [m] })\n    }\n    return groups\n  }\n\n  private send() {\n    const body = this.draft.trim()\n    if (!body) return\n    this.messages = [...this.messages, { id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' }]\n    this.draft = ''\n    this.peerTyping = true\n    if (this.typingTimer) window.clearTimeout(this.typingTimer)\n    this.typingTimer = window.setTimeout(() => {\n      this.peerTyping = false\n    }, 1800)\n  }\n\n  render() {\n    return html`\n      <div\n        data-slot=\"chat-thread\"\n        class=${cn('bg-card text-card-foreground flex h-[680px] w-full flex-col overflow-hidden rounded-xl border shadow-sm')}\n      >\n        <header class=\"flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4\">\n          <div class=\"flex min-w-0 items-center gap-3\">\n            <div class=\"relative\">\n              <uip-avatar class=\"size-9\">\n                <uip-avatar-fallback class=\"text-xs font-medium\">MR</uip-avatar-fallback>\n              </uip-avatar>\n              <span class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"></span>\n            </div>\n            <div class=\"min-w-0\">\n              <p class=\"truncate text-sm font-semibold tracking-tight\">Marcus Rivera</p>\n              <p class=\"text-muted-foreground truncate text-xs\">Active now</p>\n            </div>\n          </div>\n          <div class=\"flex items-center gap-1\">\n            <uip-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Voice call\">\n              ${icon(Phone, 'phone', 'size-4')}\n            </uip-button>\n            <uip-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Video call\">\n              ${icon(Video, 'video', 'size-4')}\n            </uip-button>\n            <uip-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"More options\">\n              ${icon(MoreVertical, 'ellipsis-vertical', 'size-4')}\n            </uip-button>\n          </div>\n        </header>\n\n        <div data-scroll-root class=\"chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4\">\n          ${this.grouped.map(\n            (group) => html`\n              <div class=\"space-y-3\">\n                <div class=\"flex items-center justify-center\">\n                  <span\n                    class=\"text-muted-foreground bg-muted/60 rounded-full px-2.5 py-0.5 text-xs font-medium tracking-wide uppercase\"\n                    >${group.label}</span\n                  >\n                </div>\n                ${group.items.map(\n                  (m) => html`\n                    <div class=${cn('flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start')}>\n                      ${m.author === 'them'\n                        ? html`<uip-avatar class=\"size-7\">\n                            <uip-avatar-fallback class=\"text-xs\">MR</uip-avatar-fallback>\n                          </uip-avatar>`\n                        : ''}\n                      <div\n                        class=${cn('flex max-w-[78%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start')}\n                      >\n                        <div\n                          class=${cn(\n                            'rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm',\n                            m.author === 'me'\n                              ? 'bg-primary text-primary-foreground rounded-br-md'\n                              : 'bg-muted text-foreground rounded-bl-md',\n                          )}\n                        >\n                          ${m.body}\n                        </div>\n                        <div class=\"text-muted-foreground flex items-center gap-1 px-1 text-xs tabular-nums\">\n                          <span>${formatTime(m.sentAt)}</span>\n                          ${m.author === 'me' && m.status === 'read'\n                            ? icon(CheckCheck, 'check-check', 'text-info size-3')\n                            : ''}\n                          ${m.author === 'me' && m.status === 'delivered' ? icon(CheckCheck, 'check-check', 'size-3') : ''}\n                          ${m.author === 'me' && m.status === 'sent' ? icon(Check, 'check', 'size-3') : ''}\n                        </div>\n                      </div>\n                    </div>\n                  `,\n                )}\n              </div>\n            `,\n          )}\n          ${this.peerTyping\n            ? html`\n                <div class=\"flex items-end gap-2\">\n                  <uip-avatar class=\"size-7\">\n                    <uip-avatar-fallback class=\"text-xs\">MR</uip-avatar-fallback>\n                  </uip-avatar>\n                  <div class=\"bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5\">\n                    <span class=\"typing-dot\"></span>\n                    <span class=\"typing-dot\" style=\"animation-delay: 0.15s\"></span>\n                    <span class=\"typing-dot\" style=\"animation-delay: 0.3s\"></span>\n                  </div>\n                </div>\n              `\n            : ''}\n        </div>\n\n        <div class=\"bg-muted/20 border-t px-4 py-3\">\n          <div\n            class=\"bg-background border-input/60 focus-within:border-primary/40 focus-within:ring-primary/10 group flex flex-col overflow-hidden rounded-2xl border shadow-sm transition-colors duration-150 focus-within:shadow-md focus-within:ring-4\"\n          >\n            <uip-textarea\n              .value=${this.draft}\n              placeholder=\"Message Marcus\"\n              rows=\"1\"\n              variant=\"plain\"\n              class=\"placeholder:text-muted-foreground/70 max-h-40 min-h-[44px] resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed shadow-none focus-visible:ring-0\"\n              @value-change=${(e: CustomEvent<{ value: string }>) => {\n                this.draft = e.detail.value\n              }}\n              @keydown=${(e: KeyboardEvent) => {\n                if (e.key === 'Enter' && !e.shiftKey) {\n                  e.preventDefault()\n                  this.send()\n                }\n              }}\n            ></uip-textarea>\n            <div class=\"flex items-center justify-between gap-2 px-2 pb-2\">\n              <div class=\"flex items-center gap-0.5\">\n                <uip-button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  class=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Attach file\"\n                >\n                  ${icon(Paperclip, 'paperclip', 'size-4')}\n                </uip-button>\n                <uip-button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  class=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Emoji\"\n                >\n                  ${icon(Smile, 'smile', 'size-4')}\n                </uip-button>\n                <uip-button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  class=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Mention\"\n                >\n                  ${icon(AtSign, 'at-sign', 'size-4')}\n                </uip-button>\n              </div>\n              <div class=\"flex items-center gap-2\">\n                <kbd\n                  class=\"bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-xs sm:inline-flex\"\n                >\n                  <span>↵</span>\n                </kbd>\n                <uip-button\n                  class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                  ?disabled=${!this.draft.trim()}\n                  aria-label=\"Send\"\n                  @click=${this.send}\n                >\n                  Send ${icon(ArrowRight, 'arrow-right', 'size-3.5')}\n                </uip-button>\n              </div>\n            </div>\n          </div>\n          <p class=\"text-muted-foreground/70 mt-2 text-center text-xs\">\n            <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-xs\">Enter</kbd> to send\n            <span class=\"mx-1.5 opacity-50\">·</span>\n            <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-xs\">Shift</kbd> +\n            <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-xs\">↵</kbd> for newline\n          </p>\n        </div>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-chat-thread') || customElements.define('uip-chat-thread', UipChatThread)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-chat-thread': UipChatThread\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/blocks/chat-thread/chat-thread.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/avatar.json",
    "https://uipkge.dev/r/lit/button.json",
    "https://uipkge.dev/r/lit/lit-core.json",
    "https://uipkge.dev/r/lit/textarea.json"
  ]
}
