Przejdź do treści
Wszystkie przykłady

Poczta

Foldery, przeszukiwana lista wiadomości i panel czytania, które na telefonie układają się w jedną kolumnę.

Dodaj ten przykład do projektu z zainstalowanym prfct. Polecenie zapisuje poniższe pliki i instaluje komponenty, których używają.

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/mail.json
components/mail/mail.tsx
"use client"

import * as React from "react"
import {
  ArchiveIcon,
  ArrowLeftIcon,
  DownloadIcon,
  EllipsisVerticalIcon,
  FilePenLineIcon,
  FileTextIcon,
  ImageIcon,
  InboxIcon,
  MailIcon,
  MailOpenIcon,
  MenuIcon,
  OctagonAlertIcon,
  PaperclipIcon,
  ReplyIcon,
  SearchIcon,
  SendIcon,
  SquarePenIcon,
  StarIcon,
  Trash2Icon,
  XIcon,
} from "lucide-react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupTextarea,
} from "@/components/ui/input-group"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  Sheet,
  SheetContent,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

type FolderId = "inbox" | "drafts" | "sent" | "archive" | "spam" | "trash"

type Message = {
  id: string
  folder: FolderId
  /** Organizations get a square avatar, people a round one. */
  from: { name: string; email: string; org?: boolean }
  to: string
  subject: string
  time: string
  date: string
  /** Paragraphs; a nested array is a bulleted list. */
  body: (string | string[])[]
  labels: string[]
  attachments: { name: string; size: string }[]
  unread: boolean
  starred: boolean
}

const me = { name: "Maya Okafor", email: "maya@halden.app" }

const folders = [
  { id: "inbox", label: "Inbox", icon: InboxIcon },
  { id: "starred", label: "Starred", icon: StarIcon },
  { id: "drafts", label: "Drafts", icon: FilePenLineIcon },
  { id: "sent", label: "Sent", icon: SendIcon },
  { id: "archive", label: "Archive", icon: ArchiveIcon },
  { id: "spam", label: "Spam", icon: OctagonAlertIcon },
  { id: "trash", label: "Trash", icon: Trash2Icon },
] as const

const labels = ["Launch", "Design", "Engineering", "Hiring", "Finance"]

