Menu

Change language

GitHub repo

Inbox

blockcommunication

Three-pane mail surface. Left rail: Compose button, folders (Inbox/Starred/Sent/Drafts/Spam/Trash) with unread counts, colour-coded labels with totals. Middle: searchable message list with sender, subject, preview, labels, attachment glyphs, star toggle, smart timestamps. Right: reader pane with subject, from/to, body, attachment chip, reply/forward/archive actions. Stateful demo — swap `mails` for your data source.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/inbox.json
Manifest:https://uipkge.dev/r/lit/inbox.jsonInstalls to:src/uip/blocks/inbox/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

InboxFolder
interface InboxFolder {
  id: InboxFolderId
  label: string
}
InboxLabel
interface InboxLabel {
  id: string
  label: string
  color: string
}
InboxMail
interface InboxMail {
  id: string
  folder: InboxFolderId
  from: { name: string; email: string; initials: string }
  to: string
  subject: string
  preview: string
  body: string
  receivedAt: Date
  read: boolean
  starred: boolean
  hasAttachment: boolean
  labels: string[]
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-inbox': UipInbox
  }
}

npm dependencies

Files installed (1)

  • src/uip/blocks/inbox/inbox.ts32.9 kB
    import { LitElement, css, html } from 'lit'
    import {
      AlertOctagon,
      Archive,
      ArrowLeft,
      ArrowRight,
      AtSign,
      Bookmark,
      FileText,
      Forward,
      Inbox as InboxIcon,
      MoreVertical,
      Paperclip,
      Pencil,
      Reply,
      ReplyAll,
      Search,
      Send,
      Smile,
      Star,
      Tag,
      Trash2,
    } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/avatar/avatar'
    import '../../components/button/button'
    import '../../components/input/input'
    import type { UipInput } from '../../components/input/input'
    
    type InboxReplyMode = 'reply' | 'reply-all' | 'forward'
    type InboxFolderId = 'inbox' | 'starred' | 'sent' | 'drafts' | 'spam' | 'trash'
    
    interface InboxFolder {
      id: InboxFolderId
      label: string
    }
    
    interface InboxLabel {
      id: string
      label: string
      color: string
    }
    
    interface InboxMail {
      id: string
      folder: InboxFolderId
      from: { name: string; email: string; initials: string }
      to: string
      subject: string
      preview: string
      body: string
      receivedAt: Date
      read: boolean
      starred: boolean
      hasAttachment: boolean
      labels: string[]
    }
    
    const FOLDERS: InboxFolder[] = [
      { id: 'inbox', label: 'Inbox' },
      { id: 'starred', label: 'Starred' },
      { id: 'sent', label: 'Sent' },
      { id: 'drafts', label: 'Drafts' },
      { id: 'spam', label: 'Spam' },
      { id: 'trash', label: 'Trash' },
    ]
    
    const LABELS: InboxLabel[] = [
      { id: 'work', label: 'Work', color: 'bg-primary' },
      { id: 'personal', label: 'Personal', color: 'bg-success' },
      { id: 'finance', label: 'Finance', color: 'bg-info' },
      { id: 'travel', label: 'Travel', color: 'bg-warning' },
    ]
    
    function seedMails(): InboxMail[] {
      const now = new Date()
      const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
      return [
        {
          id: 'e1',
          folder: 'inbox',
          from: { name: 'Sarah Connor', email: '[email protected]', initials: 'SC' },
          to: '[email protected]',
          subject: 'Q2 OKR draft — your sign-off needed',
          preview: 'Pulled together the Q2 OKR draft from the leadership offsite. I have flagged the two areas where we need…',
          body: 'Hi,\n\nPulled together the Q2 OKR draft from the leadership offsite. I have flagged the two areas where we need your call before we lock it:\n\n• Hiring plan: 4 vs 6 ENG roles (page 3)\n• EU rollout timing: April vs June (page 7)\n\nGoing to share with the rest of the leadership team Wednesday EOD, so anything from you by Tuesday is great.\n\nThanks,\nSarah',
          receivedAt: new Date(now.getTime() - 18 * 60 * 1000),
          read: false,
          starred: true,
          hasAttachment: true,
          labels: ['work'],
        },
        {
          id: 'e2',
          folder: 'inbox',
          from: { name: 'Marcus Rivera', email: '[email protected]', initials: 'MR' },
          to: '[email protected]',
          subject: 'Re: Dashboard review tomorrow',
          preview: 'Yes! Pushing to 10:30 so we can join from the standup. I will own the slides and pull the latest kanban metrics.',
          body: 'Yes! Pushing to 10:30 so we can join from the standup. I will own the slides and pull the latest kanban metrics.\n\nFew things I want to cover:\n  - Throughput delta vs last sprint\n  - The two stories we de-scoped\n  - Q2 capacity ask\n\nSee you tomorrow,\nMarcus',
          receivedAt: new Date(now.getTime() - 95 * 60 * 1000),
          read: false,
          starred: false,
          hasAttachment: false,
          labels: ['work'],
        },
        {
          id: 'e3',
          folder: 'inbox',
          from: { name: 'Stripe', email: '[email protected]', initials: 'St' },
          to: '[email protected]',
          subject: 'Your invoice from Acme Inc. — $1,290.00',
          preview: 'Thanks for using Stripe. Your invoice for the period Mar 1 – Mar 31 is attached. Total due: $1,290.00…',
          body: 'Thanks for using Stripe. Your invoice for the period Mar 1 – Mar 31 is attached. Total due: $1,290.00.',
          receivedAt: new Date(now.getTime() - 4 * 60 * 60 * 1000),
          read: true,
          starred: false,
          hasAttachment: true,
          labels: ['finance'],
        },
        {
          id: 'e4',
          folder: 'inbox',
          from: { name: 'Linear', email: '[email protected]', initials: 'Li' },
          to: '[email protected]',
          subject: 'Weekly summary: 14 issues closed, 22 opened',
          preview: 'Good news — the team closed 14 issues this week, up 3 from last week. Top contributors: Marcus, Priya, Devon.',
          body: 'Good news — the team closed 14 issues this week, up 3 from last week. Top contributors: Marcus, Priya, Devon.',
          receivedAt: new Date(now.getTime() - 9 * 60 * 60 * 1000),
          read: true,
          starred: false,
          hasAttachment: false,
          labels: ['work'],
        },
        {
          id: 'e5',
          folder: 'inbox',
          from: { name: 'Priya Shah', email: '[email protected]', initials: 'PS' },
          to: '[email protected]',
          subject: 'Color tokens are live in staging',
          preview: 'Pushed the new OKLCH token set to staging. All chart colors, success/warning/info, sidebar variants. Take a look…',
          body: 'Pushed the new OKLCH token set to staging. All chart colors, success/warning/info, sidebar variants. Take a look when you have a sec.',
          receivedAt: yesterday,
          read: true,
          starred: true,
          hasAttachment: false,
          labels: ['work'],
        },
        {
          id: 'e6',
          folder: 'inbox',
          from: { name: 'United Airlines', email: '[email protected]', initials: 'UA' },
          to: '[email protected]',
          subject: 'Your itinerary: SFO → LHR, departing Apr 12',
          preview: 'Your reservation is confirmed. Check-in opens 24h before departure. Booking reference: 7HQ4P2.',
          body: 'Your reservation is confirmed. Check-in opens 24h before departure. Booking reference: 7HQ4P2.',
          receivedAt: new Date(now.getTime() - 38 * 60 * 60 * 1000),
          read: true,
          starred: false,
          hasAttachment: true,
          labels: ['travel'],
        },
        {
          id: 'e7',
          folder: 'inbox',
          from: { name: 'Mom', email: '[email protected]', initials: 'M' },
          to: '[email protected]',
          subject: 'Sunday lunch?',
          preview: 'Are you free for lunch this Sunday? Dad is making his lasagne. Let me know!',
          body: 'Are you free for lunch this Sunday? Dad is making his lasagne. Let me know!',
          receivedAt: new Date(now.getTime() - 55 * 60 * 60 * 1000),
          read: true,
          starred: false,
          hasAttachment: false,
          labels: ['personal'],
        },
      ]
    }
    
    function formatMailTime(d: Date): string {
      const now = new Date()
      const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
      if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
      const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
      if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'
      const diffDays = Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000))
      if (diffDays < 7) return d.toLocaleDateString(undefined, { weekday: 'short' })
      return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
    }
    
    function formatFullTime(d: Date): string {
      return d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
    }
    
    function labelMeta(id: string): InboxLabel | undefined {
      return LABELS.find((l) => l.id === id)
    }
    
    /**
     * <uip-inbox> — the registry Inbox block as a web component.
     *
     * Class strings are React's Inbox verbatim. React state becomes @state
     * fields. Folder icons are static per-row markup (React mapped icon
     * components; here each row spells out its lucide icon). The reply box uses
     * a native <textarea> (as in React) with Cmd/Ctrl+Enter to send. The list
     * rows are <button>s; the star is a nested span-button — React's structure
     * is kept, with stopPropagation on toggle.
     */
    export class UipInbox extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        mails: { state: true },
        activeFolder: { state: true },
        activeLabel: { state: true },
        activeId: { state: true },
        search: { state: true },
        replyMode: { state: true },
        replyDraft: { state: true },
        justSent: { state: true },
      }
    
      private mails: InboxMail[] = []
      private activeFolder: InboxFolderId = 'inbox'
      private activeLabel: string | null = null
      private activeId = 'e1'
      private search = ''
      private replyMode: InboxReplyMode = 'reply'
      private replyDraft = ''
      private justSent = false
      private sentTimer?: number
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'inbox')
        if (this.mails.length === 0) this.mails = seedMails()
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        if (this.sentTimer) window.clearTimeout(this.sentTimer)
      }
    
      private unreadCount(id: InboxFolderId): number {
        if (id === 'starred') return this.mails.filter((m) => m.starred && !m.read && m.folder !== 'trash').length
        return this.mails.filter((m) => m.folder === id && !m.read).length
      }
    
      private get labelCounts(): Record<string, number> {
        const acc: Record<string, number> = {}
        for (const l of LABELS) acc[l.id] = this.mails.filter((m) => m.labels.includes(l.id)).length
        return acc
      }
    
      private get visibleMails(): InboxMail[] {
        const q = this.search.trim().toLowerCase()
        let list = this.mails.filter((m) => {
          if (this.activeLabel) return m.labels.includes(this.activeLabel)
          if (this.activeFolder === 'starred') return m.starred && m.folder !== 'trash'
          return m.folder === this.activeFolder
        })
        if (q) {
          list = list.filter(
            (m) =>
              m.subject.toLowerCase().includes(q) ||
              m.from.name.toLowerCase().includes(q) ||
              m.preview.toLowerCase().includes(q),
          )
        }
        return [...list].sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
      }
    
      private get activeMail(): InboxMail | null {
        return this.mails.find((m) => m.id === this.activeId) ?? null
      }
    
      private selectMail(id: string) {
        this.activeId = id
        this.mails = this.mails.map((m) => (m.id === id ? { ...m, read: true } : m))
      }
    
      private selectFolder(id: InboxFolderId) {
        this.activeFolder = id
        this.activeLabel = null
        const list = [...this.mails]
          .filter((m) => (id === 'starred' ? m.starred && m.folder !== 'trash' : m.folder === id))
          .sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
        if (list[0]) this.activeId = list[0].id
      }
    
      private selectLabel(id: string) {
        this.activeLabel = id
        const list = [...this.mails]
          .filter((m) => m.labels.includes(id))
          .sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
        if (list[0]) this.activeId = list[0].id
      }
    
      private toggleStar(id: string, e?: Event) {
        e?.stopPropagation()
        this.mails = this.mails.map((m) => (m.id === id ? { ...m, starred: !m.starred } : m))
      }
    
      private archive(id: string) {
        const next = this.mails.filter((m) => m.id !== id)
        const visible = [...next]
          .filter((m) => {
            if (this.activeLabel) return m.labels.includes(this.activeLabel)
            if (this.activeFolder === 'starred') return m.starred && m.folder !== 'trash'
            return m.folder === this.activeFolder
          })
          .sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
        if (visible[0]) this.activeId = visible[0].id
        this.mails = next
      }
    
      private folderIcon(id: InboxFolderId, cls: string) {
        switch (id) {
          case 'inbox':
            return icon(InboxIcon, 'inbox', cls)
          case 'starred':
            return icon(Star, 'star', cls)
          case 'sent':
            return icon(Send, 'send', cls)
          case 'drafts':
            return icon(FileText, 'file-text', cls)
          case 'spam':
            return icon(AlertOctagon, 'octagon-alert', cls)
          default:
            return icon(Trash2, 'trash-2', cls)
        }
      }
    
      private sendReply() {
        const body = this.replyDraft.trim()
        if (!body) return
        this.replyDraft = ''
        this.justSent = true
        if (this.sentTimer) window.clearTimeout(this.sentTimer)
        this.sentTimer = window.setTimeout(() => {
          this.justSent = false
        }, 1800)
      }
    
      render() {
        const activeMail = this.activeMail
        const visible = this.visibleMails
        const counts = this.labelCounts
        const replyRecipient = activeMail && this.replyMode !== 'forward' ? `${activeMail.from.name} <${activeMail.from.email}>` : ''
        const replyPlaceholder = this.replyMode === 'forward' ? 'Add a note before forwarding' : 'Write a reply'
        return html`
          <div
            data-slot="inbox"
            class=${cn('bg-card text-card-foreground grid h-[680px] w-full grid-cols-[220px_340px_1fr] overflow-hidden rounded-xl border shadow-sm')}
          >
            <aside class="bg-muted/30 flex min-w-0 flex-col border-r">
              <div class="flex h-14 shrink-0 items-center border-b px-3">
                <uip-button class="h-9 w-full justify-start gap-2 rounded-lg" size="sm">
                  ${icon(Pencil, 'pencil', 'size-4')} Compose
                </uip-button>
              </div>
              <nav class="flex-1 space-y-1 overflow-y-auto px-3 pt-3 pb-3">
                <p class="text-muted-foreground px-2 pb-1 text-xs font-semibold tracking-widest uppercase">Folders</p>
                ${FOLDERS.map(
                  (f) => html`
                    <button
                      class=${cn(
                        'flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
                        this.activeFolder === f.id && !this.activeLabel
                          ? 'bg-primary/10 text-primary font-medium'
                          : 'hover:bg-muted/60 text-foreground/80',
                      )}
                      @click=${() => this.selectFolder(f.id)}
                    >
                      <span class="flex min-w-0 items-center gap-2">
                        ${this.folderIcon(f.id, 'size-4 shrink-0')}
                        <span class="truncate">${f.label}</span>
                      </span>
                      ${this.unreadCount(f.id) > 0
                        ? html`<span class="text-muted-foreground shrink-0 text-xs tabular-nums"
                            >${this.unreadCount(f.id)}</span
                          >`
                        : ''}
                    </button>
                  `,
                )}
    
                <p class="text-muted-foreground mt-4 px-2 pb-1 text-xs font-semibold tracking-widest uppercase">Labels</p>
                ${LABELS.map(
                  (l) => html`
                    <button
                      class=${cn(
                        'flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
                        this.activeLabel === l.id
                          ? 'bg-primary/10 text-primary font-medium'
                          : 'hover:bg-muted/60 text-foreground/80',
                      )}
                      @click=${() => this.selectLabel(l.id)}
                    >
                      <span class="flex min-w-0 items-center gap-2">
                        <span class=${cn('size-2.5 shrink-0 rounded-full', l.color)}></span>
                        <span class="truncate">${l.label}</span>
                      </span>
                      <span class="text-muted-foreground shrink-0 text-xs tabular-nums">${counts[l.id] ?? 0}</span>
                    </button>
                  `,
                )}
              </nav>
            </aside>
    
            <section class="bg-background flex min-w-0 flex-col border-r">
              <div class="flex h-14 shrink-0 items-center gap-2 border-b px-3">
                <div class="relative flex-1">
                  <uip-input
                    .value=${this.search}
                    placeholder="Search mail"
                    class="h-8 rounded-lg text-xs"
                    @input=${(e: Event) => {
                      this.search = (e.target as UipInput).value
                    }}
                  >
                    <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span>
                  </uip-input>
                </div>
                <uip-button variant="ghost" size="icon" class="size-8" aria-label="Filter">
                  ${icon(Tag, 'tag', 'size-4')}
                </uip-button>
              </div>
              <div class="flex items-center justify-between px-4 pt-3 pb-2">
                <h2 class="text-sm font-semibold tracking-tight capitalize">
                  ${this.activeLabel ? labelMeta(this.activeLabel)?.label : this.activeFolder}
                </h2>
                <span class="text-muted-foreground text-xs tabular-nums">
                  ${visible.length} message${visible.length === 1 ? '' : 's'}
                </span>
              </div>
              <div class="list-scroll flex-1 overflow-y-auto pb-3">
                ${visible.map(
                  (m) => html`
                    <button
                      aria-label=${`Open message from ${m.from.name}`}
                      class=${cn(
                        'group relative w-full px-4 py-3 text-left transition-colors',
                        m.id === this.activeId ? 'bg-primary/5' : 'hover:bg-muted/60',
                      )}
                      @click=${() => this.selectMail(m.id)}
                    >
                      ${m.id === this.activeId
                        ? html`<span class="bg-primary absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full"></span>`
                        : ''}
                      <div class="flex items-start gap-3">
                        <uip-avatar class="mt-0.5 size-8 shrink-0">
                          <uip-avatar-fallback class="text-xs">${m.from.initials}</uip-avatar-fallback>
                        </uip-avatar>
                        <div class="min-w-0 flex-1">
                          <div class="flex items-center justify-between gap-2">
                            <p class=${cn('truncate text-sm', m.read ? 'font-medium' : 'font-semibold')}>${m.from.name}</p>
                            <span class="text-muted-foreground shrink-0 text-xs tabular-nums"
                              >${formatMailTime(m.receivedAt)}</span
                            >
                          </div>
                          <p class=${cn('mt-0.5 truncate text-xs', m.read ? 'text-foreground/80' : 'font-semibold')}>
                            ${m.subject}
                          </p>
                          <p class="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">${m.preview}</p>
                          <div class="mt-1.5 flex items-center gap-1.5">
                            ${m.labels.map(
                              (lid) => html`
                                <span
                                  class="text-muted-foreground bg-muted inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs"
                                >
                                  <span class=${cn('size-1.5 rounded-full', labelMeta(lid)?.color ?? '')}></span>
                                  ${labelMeta(lid)?.label}
                                </span>
                              `,
                            )}
                            ${m.hasAttachment ? icon(Paperclip, 'paperclip', 'text-muted-foreground size-3') : ''}
                            ${!m.read ? html`<span class="bg-primary ml-auto size-1.5 shrink-0 rounded-full"></span>` : ''}
                          </div>
                        </div>
                        <span
                          role="button"
                          tabindex="0"
                          class="text-muted-foreground hover:text-warning inline-flex shrink-0 cursor-pointer items-center justify-center transition-colors"
                          title=${m.starred ? 'Unstar' : 'Star'}
                          @click=${(e: Event) => this.toggleStar(m.id, e)}
                          @keydown=${(e: KeyboardEvent) => {
                            if (e.key === 'Enter' || e.key === ' ') {
                              e.preventDefault()
                              this.toggleStar(m.id, e)
                            }
                          }}
                        >
                          ${icon(Star, 'star', cn('size-3.5', m.starred ? 'fill-warning text-warning' : ''))}
                        </span>
                      </div>
                    </button>
                  `,
                )}
                ${visible.length === 0
                  ? html`
                      <div class="flex flex-col items-center justify-center px-6 py-16 text-center">
                        <div class="bg-muted mb-3 rounded-full p-3">${icon(InboxIcon, 'inbox', 'text-muted-foreground size-5')}</div>
                        <p class="text-sm font-medium">Nothing here</p>
                        <p class="text-muted-foreground mt-0.5 text-xs">No messages match the current filter.</p>
                      </div>
                    `
                  : ''}
              </div>
            </section>
    
            <section class="bg-background flex min-w-0 flex-col">
              ${activeMail
                ? html`
                    <div class="flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4">
                      <div class="flex items-center gap-1">
                        <uip-button variant="ghost" size="icon" class="size-8" aria-label="Back">
                          ${icon(ArrowLeft, 'arrow-left', 'size-4')}
                        </uip-button>
                        <uip-button
                          variant="ghost"
                          size="icon"
                          class="size-8"
                          aria-label="Archive"
                          @click=${() => this.archive(activeMail.id)}
                        >
                          ${icon(Archive, 'archive', 'size-4')}
                        </uip-button>
                        <uip-button variant="ghost" size="icon" class="size-8" aria-label="Delete">
                          ${icon(Trash2, 'trash-2', 'size-4')}
                        </uip-button>
                        <uip-button variant="ghost" size="icon" class="size-8" aria-label="Save">
                          ${icon(Bookmark, 'bookmark', 'size-4')}
                        </uip-button>
                      </div>
                      <div class="flex items-center gap-1">
                        <uip-button variant="ghost" size="icon" class="size-8" aria-label="Reply">
                          ${icon(Reply, 'reply', 'size-4')}
                        </uip-button>
                        <uip-button variant="ghost" size="icon" class="size-8" aria-label="Reply all">
                          ${icon(ReplyAll, 'reply-all', 'size-4')}
                        </uip-button>
                        <uip-button variant="ghost" size="icon" class="size-8" aria-label="Forward">
                          ${icon(Forward, 'forward', 'size-4')}
                        </uip-button>
                        <uip-button variant="ghost" size="icon" class="size-8" aria-label="More options">
                          ${icon(MoreVertical, 'ellipsis-vertical', 'size-4')}
                        </uip-button>
                      </div>
                    </div>
    
                    <div class="reader-scroll flex-1 overflow-y-auto">
                      <div class="px-6 pt-5 pb-3">
                        <h1 class="text-foreground text-xl leading-tight font-semibold tracking-tight">
                          ${activeMail.subject}
                        </h1>
                        <div class="mt-2 flex flex-wrap items-center gap-2">
                          ${activeMail.labels.map(
                            (lid) => html`
                              <span
                                class="text-foreground/70 bg-muted inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
                              >
                                <span class=${cn('size-1.5 rounded-full', labelMeta(lid)?.color ?? '')}></span>
                                ${labelMeta(lid)?.label}
                              </span>
                            `,
                          )}
                        </div>
                      </div>
    
                      <div class="flex items-start gap-3 px-6 pb-4">
                        <uip-avatar class="size-10">
                          <uip-avatar-fallback>${activeMail.from.initials}</uip-avatar-fallback>
                        </uip-avatar>
                        <div class="min-w-0 flex-1">
                          <div class="flex flex-wrap items-baseline gap-x-2">
                            <p class="text-sm font-semibold">${activeMail.from.name}</p>
                            <p class="text-muted-foreground text-xs">&lt;${activeMail.from.email}&gt;</p>
                          </div>
                          <p class="text-muted-foreground text-xs">to ${activeMail.to}</p>
                        </div>
                        <div class="text-muted-foreground flex shrink-0 items-center gap-2 text-xs">
                          <span>${formatFullTime(activeMail.receivedAt)}</span>
                          <button
                            class="hover:text-warning transition-colors"
                            title=${activeMail.starred ? 'Unstar' : 'Star'}
                            @click=${() => this.toggleStar(activeMail.id)}
                          >
                            ${icon(Star, 'star', cn('size-4', activeMail.starred ? 'fill-warning text-warning' : ''))}
                          </button>
                        </div>
                      </div>
    
                      <article class="text-foreground/90 px-6 pb-6 text-sm leading-relaxed whitespace-pre-wrap">
                        ${activeMail.body}
                      </article>
    
                      ${activeMail.hasAttachment
                        ? html`
                            <div class="px-6 pb-6">
                              <div class="bg-muted/40 flex items-center gap-3 rounded-lg border px-3 py-2.5">
                                <div class="bg-info/10 text-info flex size-8 items-center justify-center rounded-md">
                                  ${icon(FileText, 'file-text', 'size-4')}
                                </div>
                                <div class="min-w-0 flex-1">
                                  <p class="truncate text-xs font-medium">attachment.pdf</p>
                                  <p class="text-muted-foreground text-xs">142 KB · PDF</p>
                                </div>
                                <uip-button aria-label="Download attachment" variant="ghost" size="sm" class="h-7 text-xs">
                                  Download
                                </uip-button>
                              </div>
                            </div>
                          `
                        : ''}
                    </div>
    
                    <div class="bg-muted/20 border-t px-4 pt-3 pb-3">
                      <div class="mb-2 flex items-center gap-1">
                        <button
                          class=${cn(
                            'inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors',
                            this.replyMode === 'reply'
                              ? 'bg-background text-foreground border-input/60 border shadow-sm'
                              : 'text-muted-foreground hover:bg-background/60 hover:text-foreground',
                          )}
                          @click=${() => {
                            this.replyMode = 'reply'
                            this.justSent = false
                          }}
                        >
                          ${icon(Reply, 'reply', 'size-3.5')} Reply
                        </button>
                        <button
                          class=${cn(
                            'inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors',
                            this.replyMode === 'reply-all'
                              ? 'bg-background text-foreground border-input/60 border shadow-sm'
                              : 'text-muted-foreground hover:bg-background/60 hover:text-foreground',
                          )}
                          @click=${() => {
                            this.replyMode = 'reply-all'
                            this.justSent = false
                          }}
                        >
                          ${icon(ReplyAll, 'reply-all', 'size-3.5')} Reply all
                        </button>
                        <button
                          class=${cn(
                            'inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors',
                            this.replyMode === 'forward'
                              ? 'bg-background text-foreground border-input/60 border shadow-sm'
                              : 'text-muted-foreground hover:bg-background/60 hover:text-foreground',
                          )}
                          @click=${() => {
                            this.replyMode = 'forward'
                            this.justSent = false
                          }}
                        >
                          ${icon(Forward, 'forward', 'size-3.5')} Forward
                        </button>
                      </div>
                      <div
                        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"
                      >
                        ${replyRecipient
                          ? html`<p class="border-input/40 text-muted-foreground border-b px-4 pt-2.5 pb-2 text-xs">
                              <span class="text-foreground/70 font-medium">To:</span> ${replyRecipient}
                            </p>`
                          : ''}
                        <textarea
                          .value=${this.replyDraft}
                          placeholder=${replyPlaceholder}
                          rows="1"
                          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 outline-none focus-visible:ring-0"
                          @input=${(e: Event) => {
                            this.replyDraft = (e.target as HTMLTextAreaElement).value
                          }}
                          @keydown=${(e: KeyboardEvent) => {
                            if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
                              e.preventDefault()
                              this.sendReply()
                            }
                          }}
                        ></textarea>
                        <div class="flex items-center justify-between gap-2 px-2 pb-2">
                          <div class="flex items-center gap-0.5">
                            <uip-button
                              variant="ghost"
                              size="icon"
                              class="text-muted-foreground hover:text-foreground size-8"
                              aria-label="Attach file"
                            >
                              ${icon(Paperclip, 'paperclip', 'size-4')}
                            </uip-button>
                            <uip-button
                              variant="ghost"
                              size="icon"
                              class="text-muted-foreground hover:text-foreground size-8"
                              aria-label="Emoji"
                            >
                              ${icon(Smile, 'smile', 'size-4')}
                            </uip-button>
                            <uip-button
                              variant="ghost"
                              size="icon"
                              class="text-muted-foreground hover:text-foreground size-8"
                              aria-label="Mention"
                            >
                              ${icon(AtSign, 'at-sign', 'size-4')}
                            </uip-button>
                          </div>
                          <div class="flex items-center gap-2">
                            <kbd
                              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"
                            >
                              <span>⌘</span>
                              <span>↵</span>
                            </kbd>
                            <uip-button
                              class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium"
                              ?disabled=${!this.replyDraft.trim()}
                              aria-label="Send"
                              @click=${this.sendReply}
                            >
                              Send ${icon(ArrowRight, 'arrow-right', 'size-3.5')}
                            </uip-button>
                          </div>
                        </div>
                      </div>
                      ${this.justSent ? html`<p class="text-success mt-2 text-center text-xs font-medium">Reply sent</p>` : ''}
                    </div>
                  `
                : html`
                    <div class="flex flex-1 items-center justify-center">
                      <div class="text-center">
                        <div class="bg-muted mx-auto mb-3 size-12 rounded-full p-3">
                          ${icon(InboxIcon, 'inbox', 'text-muted-foreground size-6')}
                        </div>
                        <p class="text-sm font-medium">No message selected</p>
                        <p class="text-muted-foreground mt-1 text-xs">Pick a message from the list to read it here.</p>
                      </div>
                    </div>
                  `}
            </section>
          </div>
        `
      }
    }
    
    customElements.get('uip-inbox') || customElements.define('uip-inbox', UipInbox)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-inbox': UipInbox
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/inbox.json