Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ten-pandas-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"streamdown": minor
---

- Add `controls.table.csvSeparator` (`"," | ";" | "\t" | "auto"`) for table copy and download CSV
- Reuse `tableDataToCSV` separator handling, including locale-aware `"auto"` mode
- Improve CSV escaping to respect the selected separator for Excel compatibility
13 changes: 12 additions & 1 deletion apps/website/content/docs/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,22 @@ import { TableDownloadButton } from "streamdown";
<TableDownloadButton format="csv" />
```

You can control the CSV delimiter for table copy and download with `controls.table.csvSeparator`. Supported values are `","`, `";"`, `"\t"`, and `"auto"` (locale-aware selection). The default is `","`.

```tsx title="app/page.tsx"
<Streamdown controls={{ table: { csvSeparator: "auto" } }}>
{markdown}
</Streamdown>
```

### Lower-level utilities

For fully custom implementations, use the extraction and conversion utilities directly:

```tsx title="app/page.tsx"
import {
extractTableDataFromElement,
type CSVSeparator,
tableDataToCSV,
tableDataToTSV,
tableDataToMarkdown,
Expand All @@ -240,11 +249,13 @@ import {
const data = extractTableDataFromElement(tableElement);

// Convert to various formats
const csv = tableDataToCSV(data);
const csv = tableDataToCSV(data, "auto");
const tsv = tableDataToTSV(data);
const markdown = tableDataToMarkdown(data);
```

The `tableDataToCSV` helper accepts an optional `CSVSeparator` argument (`"," | ";" | "\t" | "auto"`) so you can choose the delimiter explicitly or let `"auto"` pick a locale-friendly separator.

## Custom HTML Tags

You can render custom HTML tags from AI responses (like `<source>`, `<mention>`, etc.) using the `allowedTags` prop alongside `components`. This is useful when you instruct the AI to output structured data that renders as interactive components.
Expand Down
1 change: 1 addition & 0 deletions apps/website/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ The `controls` prop can be configured granularly:
copy: true, // Show table copy button
download: true, // Show table download button
fullscreen: true, // Show table fullscreen button
csvSeparator: ",", // "," | ";" | "\t" | "auto"
},
code: {
copy: true, // Show code copy button
Expand Down
10 changes: 10 additions & 0 deletions apps/website/content/docs/gfm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ You can disable the table download button:
</Streamdown>
```

### Custom CSV Separator

By default, copied and downloaded CSV uses a comma. Set `controls.table.csvSeparator` to `";"`, `"\t"`, or `"auto"` (picks `;` in comma-decimal locales):

```tsx
<Streamdown controls={{ table: { csvSeparator: "auto" } }}>
{markdown}
</Streamdown>
```

## Task Lists

Create interactive todo lists:
Expand Down
8 changes: 8 additions & 0 deletions apps/website/content/docs/interactivity.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Tables include a copy button that opens a dropdown menu allowing users to copy t

Tables can be downloaded in two formats: CSV and Markdown. The download button will be shown for tables in the top-right corner on hover. The download button opens a dropdown menu with options to download as CSV or Markdown, making it easy to export table data for use in spreadsheets or documentation.

CSV copy and download use a comma by default. Customize the delimiter with `controls.table.csvSeparator` (`","`, `";"`, `"\t"`, or `"auto"`):

```tsx
<Streamdown controls={{ table: { csvSeparator: ";" } }}>
{markdown}
</Streamdown>
```

## Code Block Buttons

### Copy Code
Expand Down
183 changes: 182 additions & 1 deletion packages/streamdown/__tests__/table-dropdowns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe("TableDownloadDropdown", () => {

expect(save).toHaveBeenCalledWith(
"table.csv",
expect.any(String),
"Name,Age\nAlice,30",
"text/csv"
);
expect(onDownload).toHaveBeenCalledWith("csv");
Expand Down Expand Up @@ -114,6 +114,57 @@ describe("TableDownloadDropdown", () => {
expect(onDownload).toHaveBeenCalledWith("markdown");
});

it("should use csvSeparator from controls for CSV downloads", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: { table: { csvSeparator: ";" } },
isAnimating: false,
mode: "streaming",
}}
>
<div data-streamdown="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>30</td>
</tr>
</tbody>
</table>
<TableDownloadDropdown onDownload={onDownload} />
</div>
</StreamdownContext.Provider>
);

const toggleBtn = container.querySelector('button[title="Download table"]');
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(toggleBtn!);

const csvBtn = container.querySelector(
'button[title="Download table as CSV"]'
);
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(csvBtn!);

expect(save).toHaveBeenCalledWith(
"table.csv",
"Name;Age\nAlice;30",
"text/csv"
);
expect(onDownload).toHaveBeenCalledWith("csv");
});

