feat(frontend): CSV export utility + ExportCsvButton component
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user