An expandable table-style invoice row that opens to a line-item mini-table via the grid-rows transition, with a synced chevron, status badge, and single-open group behavior.
npx shadcn@latest add @paragon/invoice-row"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export type InvoiceStatus = "paid" | "due" | "overdue" | "draft";
const statusClasses: Record<InvoiceStatus, string> = {
paid: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
due: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
overdue: "bg-red-500/10 text-red-700 dark:text-red-400",
draft: "bg-muted text-muted-foreground",
};
const statusLabels: Record<InvoiceStatus, string> = {
paid: "Paid",
due: "Due",
overdue: "Overdue",
draft: "Draft",
};
interface InvoiceRowGroupContextValue {
openId: string | null;
setOpenId: (id: string | null) => void;
}
const InvoiceRowGroupContext =
React.createContext<InvoiceRowGroupContextValue | null>(null);
export interface InvoiceRowGroupProps extends React.ComponentProps<"div"> {}
/**
* Accordion behavior for invoice rows: at most one row is expanded at a
* time — opening a row collapses its sibling. Rows outside a group manage
* their own state independently.
*/
export function InvoiceRowGroup({
className,
children,
...props
}: InvoiceRowGroupProps) {
const [openId, setOpenId] = React.useState<string | null>(null);
const context = React.useMemo(() => ({ openId, setOpenId }), [openId]);
return (
<InvoiceRowGroupContext.Provider value={context}>
<div
data-slot="invoice-row-group"
className={cn(
"w-full divide-y divide-border overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
{children}
</div>
</InvoiceRowGroupContext.Provider>
);
}
export interface InvoiceRowProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Invoice number, e.g. "INV-1042". */
number: string;
/** Client or account name. */
client: string;
/** Formatted amount — rendered tabular-nums, right-aligned. */
amount: string;
status?: InvoiceStatus;
/** Issue or due date, shown next to the client on wide rows. */
date?: string;
/** Expanded detail — typically an InvoiceLineItems table. */
children?: React.ReactNode;
/** Disables the expand/collapse and chevron transitions. */
static?: boolean;
}
/**
* An expandable table-style row. The summary line is a real button; detail
* expands via the `grid-template-rows: 0fr ↔ 1fr` transition so rapid
* toggles retarget mid-flight. The chevron rotates in sync and the row
* tints on hover.
*/
export function InvoiceRow({
number,
client,
amount,
status = "due",
date,
static: isStatic = false,
className,
children,
...props
}: InvoiceRowProps) {
const group = React.useContext(InvoiceRowGroupContext);
const [localOpen, setLocalOpen] = React.useState(false);
const id = React.useId();
const contentId = `${id}-detail`;
const open = group ? group.openId === id : localOpen;
const toggle = () => {
if (group) group.setOpenId(open ? null : id);
else setLocalOpen(!open);
};
return (
<div
data-slot="invoice-row"
data-state={open ? "open" : "closed"}
className={cn("bg-card", className)}
{...props}
>
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={toggle}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors duration-(--duration-fast) ease-out sm:gap-4",
"hover:bg-accent/50",
open && "bg-accent/30",
)}
>
<ChevronDown
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground",
open && "rotate-180",
!isStatic && "transition-[rotate] motion-reduce:transition-none",
!isStatic &&
(open
? "duration-(--duration-base) ease-out"
: "duration-(--duration-quick) ease-(--ease-exit)"),
)}
/>
<span className="w-20 shrink-0 text-sm font-medium tabular-nums">
{number}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{client}
{date ? (
<span className="hidden text-muted-foreground/70 sm:inline">
{" "}
· {date}
</span>
) : null}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
statusClasses[status],
)}
>
{statusLabels[status]}
</span>
<span className="w-24 shrink-0 text-right text-sm font-medium tabular-nums">
{amount}
</span>
</button>
<div
id={contentId}
role="region"
aria-label={`${number} line items`}
className={cn(
"grid",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
!isStatic &&
"transition-[grid-template-rows] motion-reduce:transition-none",
!isStatic &&
(open
? "duration-(--duration-base) ease-out"
: "duration-(--duration-quick) ease-(--ease-exit)"),
)}
>
<div
inert={!open}
className={cn(
"min-h-0 overflow-hidden",
open ? "opacity-100" : "opacity-0",
!isStatic &&
(open
? "transition-opacity duration-(--duration-base) ease-out"
: "transition-opacity duration-(--duration-quick) ease-(--ease-exit)"),
)}
>
<div className="border-t border-border bg-secondary/40 px-4 py-3 pl-11 dark:bg-secondary/20">
{children}
</div>
</div>
</div>
</div>
);
}
export interface InvoiceLineItem {
description: string;
quantity: number;
/** Formatted unit price, e.g. "$120.00". */
unitPrice: string;
/** Formatted line total. */
total: string;
}
export interface InvoiceLineItemsProps extends React.ComponentProps<"table"> {
items: InvoiceLineItem[];
}
/** The mini line-item table used inside an expanded InvoiceRow. */
export function InvoiceLineItems({
items,
className,
...props
}: InvoiceLineItemsProps) {
return (
<table
data-slot="invoice-line-items"
className={cn("w-full text-sm", className)}
{...props}
>
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="pb-2 font-medium">Description</th>
<th className="pb-2 text-right font-medium">Qty</th>
<th className="hidden pb-2 text-right font-medium sm:table-cell">
Unit
</th>
<th className="pb-2 text-right font-medium">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{items.map((item) => (
<tr key={item.description}>
<td className="py-1.5 pr-4">{item.description}</td>
<td className="py-1.5 text-right tabular-nums">{item.quantity}</td>
<td className="hidden py-1.5 text-right tabular-nums sm:table-cell">
{item.unitPrice}
</td>
<td className="py-1.5 text-right font-medium tabular-nums">
{item.total}
</td>
</tr>
))}
</tbody>
</table>
);
}