An audit-log entry that expands via a grid-rows transition to a field-level before/after diff with removed values struck through and added values tinted.
npx shadcn@latest add @paragon/audit-log-rowAlso installs: avatar
"use client";
import * as React from "react";
import { ChevronRight } from "lucide-react";
import { Avatar, AvatarFallback } from "@/registry/paragon/ui/avatar";
import { cn } from "@/lib/utils";
export interface AuditFieldChange {
field: string;
/** Prior value; omit for a newly-set field. */
before?: string;
/** New value; omit for a removed field. */
after?: string;
}
export interface AuditLogRowProps extends React.ComponentProps<"div"> {
/** Actor who performed the action. */
actor?: string;
/** Human action summary, e.g. "updated role for". */
action?: string;
/** Object acted on. */
target?: string;
/** Relative timestamp. */
time?: string;
/** Source IP / client, shown in the expanded header. */
ip?: string;
/** Field-level before/after changes revealed on expand. */
changes?: AuditFieldChange[];
/** Start expanded. */
defaultExpanded?: boolean;
}
const DEFAULT_CHANGES: AuditFieldChange[] = [
{ field: "role", before: "member", after: "admin" },
{ field: "can_manage_billing", before: "false", after: "true" },
{ field: "mfa_required", before: "false", after: "true" },
];
/**
* An audit-log entry that expands to a field-level before/after diff. The
* disclosure grows through a grid-template-rows 0fr→1fr transition (the one
* height animation the spec permits), the chevron rotates, and removed values
* tint destructive with added values tinting success. Fully keyboard-operable
* via a real toggle button; the panel is `inert` while collapsed so its
* controls stay out of the tab order. Reduced motion drops the grow to an
* instant reveal.
*/
export function AuditLogRow({
actor = "Priya Raghavan",
action = "updated permissions for",
target = "marcus@acme.io",
time = "Today at 14:32",
ip = "203.0.113.24",
changes = DEFAULT_CHANGES,
defaultExpanded = false,
className,
...props
}: AuditLogRowProps) {
const [expanded, setExpanded] = React.useState(defaultExpanded);
const panelId = React.useId();
return (
<div
data-slot="audit-log-row"
data-expanded={expanded || undefined}
className={cn(
"overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<button
type="button"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() => setExpanded((v) => !v)}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left",
"transition-colors duration-(--duration-fast) hover:bg-muted/40",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
>
<ChevronRight
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
expanded && "rotate-90",
)}
/>
<Avatar size="sm" className="shrink-0">
<AvatarFallback name={actor} />
</Avatar>
<p className="min-w-0 flex-1 truncate text-[13px]">
<span className="font-medium">{actor}</span>{" "}
<span className="text-muted-foreground">{action}</span>{" "}
<span className="font-medium">{target}</span>
</p>
<span className="shrink-0 text-xs text-muted-foreground whitespace-nowrap tabular-nums">
{time}
</span>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div id={panelId} inert={!expanded} className="min-h-0 overflow-hidden">
<div className="border-t px-4 py-3">
<div className="mb-2 flex items-center gap-3 text-[11px] text-muted-foreground">
<span>
IP <span className="font-mono text-foreground">{ip}</span>
</span>
<span>
{changes.length} field{changes.length === 1 ? "" : "s"} changed
</span>
</div>
<dl className="overflow-hidden rounded-lg border font-mono text-xs">
{changes.map((change, i) => (
<div
key={change.field}
className={cn(
"grid grid-cols-[7rem_1fr] items-start gap-x-3 px-3 py-1.5",
i > 0 && "border-t",
)}
>
<dt className="truncate text-muted-foreground">
{change.field}
</dt>
<dd className="flex min-w-0 flex-wrap items-center gap-1.5">
{change.before !== undefined && (
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-destructive line-through">
{change.before}
</span>
)}
{change.before !== undefined &&
change.after !== undefined && (
<ChevronRight
aria-hidden
className="size-3 text-muted-foreground"
/>
)}
{change.after !== undefined && (
<span className="rounded bg-success/12 px-1.5 py-0.5 text-success">
{change.after}
</span>
)}
</dd>
</div>
))}
</dl>
</div>
</div>
</div>
</div>
);
}