@elabs-ai/components-data 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * search-input.test.tsx — smoke + behaviour lock for the table search field (#59).
3
+ *
4
+ * SearchInput is one of the four filter primitives DataTable's `toolbar`
5
+ * render-prop drives; until #59 it was only exercised indirectly, inside
6
+ * data-table.stories.tsx. The load-bearing contract is: a real labelled
7
+ * `<input>` (no placeholder-as-label), every keystroke reported to the caller,
8
+ * and a named clear affordance that only exists when there is something to clear.
9
+ */
10
+ import { describe, expect, it, vi } from "vitest";
11
+ import { render, screen, fireEvent } from "@testing-library/react";
12
+ import { SearchInput } from "./search-input";
13
+
14
+ describe("SearchInput — accessible name", () => {
15
+ it("exposes a textbox named by the visually-hidden label (not the placeholder)", () => {
16
+ render(<SearchInput value="" onValueChange={vi.fn()} />);
17
+ expect(screen.getByRole("textbox", { name: "Search" })).toBeInTheDocument();
18
+ });
19
+
20
+ it("uses a custom label when supplied", () => {
21
+ render(<SearchInput value="" onValueChange={vi.fn()} label="Filter deployments" />);
22
+ expect(screen.getByRole("textbox", { name: "Filter deployments" })).toBeInTheDocument();
23
+ });
24
+
25
+ it("wires the <label> to the input via a generated id (clicking the label focuses it)", () => {
26
+ render(<SearchInput value="" onValueChange={vi.fn()} />);
27
+ const input = screen.getByRole("textbox", { name: "Search" });
28
+ const label = document.querySelector("label");
29
+ expect(label).not.toBeNull();
30
+ expect(label).toHaveAttribute("for", input.getAttribute("id"));
31
+ });
32
+ });
33
+
34
+ describe("SearchInput — value reporting", () => {
35
+ it("calls onValueChange with the typed value", () => {
36
+ const onValueChange = vi.fn();
37
+ render(<SearchInput value="" onValueChange={onValueChange} />);
38
+ fireEvent.change(screen.getByRole("textbox", { name: "Search" }), {
39
+ target: { value: "billing" },
40
+ });
41
+ expect(onValueChange).toHaveBeenCalledWith("billing");
42
+ });
43
+
44
+ it("is controlled — it renders the value prop, not internal state", () => {
45
+ const { rerender } = render(<SearchInput value="alpha" onValueChange={vi.fn()} />);
46
+ expect(screen.getByRole("textbox", { name: "Search" })).toHaveValue("alpha");
47
+ rerender(<SearchInput value="beta" onValueChange={vi.fn()} />);
48
+ expect(screen.getByRole("textbox", { name: "Search" })).toHaveValue("beta");
49
+ });
50
+ });
51
+
52
+ describe("SearchInput — clear affordance", () => {
53
+ it("renders no clear button while the field is empty", () => {
54
+ render(<SearchInput value="" onValueChange={vi.fn()} />);
55
+ expect(screen.queryByRole("button", { name: "Clear search" })).toBeNull();
56
+ });
57
+
58
+ it("renders a NAMED clear button once there is a value (icon-only control, WCAG 4.1.2)", () => {
59
+ render(<SearchInput value="billing" onValueChange={vi.fn()} />);
60
+ expect(screen.getByRole("button", { name: "Clear search" })).toBeInTheDocument();
61
+ });
62
+
63
+ it("clears the value through the caller's handler", () => {
64
+ const onValueChange = vi.fn();
65
+ render(<SearchInput value="billing" onValueChange={onValueChange} />);
66
+ fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
67
+ expect(onValueChange).toHaveBeenCalledWith("");
68
+ });
69
+
70
+ it("hides its glyph from assistive tech (the button carries the name)", () => {
71
+ render(<SearchInput value="billing" onValueChange={vi.fn()} />);
72
+ const svg = screen.getByRole("button", { name: "Clear search" }).querySelector("svg");
73
+ expect(svg).toHaveAttribute("aria-hidden", "true");
74
+ });
75
+ });
76
+
77
+ describe("SearchInput — composability", () => {
78
+ it("spreads arbitrary input props (id/name/autocomplete) onto the field", () => {
79
+ render(
80
+ <SearchInput value="" onValueChange={vi.fn()} name="q" autoComplete="off" data-testid="f" />,
81
+ );
82
+ const input = screen.getByTestId("f");
83
+ expect(input).toHaveAttribute("name", "q");
84
+ expect(input).toHaveAttribute("autocomplete", "off");
85
+ });
86
+
87
+ it("merges className onto the input and containerClassName onto the wrapper", () => {
88
+ const { container } = render(
89
+ <SearchInput
90
+ value=""
91
+ onValueChange={vi.fn()}
92
+ className="input-extra"
93
+ containerClassName="wrap-extra"
94
+ />,
95
+ );
96
+ expect(container.firstChild).toHaveClass("wrap-extra");
97
+ expect(screen.getByRole("textbox", { name: "Search" })).toHaveClass("input-extra");
98
+ });
99
+ });
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { useId, type InputHTMLAttributes } from "react";
4
+ import { Input } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+ import { SearchIcon } from "@elabs-ai/components-icons";
7
+
8
+ export interface SearchInputProps extends Omit<
9
+ InputHTMLAttributes<HTMLInputElement>,
10
+ "onChange" | "value"
11
+ > {
12
+ value: string;
13
+ onValueChange: (value: string) => void;
14
+ /** Visually-hidden accessible label. Defaults to "Search". */
15
+ label?: string;
16
+ containerClassName?: string;
17
+ }
18
+
19
+ /**
20
+ * Search field with a leading icon and a clear button. Controlled.
21
+ *
22
+ * `disabled` (available via the extended `InputHTMLAttributes`) is how a
23
+ * consumer signals a pending fetch (D5 — the app owns fetch state, this
24
+ * control just reflects it; see loading-states.md). It is forwarded to the
25
+ * `<Input>` explicitly AND gates the clear button — while disabled the clear
26
+ * affordance is hidden so it can't mutate the filter mid-request (#269/#8).
27
+ */
28
+ export function SearchInput({
29
+ value,
30
+ onValueChange,
31
+ label = "Search",
32
+ placeholder = "Search…",
33
+ className,
34
+ containerClassName,
35
+ disabled,
36
+ ...props
37
+ }: SearchInputProps) {
38
+ const id = useId();
39
+ return (
40
+ <div className={cn("relative w-full max-w-xs", containerClassName)}>
41
+ <label htmlFor={id} className="sr-only">
42
+ {label}
43
+ </label>
44
+ <SearchIcon
45
+ size={16}
46
+ className="pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
47
+ />
48
+ <Input
49
+ id={id}
50
+ value={value}
51
+ onChange={(e) => onValueChange(e.target.value)}
52
+ placeholder={placeholder}
53
+ disabled={disabled}
54
+ className={cn("ps-8", value && "pe-8", className)}
55
+ {...props}
56
+ />
57
+ {value && !disabled ? (
58
+ <button
59
+ type="button"
60
+ onClick={() => onValueChange("")}
61
+ aria-label="Clear search"
62
+ className="absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-fast ease-standard hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring animate-in fade-in zoom-in-95 duration-fast ease-entrance"
63
+ >
64
+ <svg
65
+ width="14"
66
+ height="14"
67
+ viewBox="0 0 24 24"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ strokeWidth="2"
71
+ strokeLinecap="round"
72
+ strokeLinejoin="round"
73
+ aria-hidden="true"
74
+ >
75
+ <path d="M18 6 6 18M6 6l12 12" />
76
+ </svg>
77
+ </button>
78
+ ) : null}
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Data app template — the canonical full-screen data-app composition
3
+ * (app-shell + DataTable with toolbar). This story is the single source of
4
+ * truth: `pnpm gen:templates` derives the consumer template source
5
+ * (`docs/playbooks/templates/data-app.tsx`) from it.
6
+ * Verify across all three themes with globals=theme:<slug>.
7
+ */
8
+ import type { Meta, StoryObj } from "@storybook/react-vite";
9
+ import { useState } from "react";
10
+ import {
11
+ Badge,
12
+ Sidebar,
13
+ SidebarContent,
14
+ SidebarGroup,
15
+ SidebarGroupContent,
16
+ SidebarHeader,
17
+ SidebarInset,
18
+ SidebarMenu,
19
+ SidebarMenuButton,
20
+ SidebarMenuItem,
21
+ SidebarProvider,
22
+ SidebarTrigger,
23
+ } from "@elabs-ai/components-ui";
24
+ import { AppIcon } from "@elabs-ai/components-icons";
25
+ import { ColumnPicker, DataTable, FilterBar, SearchInput, type ColumnDef } from "./index";
26
+
27
+ type Status = "active" | "paused" | "error";
28
+
29
+ interface DataRow {
30
+ id: string;
31
+ name: string;
32
+ status: Status;
33
+ records: number;
34
+ lastRun: string;
35
+ }
36
+
37
+ const statusVariant: Record<Status, "success" | "secondary" | "destructive"> = {
38
+ active: "success",
39
+ paused: "secondary",
40
+ error: "destructive",
41
+ };
42
+
43
+ const columns: ColumnDef<DataRow>[] = [
44
+ { accessorKey: "name", header: "Name" },
45
+ {
46
+ accessorKey: "status",
47
+ header: "Status",
48
+ cell: ({ row }) => (
49
+ <Badge variant={statusVariant[row.original.status]}>{row.original.status}</Badge>
50
+ ),
51
+ },
52
+ {
53
+ accessorKey: "records",
54
+ header: "Records",
55
+ cell: ({ row }) => (
56
+ <span className="tabular-nums">{row.original.records.toLocaleString()}</span>
57
+ ),
58
+ },
59
+ { accessorKey: "lastRun", header: "Last run" },
60
+ ];
61
+
62
+ const sampleData: DataRow[] = [
63
+ { id: "1", name: "Customer import", status: "active", records: 14200, lastRun: "2 min ago" },
64
+ { id: "2", name: "Nightly sync", status: "active", records: 88541, lastRun: "6 h ago" },
65
+ { id: "3", name: "Legacy migration", status: "paused", records: 3010, lastRun: "3 d ago" },
66
+ { id: "4", name: "Partner feed", status: "error", records: 0, lastRun: "1 d ago" },
67
+ ];
68
+
69
+ const nav = [
70
+ { id: "data", label: "Data" },
71
+ { id: "tables", label: "Tables" },
72
+ { id: "home", label: "Home" },
73
+ { id: "settings", label: "Settings" },
74
+ ];
75
+
76
+ function DataAppTemplate({ loading = false }: { loading?: boolean }) {
77
+ const [active, setActive] = useState("data");
78
+ const [search, setSearch] = useState("");
79
+ return (
80
+ <SidebarProvider>
81
+ <Sidebar collapsible="offcanvas">
82
+ <SidebarHeader className="px-3 py-2">
83
+ <div className="flex items-center gap-2">
84
+ <AppIcon height={20} aria-hidden />
85
+ <span className="truncate font-semibold group-data-[collapsible=icon]:hidden">
86
+ Data
87
+ </span>
88
+ </div>
89
+ </SidebarHeader>
90
+ <SidebarContent>
91
+ <SidebarGroup>
92
+ <SidebarGroupContent>
93
+ <SidebarMenu>
94
+ {nav.map((n) => (
95
+ <SidebarMenuItem key={n.id}>
96
+ <SidebarMenuButton
97
+ isActive={active === n.id}
98
+ tooltip={n.label}
99
+ onClick={() => setActive(n.id)}
100
+ >
101
+ <span>{n.label}</span>
102
+ </SidebarMenuButton>
103
+ </SidebarMenuItem>
104
+ ))}
105
+ </SidebarMenu>
106
+ </SidebarGroupContent>
107
+ </SidebarGroup>
108
+ </SidebarContent>
109
+ </Sidebar>
110
+ <SidebarInset>
111
+ <header className="flex h-14 items-center gap-2 border-b px-4">
112
+ <SidebarTrigger />
113
+ <h1 className="text-body font-medium capitalize">{active}</h1>
114
+ </header>
115
+ {/* NOT a second `<main>` (#386): `SidebarInset` already renders the
116
+ page's `<main>` landmark, so nesting one here produced three axe
117
+ violations at once — `landmark-main-is-top-level`,
118
+ `landmark-no-duplicate-main` and `landmark-unique`. The content
119
+ region inside the inset is a plain `<div>`. */}
120
+ <div className="p-6">
121
+ <DataTable
122
+ columns={columns}
123
+ data={loading ? [] : sampleData}
124
+ loading={loading}
125
+ enablePagination
126
+ // Controlled global filter — the app owns `search` and passes it down.
127
+ // Never mutate the filter inside `toolbar` (e.g. table.setGlobalFilter),
128
+ // which sets state during render and loops ("Too many re-renders").
129
+ globalFilter={search}
130
+ onGlobalFilterChange={setSearch}
131
+ toolbar={(table) => (
132
+ // The toolbar has no fetch state of its own (D5) — it just
133
+ // reflects the table's `loading` by disabling its controls
134
+ // (loading-states.md; #269).
135
+ <FilterBar actions={<ColumnPicker table={table} disabled={loading} />}>
136
+ <SearchInput value={search} onValueChange={setSearch} disabled={loading} />
137
+ </FilterBar>
138
+ )}
139
+ />
140
+ </div>
141
+ </SidebarInset>
142
+ </SidebarProvider>
143
+ );
144
+ }
145
+
146
+ const meta = {
147
+ title: "Patterns/Templates/Data App",
148
+ parameters: { layout: "fullscreen" },
149
+ tags: ["autodocs"],
150
+ } satisfies Meta;
151
+ export default meta;
152
+ type Story = StoryObj<typeof meta>;
153
+
154
+ export const Default: Story = { render: () => <DataAppTemplate /> };
155
+
156
+ // LOADING — the toolbar's controls (`ColumnPicker`, `SearchInput`) are
157
+ // disabled while `DataTable loading` renders its skeleton rows (#269,
158
+ // loading-states.md). The toolbar has no fetch state of its own (D5); it
159
+ // just reflects the table's `loading` prop.
160
+ export const Loading: Story = { render: () => <DataAppTemplate loading /> };
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { toCsv, downloadCsv } from "./to-csv";
3
+
4
+ type Row = Record<string, unknown>;
5
+
6
+ describe("toCsv", () => {
7
+ it("produces a header row from Object.keys when columns omitted", () => {
8
+ const rows = [{ name: "Alice", age: 30 }];
9
+ const csv = toCsv(rows);
10
+ const lines = csv.split("\r\n").filter(Boolean);
11
+ expect(lines[0]).toBe("name,age");
12
+ expect(lines[1]).toBe("Alice,30");
13
+ });
14
+
15
+ it("uses custom header labels when provided", () => {
16
+ const rows = [{ name: "Bob", age: 25 }];
17
+ const csv = toCsv(rows, {
18
+ columns: [
19
+ { key: "name", header: "Full Name" },
20
+ { key: "age", header: "Age" },
21
+ ],
22
+ });
23
+ const lines = csv.split("\r\n").filter(Boolean);
24
+ expect(lines[0]).toBe("Full Name,Age");
25
+ });
26
+
27
+ it("column subset and reorder", () => {
28
+ const rows = [{ a: 1, b: 2, c: 3 }];
29
+ const csv = toCsv(rows, { columns: [{ key: "c" }, { key: "a" }] });
30
+ const lines = csv.split("\r\n").filter(Boolean);
31
+ expect(lines[0]).toBe("c,a");
32
+ expect(lines[1]).toBe("3,1");
33
+ });
34
+
35
+ it("omits header row when header:false", () => {
36
+ const rows = [{ x: 1 }];
37
+ const csv = toCsv(rows, { header: false });
38
+ const lines = csv.split("\r\n").filter(Boolean);
39
+ expect(lines).toHaveLength(1);
40
+ expect(lines[0]).toBe("1");
41
+ });
42
+
43
+ it("quotes a field containing the delimiter", () => {
44
+ const rows = [{ val: "hello,world" } as Row];
45
+ const csv = toCsv(rows);
46
+ expect(csv).toContain('"hello,world"');
47
+ });
48
+
49
+ it("escapes embedded double-quotes per RFC 4180", () => {
50
+ const rows = [{ val: 'say "hi"' } as Row];
51
+ const csv = toCsv(rows);
52
+ expect(csv).toContain('"say ""hi"""');
53
+ });
54
+
55
+ it("quotes a field containing a newline", () => {
56
+ const rows = [{ val: "line1\nline2" } as Row];
57
+ const csv = toCsv(rows);
58
+ expect(csv).toContain('"line1\nline2"');
59
+ });
60
+
61
+ it("CSV injection guard: prefixes = with single quote", () => {
62
+ const rows = [{ formula: "=SUM(A1:A10)" } as Row];
63
+ const csv = toCsv(rows);
64
+ expect(csv).toContain("'=SUM");
65
+ });
66
+
67
+ it("CSV injection guard: prefixes + with single quote", () => {
68
+ const rows = [{ val: "+foo" } as Row];
69
+ const csv = toCsv(rows);
70
+ expect(csv).toContain("'+foo");
71
+ });
72
+
73
+ it("CSV injection guard: prefixes - with single quote", () => {
74
+ const rows = [{ val: "-bar" } as Row];
75
+ const csv = toCsv(rows);
76
+ expect(csv).toContain("'-bar");
77
+ });
78
+
79
+ it("CSV injection guard: prefixes @ with single quote", () => {
80
+ const rows = [{ val: "@baz" } as Row];
81
+ const csv = toCsv(rows);
82
+ expect(csv).toContain("'@baz");
83
+ });
84
+
85
+ it("null → empty string", () => {
86
+ const rows = [{ val: null } as Row];
87
+ const csv = toCsv(rows);
88
+ // Split without filter so empty data lines are preserved. Lines: header, data, trailing "".
89
+ const lines = csv.split("\r\n");
90
+ expect(lines[0]).toBe("val");
91
+ expect(lines[1]).toBe(""); // empty cell
92
+ });
93
+
94
+ it("undefined → empty string", () => {
95
+ const rows = [{ val: undefined } as Row];
96
+ const csv = toCsv(rows);
97
+ const lines = csv.split("\r\n");
98
+ expect(lines[0]).toBe("val");
99
+ expect(lines[1]).toBe(""); // empty cell
100
+ });
101
+
102
+ it("custom delimiter (semicolon)", () => {
103
+ const rows = [{ a: 1, b: 2 }];
104
+ const csv = toCsv(rows, { delimiter: ";" });
105
+ const lines = csv.split("\r\n").filter(Boolean);
106
+ expect(lines[0]).toBe("a;b");
107
+ expect(lines[1]).toBe("1;2");
108
+ });
109
+
110
+ it("uses CRLF line terminators and ends with a trailing newline", () => {
111
+ const rows = [{ x: 1 }];
112
+ const csv = toCsv(rows);
113
+ expect(csv.endsWith("\r\n")).toBe(true);
114
+ // Internal lines also use CRLF
115
+ expect(csv.includes("\r\n")).toBe(true);
116
+ });
117
+
118
+ it("returns empty string for empty rows array", () => {
119
+ const csv = toCsv([]);
120
+ expect(csv).toBe("");
121
+ });
122
+ });
123
+
124
+ describe("downloadCsv", () => {
125
+ beforeEach(() => {
126
+ // Mock Blob and URL APIs for jsdom
127
+ global.URL.createObjectURL = vi.fn(() => "blob:mock");
128
+ global.URL.revokeObjectURL = vi.fn();
129
+ });
130
+
131
+ it("creates and clicks an anchor element with the correct download attribute", () => {
132
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
133
+ downloadCsv([{ name: "Alice" }], { filename: "test-export" });
134
+ expect(clickSpy).toHaveBeenCalled();
135
+ clickSpy.mockRestore();
136
+ });
137
+
138
+ it("is a no-op when document is undefined (SSR guard)", () => {
139
+ const orig = global.document;
140
+ // @ts-expect-error intentional SSR simulation
141
+ delete global.document;
142
+ // Should not throw
143
+ expect(() => downloadCsv([{ x: 1 }])).not.toThrow();
144
+ // Restore
145
+ global.document = orig;
146
+ });
147
+ });
package/src/to-csv.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Minimal, dependency-free CSV serializer (RFC 4180).
3
+ *
4
+ * `toCsv` is pure + SSR-safe (no DOM, no deps). `downloadCsv` delegates the
5
+ * browser save mechanics to `@elabs-ai/components-ui`'s shared `downloadBlob` (one home for
6
+ * the Blob → `<a download>` dance; @elabs-ai/components-ui is already a peer dep here).
7
+ * ChartFrame uses its own local copy of `toCsv` in @elabs-ai/components-charts to avoid a
8
+ * cross-sibling dependency (charts → data is not allowed per the one-way rule).
9
+ */
10
+ import { downloadBlob } from "@elabs-ai/components-ui";
11
+
12
+ export type CsvColumn<TData> = { key: keyof TData & string; header?: string };
13
+
14
+ export interface ToCsvOptions<TData> {
15
+ /** Subset/reorder of columns. Omitted → all keys from rows[0]. */
16
+ columns?: CsvColumn<TData>[];
17
+ /** Emit header row. Default true. */
18
+ header?: boolean;
19
+ /** Field delimiter. Default ",". */
20
+ delimiter?: string;
21
+ }
22
+
23
+ export interface DownloadCsvOptions<TData> extends ToCsvOptions<TData> {
24
+ /** File name without extension. Default "download". */
25
+ filename?: string;
26
+ }
27
+
28
+ /** RFC 4180 injection guard prefixes. */
29
+ const INJECTION_PREFIXES = ["=", "+", "-", "@"];
30
+
31
+ function stringifyValue(value: unknown): string {
32
+ if (value === null || value === undefined) return "";
33
+ if (value instanceof Date) return value.toISOString();
34
+ if (typeof value === "object") return JSON.stringify(value);
35
+ return String(value);
36
+ }
37
+
38
+ function quoteField(field: string, delimiter: string): string {
39
+ // CSV-injection guard: prefix with a single quote if the field starts with a
40
+ // formula trigger character.
41
+ if (INJECTION_PREFIXES.some((p) => field.startsWith(p))) {
42
+ field = "'" + field;
43
+ }
44
+ // RFC 4180: quote iff the field contains delimiter, double-quote, CR, or LF.
45
+ if (
46
+ field.includes(delimiter) ||
47
+ field.includes('"') ||
48
+ field.includes("\n") ||
49
+ field.includes("\r")
50
+ ) {
51
+ return '"' + field.replaceAll('"', '""') + '"';
52
+ }
53
+ return field;
54
+ }
55
+
56
+ /**
57
+ * Serialize rows to a CSV string (no DOM access — safe for SSR / jsdom).
58
+ */
59
+ export function toCsv<TData extends Record<string, unknown>>(
60
+ rows: TData[],
61
+ opts?: ToCsvOptions<TData>,
62
+ ): string {
63
+ const delimiter = opts?.delimiter ?? ",";
64
+ const includeHeader = opts?.header !== false;
65
+
66
+ // Derive columns from first row when not provided.
67
+ const firstRow = rows[0];
68
+ const cols: CsvColumn<TData>[] =
69
+ opts?.columns ??
70
+ (firstRow !== undefined
71
+ ? (Object.keys(firstRow) as (keyof TData & string)[]).map((k) => ({ key: k }))
72
+ : []);
73
+
74
+ const lines: string[] = [];
75
+
76
+ if (includeHeader && cols.length > 0) {
77
+ const headerRow = cols.map((c) => quoteField(c.header ?? c.key, delimiter)).join(delimiter);
78
+ lines.push(headerRow);
79
+ }
80
+
81
+ for (const row of rows) {
82
+ const line = cols.map((c) => quoteField(stringifyValue(row[c.key]), delimiter)).join(delimiter);
83
+ lines.push(line);
84
+ }
85
+
86
+ // RFC 4180: CRLF line terminator, trailing newline.
87
+ return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
88
+ }
89
+
90
+ /**
91
+ * Trigger a CSV file download in the browser. No-op in SSR environments.
92
+ */
93
+ export function downloadCsv<TData extends Record<string, unknown>>(
94
+ rows: TData[],
95
+ opts?: DownloadCsvOptions<TData>,
96
+ ): void {
97
+ if (typeof document === "undefined") return;
98
+
99
+ const csv = toCsv(rows, opts);
100
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
101
+ downloadBlob(blob, (opts?.filename ?? "download") + ".csv");
102
+ }