Inbox
blockcommunicationThree-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
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/inbox.json$npx shadcn@latest add https://uipkge.dev/r/lit/inbox.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/inbox.json$bunx shadcn@latest add https://uipkge.dev/r/lit/inbox.jsonManifest:
https://uipkge.dev/r/lit/inbox.jsonInstalls to:src/uip/blocks/inbox/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
InboxFolderinterface InboxFolder {
id: InboxFolderId
label: string
}InboxLabelinterface InboxLabel {
id: string
label: string
color: string
}InboxMailinterface 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[]
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-inbox': UipInbox
}
}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: 'sarah@acme.com', initials: 'SC' }, to: 'you@acme.com', 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: 'marcus@acme.com', initials: 'MR' }, to: 'you@acme.com', 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: 'receipts@stripe.com', initials: 'St' }, to: 'you@acme.com', 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: 'no-reply@linear.app', initials: 'Li' }, to: 'you@acme.com', 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: 'priya@acme.com', initials: 'PS' }, to: 'you@acme.com', 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: 'reservations@united.com', initials: 'UA' }, to: 'you@acme.com', 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: 'mom@family.net', initials: 'M' }, to: 'you@acme.com', 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"><${activeMail.from.email}></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