const initialMessages: Message[] = [
  {
    id: "m1",
    folder: "inbox",
    from: { name: "Priya Raman", email: "priya@halden.app" },
    to: "Product team",
    subject: "Launch review: Halden 3.2",
    time: "12m",
    date: "Thu, Oct 29, 1:18 PM",
    body: [
      "Hi team,",
      "Thanks for a focused review this morning. We're a go for Tuesday, November 3, with two conditions we agreed on in the room:",
      [
        "The billing migration finishes in staging by Friday noon, and Amara signs off on the rollback plan.",
        "Help center articles for the new scheduling view are published before the announcement email goes out.",
      ],
      "If either slips, we move to Thursday rather than ship half the story. Kenji will post the final checklist in the launch channel by the end of the day.",
      "The slides and the decision log are attached.",
      "Priya",
    ],
    labels: ["Launch"],
    attachments: [{ name: "launch-review-3.2.pdf", size: "1.2 MB" }],
    unread: false,
    starred: true,
  },
  {
    id: "m2",
    folder: "inbox",
    from: {
      name: "Northwind Billing",
      email: "billing@northwind.io",
      org: true,
    },
    to: "Maya Okafor",
    subject: "Your October invoice is ready",
    time: "1h",
    date: "Thu, Oct 29, 12:24 PM",
    body: [
      "Hi Maya,",
      "Your invoice for October is ready. The total covers 14 seats on the Team plan, including the two seats added on October 17, prorated for the rest of the month.",
      [
        "Invoice: INV-20481",
        "Amount due: $1,260.00",
        "Due date: November 12, 2026",
      ],
      "The card ending in 4242 will be charged automatically on the due date. You can download a copy below or from your billing settings at any time.",
      "Northwind Billing",
    ],
    labels: ["Finance"],
    attachments: [{ name: "INV-20481.pdf", size: "84 KB" }],
    unread: true,
    starred: false,
  },
  {
    id: "m3",
    folder: "inbox",
    from: { name: "Tomás Herrera", email: "tomas@halden.app" },
    to: "Maya Okafor, Design reviews",
    subject: "Design critique notes: onboarding flow",
    time: "2h",
    date: "Thu, Oct 29, 11:30 AM",
    body: [
      "Thanks for walking us through the new onboarding today. Three themes came up more than once, so I grouped the notes by those rather than by screen.",
      [
        "The workspace step asks for too much before people see any value. Could the team name be optional?",
        "Progress is unclear after step two. Several people expected four steps, not six.",
        "The empty calendar reads as broken. A sample week, clearly marked, would help.",
      ],
      "Overall the direction is right, and the new flow feels much calmer than the current one. I've attached the annotated board from the session.",
      "Happy to pair on the empty state on Monday if that's useful.",
      "Tomás",
    ],
    labels: ["Design"],
    attachments: [{ name: "onboarding-critique.png", size: "2.4 MB" }],
    unread: true,
    starred: false,
  },
  {
    id: "m4",
    folder: "inbox",
    from: { name: "Amara Nwosu", email: "amara@halden.app" },
    to: "Engineering",
    subject: "On-call handover, week 44",
    time: "3h",
    date: "Thu, Oct 29, 10:27 AM",
    body: [
      "Handing over to Daniel for week 45. Quiet week overall: two pages, both resolved within the hour.",
      "One open item to keep an eye on:",
      [
        "Webhook retries are delayed by up to 20 minutes for a few workspaces in eu-west. The fix is merged and rolls out with Monday's deploy.",
        "If retries back up past 30 minutes, the runbook has a one-line command to drain the queue by hand.",
      ],
      "Dashboards and the incident timeline are linked in the handover doc. I'm around until 6 PM if anything is unclear.",
      "Amara",
    ],
    labels: ["Engineering"],
    attachments: [],
    unread: true,
    starred: false,
  },
  {
    id: "m5",
    folder: "inbox",
    from: { name: "Lena Fischer", email: "lena@halden.app" },
    to: "Maya Okafor, Tomás Herrera",
    subject: "Interview feedback: Jonah Park, senior frontend",
    time: "Yesterday",
    date: "Wed, Oct 28, 5:40 PM",
    body: [
      "Strong yes from me. Jonah's system design round was the clearest we've seen this cycle: he asked about constraints before drawing anything and changed course calmly when we pushed back.",
      "Where I'd like a second opinion:",
      [
        "He's lighter on accessibility testing. He knows the principles but hasn't owned an audit.",
        "Most of his recent work is in one large, slow-moving codebase. I'm curious how he'd handle our pace.",
      ],
      "My scorecard is in the hiring tool. Could you or Tomás cover the product sense round on Thursday?",
      "Lena",
    ],
    labels: ["Hiring"],
    attachments: [],
    unread: false,
    starred: false,
  },
  {
    id: "m6",
    folder: "inbox",
    from: {
      name: "Meridian Travel",
      email: "trips@meridian.travel",
      org: true,
    },
    to: "Maya Okafor",
    subject: "Your trip to Lisbon, November 9–12",
    time: "Yesterday",
    date: "Wed, Oct 28, 11:02 AM",
    body: [
      "Hi Maya, your trip for the team offsite is confirmed.",
      [
        "Outbound: HL 2041, Berlin to Lisbon, Monday, November 9, 7:40 AM",
        "Hotel: Casa Alfama, three nights, check-in from 3 PM",
        "Return: HL 2046, Lisbon to Berlin, Thursday, November 12, 6:15 PM",
      ],
      "Your booking reference is K7Q2PX. Online check-in opens 24 hours before departure.",
      "Have a good trip,",
      "Meridian Travel",
    ],
    labels: [],
    attachments: [{ name: "itinerary-lisbon.pdf", size: "312 KB" }],
    unread: true,
    starred: false,
  },
  {
    id: "m7",
    folder: "inbox",
    from: { name: "Kenji Watanabe", email: "kenji@halden.app" },
    to: "Product team",
    subject: "Q4 roadmap draft for comments",
    time: "Tue",
    date: "Tue, Oct 27, 3:15 PM",
    body: [
      "I've folded in everything from last week's planning session. The biggest change is moving reporting to January, so the mobile app gets the attention it needs before the holidays.",
      "Please leave comments in the doc by Monday. I'm looking for two things in particular:",
      [
        "Anything that doesn't have a clear owner yet",
        "Dependencies on teams we haven't talked to",
      ],
      "I'll share the final version at the all-hands on November 6.",
      "Kenji",
    ],
    labels: ["Launch"],
    attachments: [],
    unread: false,
    starred: true,
  },
  {
    id: "m8",
    folder: "inbox",
    from: { name: "Sofia Marin", email: "sofia@halden.app" },
    to: "Maya Okafor, Tomás Herrera",
    subject: "Customer interview recordings",
    time: "Tue",
    date: "Tue, Oct 27, 9:20 AM",
    body: [
      "I've uploaded the five sessions from Thursday to the research folder. Two of them mention the CSV export bug unprompted, which makes me think it's more common than the support tickets suggest.",
      "The highlights I'd watch first:",
      [
        "Session 2, from 14:10: a clinic manager rebuilding her week by hand after an export",
        "Session 4, from 31:45: a strong case for recurring bookings with exceptions",
      ],
      "Notes for every session are in the attached summary.",
      "Sofia",
    ],
    labels: ["Design"],
    attachments: [{ name: "interview-summary.pdf", size: "640 KB" }],
    unread: false,
    starred: false,
  },
  {
    id: "m9",
    folder: "inbox",
    from: { name: "Halden Status", email: "status@halden.app", org: true },
    to: "Engineering",
    subject: "Resolved: elevated API latency in eu-west",
    time: "Mon",
    date: "Mon, Oct 26, 3:52 PM",
    body: [
      "This incident has been resolved.",
      "Between 2:05 and 2:41 PM UTC on October 26, a portion of API requests in the eu-west region took longer than usual to complete. Scheduling, notifications and the web app were not affected, and no data was lost.",
      "The cause was a database failover that took longer than expected to promote a replica. We've adjusted the health check thresholds and will publish a full write-up within five working days.",
      "Halden Status",
    ],
    labels: ["Engineering"],
    attachments: [],
    unread: false,
    starred: false,
  },
  {
    id: "m10",
    folder: "inbox",
    from: { name: "Grace Liu", email: "grace@halden.app" },
    to: "Everyone at Halden",
    subject: "Offsite dinner: final headcount",
    time: "Oct 23",
    date: "Fri, Oct 23, 10:14 AM",
    body: [
      "Hi all,",
      "We have a table for 18 at Taberna do Largo on Tuesday, November 10, at 7:30 PM. It's a ten-minute walk from the hotel.",
      "Let me know by Friday, October 30, if you have dietary needs or can't make it, so I can confirm numbers with the restaurant.",
      "Grace",
    ],
    labels: [],
    attachments: [],
    unread: false,
    starred: false,
  },
  {
    id: "m11",
    folder: "drafts",
    from: me,
    to: "Lena Fischer",
    subject: "Re: Interview feedback: Jonah Park, senior frontend",
    time: "Yesterday",
    date: "Wed, Oct 28, 6:03 PM",
    body: [
      "Happy to cover the product sense round on Thursday. Could we move it to 2 PM? I have a design review until 1:30.",
    ],
    labels: ["Hiring"],
    attachments: [],
    unread: false,
    starred: false,
  },
  {
    id: "m12",
    folder: "sent",
    from: me,
    to: "Tomás Herrera",
    subject: "Onboarding prototype for tomorrow",
    time: "Yesterday",
    date: "Wed, Oct 28, 4:12 PM",
    body: [
      "Hi Tomás,",
      "Here's the clickable prototype for tomorrow's critique. Start from the invite email; the rest should explain itself.",
      "I'd love the group to focus on the first five minutes: what people see before they've set anything up.",
      "Thanks,",
      "Maya",
    ],
    labels: ["Design"],
    attachments: [],
    unread: false,
    starred: false,
  },
]