it("should download when inside table-fullscreen", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();
Expand Down Expand Up @@ -263,6 +314,53 @@ describe("TableDownloadButton with format='markdown'", () => {
expect(onDownload).toHaveBeenCalled();
});

it("should use csvSeparator from controls for CSV button downloads", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: { table: { csvSeparator: ";" } },
isAnimating: false,
mode: "streaming",
}}
>
<div data-streamdown="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>30</td>
</tr>
</tbody>
</table>
<TableDownloadButton format="csv" onDownload={onDownload} />
</div>
</StreamdownContext.Provider>
);

const btn = container.querySelector(
'button[title="Download table as CSV"]'
);
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(btn!);

expect(save).toHaveBeenCalledWith(
"table.csv",
"Name;Age\nAlice;30",
"text/csv"
);
expect(onDownload).toHaveBeenCalled();
});

it("should handle default format (fallback to csv)", () => {
const { container } = renderInTableWrapper(
<TableDownloadButton format={"unknown" as any} />
Expand Down Expand Up @@ -406,6 +504,20 @@ describe("TableCopyDropdown", () => {

it("should copy as CSV when csv button clicked", async () => {
const onCopy = vi.fn();
const OriginalBlob = globalThis.Blob;
const blobPartsByType: Record<string, BlobPart[]> = {};
const blobSpy = vi.spyOn(globalThis, "Blob").mockImplementation(function (
this: Blob,
parts?: BlobPart[],
opts?: BlobPropertyBag
) {
const type = opts?.type ?? "";
if (parts) {
blobPartsByType[type] = parts;
}
return new OriginalBlob(parts, opts);
} as unknown as typeof Blob);

const { container } = renderInTableWrapper(
<TableCopyDropdown onCopy={onCopy} />
);
Expand All @@ -421,7 +533,76 @@ describe("TableCopyDropdown", () => {
fireEvent.click(csvBtn!);
});

expect(navigator.clipboard.write).toHaveBeenCalled();
expect(String(blobPartsByType["text/plain"]?.[0] ?? "")).toBe(
"Name,Age\nAlice,30"
);
expect(onCopy).toHaveBeenCalledWith("csv");
blobSpy.mockRestore();
});

it("should copy CSV when csvSeparator is configured", async () => {
const onCopy = vi.fn();
const OriginalBlob = globalThis.Blob;
const blobPartsByType: Record<string, BlobPart[]> = {};
const blobSpy = vi.spyOn(globalThis, "Blob").mockImplementation(function (
this: Blob,
parts?: BlobPart[],
opts?: BlobPropertyBag
) {
const type = opts?.type ?? "";
if (parts) {
blobPartsByType[type] = parts;
}
return new OriginalBlob(parts, opts);
} as unknown as typeof Blob);

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: { table: { csvSeparator: ";" } },
isAnimating: false,
mode: "streaming",
}}
>
<div data-streamdown="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>30</td>
</tr>
</tbody>
</table>
<TableCopyDropdown onCopy={onCopy} />
</div>
</StreamdownContext.Provider>
);

const toggleBtn = container.querySelector('button[title="Copy table"]');
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(toggleBtn!);

const csvBtn = container.querySelector('button[title="Copy table as CSV"]');
// biome-ignore lint/suspicious/useAwait: act needs async to flush clipboard promises
await act(async () => {
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(csvBtn!);
});

expect(navigator.clipboard.write).toHaveBeenCalled();
expect(String(blobPartsByType["text/plain"]?.[0] ?? "")).toBe(
"Name;Age\nAlice;30"
);
expect(onCopy).toHaveBeenCalledWith("csv");
blobSpy.mockRestore();
});

it("should copy as TSV when tsv button clicked", async () => {
Expand Down
Loading
Loading