feat(frontend): CSV export utility + ExportCsvButton component

This commit is contained in:
Tyler
2026-06-20 17:12:44 -06:00
parent 4cd52c3084
commit 162127b494
4 changed files with 503 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
import * as React from "react";
import { Download } from "lucide-react";
import { Button, type ButtonProps } from "@/components/ui/button";
import { defaultCsvFilename, downloadCsv, toCsv, type CsvColumn } from "@/lib/csv";
import { cn } from "@/lib/utils";
export type ExportCsvButtonProps<T> = {
/** Rows to serialize. Empty rows → button disabled with a tooltip. */
rows: T[];
/** Column descriptors (header + per-row extractor). */
columns: CsvColumn<T>[];
/** Override the download filename. Defaults to `{prefix}-{YYYY-MM-DD}.csv`. */
filename?: string;
/** Filename prefix when no explicit filename is provided. */
filenamePrefix?: string;
/** Visible label. Defaults to "Export CSV". */
label?: string;
/** Render an icon-only button with `aria-label="Export as CSV"`. */
iconOnly?: boolean;
/** Forwarded to the underlying Button. */
disabled?: boolean;
/** Forwarded to the underlying Button for layout tweaks. */
className?: string;
} & Omit<ButtonProps, "onClick" | "children" | "disabled">;
/**
* One-line CSV export: a page passes `rows` + `columns`, we serialize and
* trigger a download. Disabled (with a tooltip) when there's nothing to
* export. The "Exporting..." state is decorative — `downloadCsv` is sync —
* but it gives the click tactile feedback so the action feels deliberate.
*/
export function ExportCsvButton<T>({
rows,
columns,
filename,
filenamePrefix = "export",
label = "Export CSV",
iconOnly = false,
disabled,
className,
variant = "outline",
size,
type = "button",
...buttonProps
}: ExportCsvButtonProps<T>) {
const [busy, setBusy] = React.useState(false);
const nothingToExport = rows.length === 0;
const isDisabled = disabled || nothingToExport;
const handleClick = React.useCallback(() => {
if (isDisabled) return;
const csv = toCsv(rows, columns);
const finalName = filename ?? defaultCsvFilename(filenamePrefix);
setBusy(true);
try {
downloadCsv(finalName, csv);
} finally {
// Reset on the next tick so the user sees the busy state flash.
queueMicrotask(() => setBusy(false));
}
}, [columns, filename, filenamePrefix, isDisabled, rows]);
const tooltip = nothingToExport ? "No rows to export" : undefined;
return (
<Button
variant={variant}
size={size ?? (iconOnly ? "icon" : "sm")}
type={type}
disabled={isDisabled || busy}
onClick={handleClick}
title={tooltip}
aria-label={iconOnly ? "Export as CSV" : undefined}
data-busy={busy || undefined}
className={cn(busy && "cursor-progress", className)}
{...buttonProps}
>
<Download className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
{iconOnly ? null : (
<span>{busy ? "Exporting…" : label}</span>
)}
</Button>
);
}