/** Whether a message belongs in a folder, the Starred view or a label. */
function inView(message: Message, view: string) {
  const hidden = message.folder === "spam" || message.folder === "trash"
  if (view === "starred") return message.starred && !hidden
  if (labels.includes(view)) return message.labels.includes(view) && !hidden
  return message.folder === view
}

/** The inbox counts unread mail; drafts and spam count everything. */
function folderCount(messages: Message[], folder: string) {
  const inFolder = messages.filter((m) => m.folder === folder)
  if (folder === "inbox") return inFolder.filter((m) => m.unread).length
  if (folder === "drafts" || folder === "spam") return inFolder.length
  return 0
}

const initials = (name: string) =>
  name
    .split(" ")
    .map((word) => word[0])
    .join("")
    .slice(0, 2)

const fromMe = (message: Message) => message.from.email === me.email

/** Who a conversation is with: the recipient of mail you wrote, else the sender. */
const correspondent = (message: Message) =>
  fromMe(message) ? message.to : message.from.name

/** Like most mail apps, the list previews the start of the body. */
const preview = (message: Message) => message.body.flat().join(" ")

/**
 * An icon-only button. The tooltip repeats its accessible name, or a shorter
 * one when the name carries detail that's already on screen.
 */
function ToolbarButton({
  label,
  tooltip = label,
  children,
  ...props
}: React.ComponentProps<typeof Button> & { label: string; tooltip?: string }) {
  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={label}
            {...props}
          />
        }
      >
        {children}
      </TooltipTrigger>
      <TooltipContent>{tooltip}</TooltipContent>
    </Tooltip>
  )
}

type NavigationProps = {
  messages: Message[]
  view: string
  onViewChange: (view: string) => void
  onCompose: () => void
}

function Navigation({
  messages,
  view,
  onViewChange,
  onCompose,
}: NavigationProps) {
  const labelsId = React.useId()
  return (
    <div className="flex h-full min-h-0 flex-col">
      {/* In the sheet, leave room for its close button. */}
      <div className="flex h-14 shrink-0 items-center gap-2.5 border-b px-3 in-data-[slot=sheet-content]:pr-14">
        <Avatar>
          <AvatarFallback>{initials(me.name)}</AvatarFallback>
        </Avatar>
        <div className="flex min-w-0 flex-col">
          <span className="truncate text-sm leading-5 font-medium">
            {me.name}
          </span>
          <span className="truncate text-xs text-muted-foreground">
            {me.email}
          </span>
        </div>
      </div>
      <ScrollArea className="min-h-0 flex-1">
        <div className="flex flex-col gap-5 p-3">
          <Button onClick={onCompose}>
            <SquarePenIcon data-icon="inline-start" />
            Compose
          </Button>
          <nav aria-label="Mailboxes" className="flex flex-col gap-5">
            <ul className="flex flex-col gap-px">
              {folders.map(({ id, label, icon: Icon }) => (
                <NavItem
                  key={id}
                  icon={<Icon />}
                  label={label}
                  count={folderCount(messages, id)}
                  current={view === id}
                  onClick={() => onViewChange(id)}
                />
              ))}
            </ul>
            <div className="flex flex-col gap-1">
              <span
                id={labelsId}
                className="px-2 text-xs font-medium text-muted-foreground"
              >
                Labels
              </span>
              <ul aria-labelledby={labelsId} className="flex flex-col gap-px">
                {labels.map((label) => (
                  <NavItem
                    key={label}
                    icon={
                      <span className="size-2 rounded-full border-[1.5px] border-current" />
                    }
                    label={label}
                    current={view === label}
                    onClick={() => onViewChange(label)}
                  />
                ))}
              </ul>
            </div>
          </nav>
        </div>
      </ScrollArea>
    </div>
  )
}

