E-commerce

Order History Row

A past-order row that expands via a grid-rows transition to reveal its line items and tracking status.

Install

npx shadcn@latest add @paragon/order-history-row

order-history-row.tsx

"use client";

import * as React from "react";
import { ChevronDown, Package, Truck, CheckCircle2 } from "lucide-react";
import { cn } from "@/lib/utils";

export interface OrderLine {
  name: string;
  qty: number;
  price: number;
  hue?: number;
}

export interface Order {
  id: string;
  /** Display order number, e.g. "#10482". */
  number: string;
  date: string;
  total: number;
  status: "processing" | "shipped" | "delivered";
  /** Tracking / status line shown when expanded. */
  tracking?: string;
  items: OrderLine[];
}

export interface OrderHistoryRowProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  order: Order;
  defaultOpen?: boolean;
  currency?: string;
  locale?: Intl.LocalesArgument;
}

const STATUS: Record<
  Order["status"],
  { label: string; className: string; Icon: typeof Package }
> = {
  processing: {
    label: "Processing",
    className: "bg-warning/15 text-warning",
    Icon: Package,
  },
  shipped: {
    label: "Shipped",
    className: "bg-foreground/10 text-foreground",
    Icon: Truck,
  },
  delivered: {
    label: "Delivered",
    className: "bg-success/12 text-success",
    Icon: CheckCircle2,
  },
};

function placeholder(hue: number) {
  return `linear-gradient(135deg, hsl(${hue} 55% 82%), hsl(${(hue + 40) % 360} 60% 66%))`;
}

/**
 * A past-order row that expands to its line items and tracking. The disclosure
 * uses a grid-template-rows 0fr↔1fr transition (the only permitted height
 * animation) so content reveals without measuring; the chevron rotates.
 * Amounts are tabular. Reduced motion drops the transition, keeps the toggle.
 */
export function OrderHistoryRow({
  order,
  defaultOpen = false,
  currency = "USD",
  locale = "en-US",
  className,
  ...props
}: OrderHistoryRowProps) {
  const [open, setOpen] = React.useState(defaultOpen);
  const contentId = React.useId();
  const format = React.useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency }),
    [locale, currency],
  );
  const status = STATUS[order.status];

  return (
    <div
      className={cn(
        "w-full max-w-lg overflow-hidden rounded-xl bg-card shadow-border",
        className,
      )}
      {...props}
    >
      <button
        type="button"
        aria-expanded={open}
        aria-controls={contentId}
        onClick={() => setOpen((o) => !o)}
        className="flex w-full items-center gap-3 px-4 py-3 text-left outline-none transition-colors duration-150 hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
      >
        <span
          aria-hidden
          className={cn(
            "flex size-9 shrink-0 items-center justify-center rounded-lg",
            status.className,
          )}
        >
          <status.Icon className="size-4" />
        </span>
        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-2">
            <span className="text-sm font-medium text-card-foreground tabular-nums">
              {order.number}
            </span>
            <span
              className={cn(
                "rounded-full px-1.5 py-0.5 text-[11px] font-medium",
                status.className,
              )}
            >
              {status.label}
            </span>
          </div>
          <p className="text-xs text-muted-foreground tabular-nums">
            {order.date} · {order.items.length}{" "}
            {order.items.length === 1 ? "item" : "items"}
          </p>
        </div>
        <span className="text-sm font-semibold text-card-foreground tabular-nums">
          {format.format(order.total)}
        </span>
        <ChevronDown
          aria-hidden
          className={cn(
            "size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out motion-reduce:transition-none",
            open && "rotate-180",
          )}
        />
      </button>

      <div
        id={contentId}
        className="grid transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-in-out) motion-reduce:transition-none"
        style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
      >
        <div className="overflow-hidden">
          <div className="border-t border-border px-4 py-3">
            {order.tracking && (
              <p className="mb-3 flex items-center gap-2 text-xs text-muted-foreground">
                <Truck className="size-3.5 shrink-0" aria-hidden />
                {order.tracking}
              </p>
            )}
            <ul className="flex flex-col gap-2.5">
              {order.items.map((line, i) => (
                <li key={i} className="flex items-center gap-3">
                  <span
                    aria-hidden
                    className="size-9 shrink-0 rounded-md"
                    style={{ background: placeholder(line.hue ?? i * 47) }}
                  />
                  <span className="min-w-0 flex-1 truncate text-sm text-card-foreground">
                    {line.name}
                  </span>
                  <span className="text-xs text-muted-foreground tabular-nums">
                    ×{line.qty}
                  </span>
                  <span className="text-sm text-card-foreground tabular-nums">
                    {format.format(line.price)}
                  </span>
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>
    </div>
  );
}