A past-order row that expands via a grid-rows transition to reveal its line items and tracking status.
npx shadcn@latest add @paragon/order-history-row"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>
);
}