function NavItem({
  icon,
  label,
  count = 0,
  current,
  onClick,
}: {
  icon: React.ReactNode
  label: string
  count?: number
  current: boolean
  onClick: () => void
}) {
  return (
    <li>
      <button
        type="button"
        aria-current={current ? "page" : undefined}
        onClick={onClick}
        className={cn(
          "flex h-8 w-full items-center gap-2.5 rounded-lg px-2 text-sm text-muted-foreground focus-ring-inset outline-none",
          "hover:bg-accent hover:text-foreground",
          "aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"
        )}
      >
        <span className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
          {icon}
        </span>
        <span className="truncate">{label}</span>
        {count > 0 && (
          <span className="ml-auto text-xs text-muted-foreground numeric">
            {count}
          </span>
        )}
      </button>
    </li>
  )
}

/** The navigation in a sheet, for containers too narrow to show it. */
function MailboxSheet(navigation: NavigationProps) {
  const [open, setOpen] = React.useState(false)
  return (
    <Sheet open={open} onOpenChange={setOpen}>
      <SheetTrigger
        render={
          <ToolbarButton label="Mailboxes" className="@4xl/mail:hidden" />
        }
      >
        <MenuIcon />
      </SheetTrigger>
      <SheetContent side="left" className="w-[min(18rem,calc(100%-3rem))]">
        <SheetTitle className="sr-only">Mailboxes</SheetTitle>
        {/* Choosing a mailbox or composing closes the sheet. */}
        <Navigation
          {...navigation}
          onViewChange={(view) => {
            setOpen(false)
            navigation.onViewChange(view)
          }}
          onCompose={() => {
            setOpen(false)
            navigation.onCompose()
          }}
        />
      </SheetContent>
    </Sheet>
  )
}

type ListPaneProps = {
  title: string
  messages: Message[]
  selectedId: string
  onOpen: (id: string) => void
  query: string
  onQueryChange: (query: string) => void
  unreadOnly: boolean
  onUnreadOnlyChange: (unreadOnly: boolean) => void
  navigation: NavigationProps
}

function ListPane({
  title,
  messages,
  selectedId,
  onOpen,
  query,
  onQueryChange,
  unreadOnly,
  onUnreadOnlyChange,
  navigation,
}: ListPaneProps) {
  const titleId = React.useId()
  const searching = query.trim() !== ""
  const [emptyTitle, emptyDescription] = searching
    ? ["No results", "No messages match your search."]
    : unreadOnly
      ? ["All caught up", "You've read everything in this view."]
      : ["Nothing here", "Messages you move here will show up in this list."]

  return (
    <section aria-labelledby={titleId} className="flex h-full flex-col">
      <header className="flex flex-col border-b">
        <div className="flex h-14 items-center gap-1 px-2 @4xl/mail:px-4">
          <MailboxSheet {...navigation} />
          <h2
            id={titleId}
            className="min-w-0 flex-1 truncate px-1 text-heading-sm @4xl/mail:px-0"
          >
            {title}
          </h2>
          <ToolbarButton
            label="Compose"
            className="@4xl/mail:hidden"
            onClick={navigation.onCompose}
          >
            <SquarePenIcon />
          </ToolbarButton>
        </div>
        {/* Search and filter share a row when they fit; otherwise the filter
            wraps below at full width, its segments as wide as the longest. */}
        <div className="flex flex-wrap gap-2 px-3 pb-3 @4xl/mail:px-4">
          <InputGroup className="h-8 flex-[2_1_10rem]">
            <InputGroupAddon>
              <SearchIcon />
            </InputGroupAddon>
            {/* The browser's own clear button gives way to one that matches
                the toolbar. */}
            <InputGroupInput
              type="search"
              placeholder="Search"
              aria-label="Search mail"
              value={query}
              onChange={(event) => onQueryChange(event.target.value)}
              className="[&::-webkit-search-cancel-button]:appearance-none"
            />
            {query && (
              <InputGroupAddon align="inline-end">
                <InputGroupButton
                  size="icon-xs"
                  aria-label="Clear search"
                  onClick={() => onQueryChange("")}
                >
                  <XIcon />
                </InputGroupButton>
              </InputGroupAddon>
            )}
          </InputGroup>
          <ToggleGroup
            value={[unreadOnly ? "unread" : "all"]}
            onValueChange={(value) =>
              value.length > 0 && onUnreadOnlyChange(value[0] === "unread")
            }
            variant="outline"
            size="sm"
            spacing={0}
            aria-label="Show"
            className="grid flex-[1_0_auto] auto-cols-fr grid-flow-col"
          >
            <ToggleGroupItem value="all">All</ToggleGroupItem>
            <ToggleGroupItem value="unread">Unread</ToggleGroupItem>
          </ToggleGroup>
        </div>
      </header>

      {/* Keyed, so each mailbox opens scrolled to the top. */}
      <ScrollArea key={title} className="@2xl/mail:min-h-0 @2xl/mail:flex-1">
        {messages.length > 0 ? (
          <ul className="flex flex-col gap-px p-2">
            {messages.map((message) => (
              <li key={message.id}>
                <MessageRow
                  message={message}
                  selected={message.id === selectedId}
                  onSelect={() => onOpen(message.id)}
                />
              </li>
            ))}
          </ul>
        ) : (
          <Empty className="py-16">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                {searching ? <SearchIcon /> : <InboxIcon />}
              </EmptyMedia>
              <EmptyTitle>{emptyTitle}</EmptyTitle>
              <EmptyDescription>{emptyDescription}</EmptyDescription>
            </EmptyHeader>
          </Empty>
        )}
      </ScrollArea>
    </section>
  )
}

function MessageRow({
  message,
  selected,
  onSelect,
}: {
  message: Message
  selected: boolean
  onSelect: () => void
}) {
  return (
    <button
      type="button"
      aria-current={selected || undefined}
      onClick={onSelect}
      className={cn(
        "group/row grid w-full grid-cols-[0.5rem_minmax(0,1fr)] gap-x-2.5 rounded-lg px-3 py-2.5 text-left focus-ring-inset outline-none",
        "hover:bg-accent/60 aria-[current=true]:bg-accent"
      )}
    >
      <span className="flex h-5 items-center">
        {message.unread && (
          <span
            role="img"
            aria-label="Unread message"
            className="size-2 rounded-full bg-brand"
          />
        )}
      </span>
      <span className="flex min-w-0 flex-col gap-0.5">
        <span className="flex items-center gap-2">
          <span
            className={cn(
              "truncate text-sm leading-5",
              message.unread ? "font-semibold" : "font-medium"
            )}
          >
            {fromMe(message) ? `To ${message.to}` : message.from.name}
          </span>
          <span className="ml-auto flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground [&_svg]:size-3.5">
            {message.starred && (
              <StarIcon
                role="img"
                aria-label="Starred"
                className="fill-current"
              />
            )}
            {message.attachments.length > 0 && (
              <PaperclipIcon role="img" aria-label="Has attachments" />
            )}
            <span className="numeric">{message.time}</span>
          </span>
        </span>
        <span
          className={cn(
            "truncate text-body-xs",
            message.unread && "font-medium"
          )}
        >
          {message.subject}
        </span>
        <span className="truncate text-body-xs text-muted-foreground">
          {preview(message)}
        </span>
        {message.labels.length > 0 && (
          <span className="mt-1.5 flex flex-wrap gap-1">
            {message.labels.map((label) => (
              <Badge
                key={label}
                variant="secondary"
                size="sm"
                className="group-aria-[current=true]/row:bg-background"
              >
                {label}
              </Badge>
            ))}
          </span>
        )}
      </span>
    </button>
  )
}

type ReadingPaneProps = {
  message?: Message
  backLabel: string
  headingRef: React.Ref<HTMLHeadingElement>
  onBack: () => void
  onUpdate: (id: string, change: Partial<Message>) => void
  onMove: (message: Message, folder: FolderId, notice: string) => void
  onReply: (message: Message, text: string) => void
}

function ReadingPane({
  message,
  backLabel,
  headingRef,
  onBack,
  onUpdate,
  onMove,
  onReply,
}: ReadingPaneProps) {
  const replyRef = React.useRef<HTMLTextAreaElement>(null)

  if (!message) {
    return (
      <Empty className="h-full">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <MailIcon />
          </EmptyMedia>
          <EmptyTitle>No message selected</EmptyTitle>
          <EmptyDescription>
            Choose a message from the list to read it here.
          </EmptyDescription>
        </EmptyHeader>
      </Empty>
    )
  }

  return (
    <div className="@container/reading flex h-full flex-col">
      <div className="flex h-14 shrink-0 items-center gap-1 border-b px-2 @lg/reading:px-4">
        <ToolbarButton
          label={backLabel}
          className="@2xl/mail:hidden"
          onClick={onBack}
        >
          <ArrowLeftIcon />
        </ToolbarButton>
        <ToolbarButton
          label="Move to Archive"
          disabled={message.folder === "archive"}
          onClick={() => onMove(message, "archive", "Moved to Archive")}
        >
          <ArchiveIcon />
        </ToolbarButton>
        <ToolbarButton
          label="Move to Trash"
          disabled={message.folder === "trash"}
          onClick={() => onMove(message, "trash", "Moved to Trash")}
        >
          <Trash2Icon />
        </ToolbarButton>
        <ToolbarButton
          label={message.unread ? "Mark as read" : "Mark as unread"}
          onClick={() => onUpdate(message.id, { unread: !message.unread })}
        >
          {message.unread ? <MailOpenIcon /> : <MailIcon />}
        </ToolbarButton>
        <div className="ml-auto flex items-center gap-1">
          <ToolbarButton
            label="Reply"
            onClick={() => replyRef.current?.focus()}
          >
            <ReplyIcon />
          </ToolbarButton>
          <MessageMenu message={message} onUpdate={onUpdate} onMove={onMove} />
        </div>
      </div>

      {/* Keyed, so each message opens scrolled to the top with an empty reply. */}
      <ScrollArea
        key={message.id}
        className="@2xl/mail:min-h-0 @2xl/mail:flex-1"
      >
        <MessageView
          message={message}
          headingRef={headingRef}
          replyRef={replyRef}
          onUpdate={onUpdate}
          onReply={onReply}
        />
      </ScrollArea>
    </div>
  )
}

function MessageView({
  message,
  headingRef,
  replyRef,
  onUpdate,
  onReply,
}: Pick<ReadingPaneProps, "headingRef" | "onUpdate" | "onReply"> & {
  message: Message
  replyRef: React.Ref<HTMLTextAreaElement>
}) {
  const subjectId = React.useId()
  return (
    <article
      aria-labelledby={subjectId}
      className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-5 @lg/reading:px-8 @lg/reading:py-7"
    >
      <header className="flex flex-col gap-5">
        <div className="flex items-start gap-3">
          <div className="flex min-w-0 flex-1 flex-col gap-2">
            <h2
              id={subjectId}
              ref={headingRef}
              tabIndex={-1}
              className="text-heading-md text-balance outline-none @xl/reading:text-heading-lg"
            >
              {message.subject}
            </h2>
            {message.labels.length > 0 && (
              <div className="flex flex-wrap gap-1">
                {message.labels.map((label) => (
                  <Badge key={label} variant="secondary">
                    {label}
                  </Badge>
                ))}
              </div>
            )}
          </div>
          <ToolbarButton
            label="Star"
            aria-pressed={message.starred}
            className="-mr-1 text-muted-foreground aria-pressed:text-foreground @xl/reading:mt-0.5"
            onClick={() => onUpdate(message.id, { starred: !message.starred })}
          >
            <StarIcon className={cn(message.starred && "fill-current")} />
          </ToolbarButton>
        </div>
        <div className="flex items-start gap-3">
          <Avatar size="lg" shape={message.from.org ? "square" : "circle"}>
            <AvatarFallback>{initials(message.from.name)}</AvatarFallback>
          </Avatar>
          <div className="flex min-w-0 flex-1 flex-col gap-0.5 text-body-xs text-muted-foreground @md/reading:flex-row @md/reading:justify-between @md/reading:gap-4">
            <div className="flex min-w-0 flex-col gap-0.5">
              <p className="flex flex-wrap items-baseline gap-x-2 text-sm">
                <span className="font-medium text-foreground">
                  {message.from.name}
                </span>
                <span className="truncate">{message.from.email}</span>
              </p>
              <p>{`To ${message.to}`}</p>
            </div>
            <p className="shrink-0 @md/reading:text-xs">{message.date}</p>
          </div>
        </div>
      </header>

      <div className="flex max-w-[65ch] flex-col gap-4 text-body-sm">
        {message.body.map((block, i) =>
          Array.isArray(block) ? (
            <ul
              key={i}
              className="flex list-disc flex-col gap-1.5 pl-5 marker:text-muted-foreground"
            >
              {block.map((item) => (
                <li key={item} className="pl-1">
                  {item}
                </li>
              ))}
            </ul>
          ) : (
            <p key={i}>{block}</p>
          )
        )}
      </div>

      {message.attachments.length > 0 && (
        <ul
          aria-label="Attachments"
          className="grid gap-2 @xl/reading:grid-cols-2"
        >
          {message.attachments.map((file) => (
            <Attachment key={file.name} {...file} />
          ))}
        </ul>
      )}

      <ReplyBox
        to={correspondent(message)}
        inputRef={replyRef}
        onSend={(text) => onReply(message, text)}
      />
    </article>
  )
}

/** Labels and spam, the actions you need less often. */
function MessageMenu({
  message,
  onUpdate,
  onMove,
}: Pick<ReadingPaneProps, "onUpdate" | "onMove"> & { message: Message }) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<ToolbarButton label="More actions" />}>
        <EllipsisVerticalIcon />
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-52">
        <DropdownMenuGroup>
          <DropdownMenuLabel>Labels</DropdownMenuLabel>
          {labels.map((label) => (
            <DropdownMenuCheckboxItem
              key={label}
              checked={message.labels.includes(label)}
              onCheckedChange={(checked) =>
                // Rebuilt from the full list, so labels keep their order.
                onUpdate(message.id, {
                  labels: labels.filter((l) =>
                    l === label ? checked : message.labels.includes(l)
                  ),
                })
              }
            >
              {label}
            </DropdownMenuCheckboxItem>
          ))}
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem
          disabled={message.folder === "spam"}
          onClick={() => onMove(message, "spam", "Marked as spam")}
        >
          <OctagonAlertIcon />
          Report spam
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

function Attachment({ name, size }: { name: string; size: string }) {
  const Icon = /\.(png|jpe?g|gif|webp)$/i.test(name) ? ImageIcon : FileTextIcon
  const extension = name.split(".").pop()?.toUpperCase()
  return (
    <li className="flex min-w-0 items-center gap-3 rounded-xl border bg-card p-2 pr-1.5">
      <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
        <Icon className="size-5" />
      </span>
      <span className="flex min-w-0 flex-1 flex-col">
        <span title={name} className="truncate text-sm leading-5 font-medium">
          {name}
        </span>
        <span className="text-xs text-muted-foreground numeric">
          {`${extension} · ${size}`}
        </span>
      </span>
      <ToolbarButton
        label={`Download ${name}`}
        tooltip="Download"
        onClick={() =>
          toast.add({ title: "Download started", description: name })
        }
      >
        <DownloadIcon />
      </ToolbarButton>
    </li>
  )
}

function ReplyBox({
  to,
  inputRef,
  onSend,
}: {
  to: string
  inputRef: React.Ref<HTMLTextAreaElement>
  onSend: (text: string) => void
}) {
  const [text, setText] = React.useState("")
  const id = React.useId()

  function send(event?: React.FormEvent) {
    event?.preventDefault()
    if (!text.trim()) return
    onSend(text.trim())
    setText("")
  }

  return (
    <form onSubmit={send}>
      <InputGroup>
        <InputGroupAddon align="block-start">
          <ReplyIcon className="size-3.5" />
          <label htmlFor={id} className="truncate text-xs font-medium">
            {`Reply to ${to}`}
          </label>
        </InputGroupAddon>
        <InputGroupTextarea
          id={id}
          ref={inputRef}
          value={text}
          onChange={(event) => setText(event.target.value)}
          onKeyDown={(event) => {
            if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
              send()
            }
          }}
          placeholder="Write a reply…"
          className="min-h-20"
        />
        <InputGroupAddon align="block-end" className="justify-end">
          {/* Disabled without the native attribute: InputGroup greys out
              when any descendant is :disabled. */}
          <Button
            type="submit"
            size="sm"
            disabled={!text.trim()}
            focusableWhenDisabled
          >
            Send
            <SendIcon data-icon="inline-end" />
          </Button>
        </InputGroupAddon>
      </InputGroup>
    </form>
  )
}

type Draft = { to: string; subject: string; body: string }

/** Mounted each time the dialog opens, so every message starts blank. */
function ComposeForm({ onSend }: { onSend: (draft: Draft) => void }) {
  const [invalid, setInvalid] = React.useState(false)
  const toRef = React.useRef<HTMLInputElement>(null)
  const id = React.useId()

  function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const data = new FormData(event.currentTarget)
    const to = String(data.get("to")).trim()
    if (!/^\S+@\S+\.\S+$/.test(to)) {
      setInvalid(true)
      toRef.current?.focus()
      return
    }
    onSend({
      to,
      subject: String(data.get("subject")).trim(),
      body: String(data.get("body")).trim(),
    })
  }

  return (
    <form onSubmit={submit} noValidate className="flex flex-col gap-5">
      <DialogHeader>
        <DialogTitle>New message</DialogTitle>
      </DialogHeader>
      <FieldGroup className="gap-4">
        <Field>
          <FieldLabel htmlFor={`${id}-to`}>To</FieldLabel>
          <Input
            ref={toRef}
            id={`${id}-to`}
            name="to"
            type="email"
            // Someone else's address: the browser would offer the sender's own.
            autoComplete="off"
            placeholder="name@company.com"
            aria-invalid={invalid || undefined}
            onChange={() => setInvalid(false)}
          />
          {invalid && <FieldError>Enter an email address.</FieldError>}
        </Field>
        <Field>
          <FieldLabel htmlFor={`${id}-subject`}>Subject</FieldLabel>
          <Input id={`${id}-subject`} name="subject" />
        </Field>
        <Field>
          <FieldLabel htmlFor={`${id}-body`}>Message</FieldLabel>
          <Textarea id={`${id}-body`} name="body" className="min-h-32" />
        </Field>
      </FieldGroup>
      <DialogFooter>
        <DialogClose render={<Button variant="outline" />}>Discard</DialogClose>
        <Button type="submit">
          Send
          <SendIcon data-icon="inline-end" />
        </Button>
      </DialogFooter>
    </form>
  )
}

/**
 * Lays the panes out for the space available: all three on wide containers;
 * the list and the message on mid-sized ones, with mailboxes in a sheet; and
 * one pane at a time on phones, where `pane` picks which.
 */
function MailLayout({
  pane,
  listRef,
  sidebar,
  list,
  message,
}: {
  pane: "list" | "message"
  listRef: React.Ref<HTMLDivElement>
  sidebar: React.ReactNode
  list: React.ReactNode
  message: React.ReactNode
}) {
  return (
    <div
      className={cn(
        "grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl border bg-background",
        "@2xl/mail:h-[44rem] @2xl/mail:grid-cols-[17rem_minmax(0,1fr)] @2xl/mail:grid-rows-[minmax(0,1fr)]",
        "@4xl/mail:grid-cols-[13rem_18rem_minmax(0,1fr)]",
        "@6xl/mail:grid-cols-[15rem_22rem_minmax(0,1fr)]"
      )}
    >
      <aside className="hidden border-r bg-surface @4xl/mail:block">
        {sidebar}
      </aside>
      <div
        ref={listRef}
        className={cn(
          "min-w-0 @2xl/mail:border-r",
          pane === "message" && "@max-2xl/mail:hidden"
        )}
      >
        {list}
      </div>
      <div className={cn("min-w-0", pane === "list" && "@max-2xl/mail:hidden")}>
        {message}
      </div>
    </div>
  )
}

export default function MailExample() {
  const [messages, setMessages] = React.useState(initialMessages)
  const [view, setView] = React.useState("inbox")
  const [query, setQuery] = React.useState("")
  const [unreadOnly, setUnreadOnly] = React.useState(false)
  const [selectedId, setSelectedId] = React.useState("m1")
  const [pane, setPane] = React.useState<"list" | "message">("list")
  const [composing, setComposing] = React.useState(false)

  const rootRef = React.useRef<HTMLDivElement>(null)
  const listRef = React.useRef<HTMLDivElement>(null)
  const headingRef = React.useRef<HTMLHeadingElement>(null)

  const title = folders.find((folder) => folder.id === view)?.label ?? view
  const inFolder = messages.filter((m) => inView(m, view))
  const selected = inFolder.find((m) => m.id === selectedId)
  const search = query.trim().toLowerCase()
  const visible = inFolder.filter(
    (m) =>
      // The open message stays listed once read, so it doesn't vanish from
      // the Unread view the moment you open it.
      (!unreadOnly || m.unread || m.id === selectedId) &&
      `${m.from.name} ${m.to} ${m.subject} ${preview(m)}`
        .toLowerCase()
        .includes(search)
  )

  function update(id: string, change: Partial<Message>) {
    setMessages((current) =>
      current.map((m) => (m.id === id ? { ...m, ...change } : m))
    )
  }

  function open(id: string) {
    setSelectedId(id)
    update(id, { unread: false })
    setPane("message")
    // When the message replaces the list (phones), start reading at the top.
    requestAnimationFrame(() => {
      if (listRef.current?.offsetParent !== null) return
      rootRef.current?.scrollIntoView({ block: "start" })
      headingRef.current?.focus({ preventScroll: true })
    })
  }

  function back() {
    setPane("list")
    requestAnimationFrame(() =>
      listRef.current
        ?.querySelector<HTMLElement>("[aria-current=true]")
        ?.focus()
    )
  }

  function changeView(next: string) {
    setView(next)
    setQuery("")
    setPane("list")
  }

  function send({ to, subject, body }: Draft, notice: string) {
    setMessages((current) => [
      {
        id: `sent-${Date.now()}`,
        folder: "sent",
        from: me,
        to,
        subject: subject || "No subject",
        time: "Now",
        date: "Just now",
        body: body.split(/\n\s*\n/),
        labels: [],
        attachments: [],
        unread: false,
        starred: false,
      },
      ...current,
    ])
    toast.add({ title: notice, description: `To ${to}`, type: "success" })
  }

  function reply(message: Message, text: string) {
    const to = correspondent(message)
    send({ to, subject: `Re: ${message.subject}`, body: text }, "Reply sent")
  }

  /** Moves a message out of view and opens the next one, with an undo. */
  function move(message: Message, folder: FolderId, notice: string) {
    const index = visible.findIndex((m) => m.id === message.id)
    const next = visible[index + 1] ?? visible[index - 1]
    update(message.id, { folder })
    if (next) open(next.id)
    else back()
    const id = toast.add({
      title: notice,
      timeout: 8000,
      actionProps: {
        children: "Undo",
        onClick: () => {
          toast.close(id)
          update(message.id, { folder: message.folder })
          setSelectedId(message.id)
        },
      },
    })
  }

  const navigation = {
    messages,
    view,
    onViewChange: changeView,
    onCompose: () => setComposing(true),
  }

  return (
    <TooltipProvider>
      <div ref={rootRef} className="@container/mail">
        <MailLayout
          pane={pane}
          listRef={listRef}
          sidebar={<Navigation {...navigation} />}
          list={
            <ListPane
              title={title}
              messages={visible}
              selectedId={selectedId}
              onOpen={open}
              query={query}
              onQueryChange={setQuery}
              unreadOnly={unreadOnly}
              onUnreadOnlyChange={setUnreadOnly}
              navigation={navigation}
            />
          }
          message={
            <ReadingPane
              message={selected}
              backLabel={`Back to ${title}`}
              headingRef={headingRef}
              onBack={back}
              onUpdate={update}
              onMove={move}
              onReply={reply}
            />
          }
        />

        <Dialog open={composing} onOpenChange={setComposing}>
          <DialogContent>
            <ComposeForm
              onSend={(draft) => {
                send(draft, "Message sent")
                setComposing(false)
              }}
            />
          </DialogContent>
        </Dialog>
      </div>
    </TooltipProvider>
  )
}
app/mail/page.tsx
import type { Metadata } from "next"

import Mail from "@/components/mail/mail"
import { Toaster } from "@/components/ui/toast"

export const metadata: Metadata = {
  title: "Mail",
  description: "A three-pane inbox: folders, a message list with unread states, and a reading pane with a reply box.",
}

export default function MailPage() {
  return (
    <Toaster>
      <main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
        <Mail />
      </main>
    </Toaster>
  )
}