Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/shaggy-terms-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"streamdown": minor
---

Fix table copy and download actions not working in fullscreen mode.

- Support table lookup inside the fullscreen portal container.
- Restore copy and download functionality for fullscreen tables.
- Keep existing inline table controls behavior unchanged.
134 changes: 134 additions & 0 deletions packages/streamdown/__tests__/table-dropdowns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,57 @@ describe("TableDownloadDropdown", () => {
expect(onDownload).toHaveBeenCalledWith("markdown");
});

it("should download when inside table-fullscreen", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
}}
>
<div data-streamdown="table-fullscreen">
<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",
expect.any(String),
"text/csv"
);
expect(onDownload).toHaveBeenCalledWith("csv");
});

it("should call onError when save throws", async () => {
const { save } = await import("../lib/utils");
(save as any).mockImplementation(() => {
Expand Down Expand Up @@ -242,6 +293,44 @@ describe("TableDownloadButton with format='markdown'", () => {

expect(onError).toHaveBeenCalledWith(expect.any(Error));
});

it("should download when inside table-fullscreen", () => {
const onDownload = vi.fn();
const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
}}
>
<div data-streamdown="table-fullscreen">
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>30</td>
</tr>
</tbody>
</table>
<TableDownloadButton onDownload={onDownload} />
</div>
</StreamdownContext.Provider>
);

const btn = container.querySelector("button");
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(btn!);

expect(onDownload).toHaveBeenCalled();
});
});

describe("TableCopyDropdown", () => {
Expand Down Expand Up @@ -408,6 +497,51 @@ describe("TableCopyDropdown", () => {
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});

it("should copy when inside table-fullscreen", async () => {
const onCopy = vi.fn();
const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
}}
>
<div data-streamdown="table-fullscreen">
<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(onCopy).toHaveBeenCalledWith("csv");
});

it("should close dropdown on outside click", () => {
const { container } = renderInTableWrapper(<TableCopyDropdown />);

Expand Down
158 changes: 156 additions & 2 deletions packages/streamdown/__tests__/table-fullscreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { act, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Streamdown } from "../index";

vi.mock("../lib/utils", async () => {
const actual = await vi.importActual("../lib/utils");
return {
...actual,
save: vi.fn(),
};
});

const markdownWithTable = `
| Name | Age |
|------|-----|
Expand Down Expand Up @@ -308,4 +316,150 @@ describe("TableFullscreenButton", () => {
const copyBtn = overlay?.querySelector('button[title="Copy table"]');
expect(copyBtn).toBeFalsy();
});

it("should show copy format options in fullscreen", () => {
const { container } = render(<Streamdown>{markdownWithTable}</Streamdown>);

const btn = container.querySelector(
'button[title="View fullscreen"]'
) as HTMLButtonElement;
fireEvent.click(btn);

const overlay = document.querySelector(
'[data-streamdown="table-fullscreen"]'
);
const copyBtn = overlay?.querySelector(
'button[title="Copy table"]'
) as HTMLButtonElement;
fireEvent.click(copyBtn);

expect(
overlay?.querySelector('button[title="Copy table as CSV"]')
).toBeTruthy();
expect(
overlay?.querySelector('button[title="Copy table as Markdown"]')
).toBeTruthy();
expect(
overlay?.querySelector('button[title="Copy table as TSV"]')
).toBeTruthy();
});

it("should show download format options in fullscreen", () => {
const { container } = render(<Streamdown>{markdownWithTable}</Streamdown>);

const btn = container.querySelector(
'button[title="View fullscreen"]'
) as HTMLButtonElement;
fireEvent.click(btn);

const overlay = document.querySelector(
'[data-streamdown="table-fullscreen"]'
);
const downloadBtn = overlay?.querySelector(
'button[title="Download table"]'
) as HTMLButtonElement;
fireEvent.click(downloadBtn);

expect(
overlay?.querySelector('button[title="Download table as CSV"]')
).toBeTruthy();
expect(
overlay?.querySelector('button[title="Download table as Markdown"]')
).toBeTruthy();
});
});

describe("TableFullscreenButton copy and download", () => {
const originalClipboard = navigator.clipboard;
const originalClipboardItem = globalThis.ClipboardItem;

beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(navigator, "clipboard", {
value: {
write: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn().mockResolvedValue(undefined),
},
writable: true,
configurable: true,
});
if (!globalThis.ClipboardItem) {
globalThis.ClipboardItem = class {
types: string[];
data: Record<string, Blob>;
constructor(data: Record<string, Blob>) {
this.types = Object.keys(data);
this.data = data;
}
} as any;
}
});

afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(navigator, "clipboard", {
value: originalClipboard,
writable: true,
configurable: true,
});
if (originalClipboardItem) {
globalThis.ClipboardItem = originalClipboardItem;
}
});

it("should copy table as CSV in fullscreen", async () => {
const { container } = render(<Streamdown>{markdownWithTable}</Streamdown>);

const btn = container.querySelector(
'button[title="View fullscreen"]'
) as HTMLButtonElement;
fireEvent.click(btn);

const overlay = document.querySelector(
'[data-streamdown="table-fullscreen"]'
);
const copyBtn = overlay?.querySelector(
'button[title="Copy table"]'
) as HTMLButtonElement;
fireEvent.click(copyBtn);

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

expect(navigator.clipboard.write).toHaveBeenCalled();
});

it("should download table as CSV in fullscreen", async () => {
const { save } = await import("../lib/utils");
const { container } = render(<Streamdown>{markdownWithTable}</Streamdown>);

const btn = container.querySelector(
'button[title="View fullscreen"]'
) as HTMLButtonElement;
fireEvent.click(btn);

const overlay = document.querySelector(
'[data-streamdown="table-fullscreen"]'
);
const downloadBtn = overlay?.querySelector(
'button[title="Download table"]'
) as HTMLButtonElement;
fireEvent.click(downloadBtn);

const csvBtn = overlay?.querySelector(
'button[title="Download table as CSV"]'
) as HTMLButtonElement;
fireEvent.click(csvBtn);

expect(save).toHaveBeenCalledWith(
"table.csv",
expect.any(String),
"text/csv"
);
});
});
2 changes: 1 addition & 1 deletion packages/streamdown/lib/table/copy-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const TableCopyDropdown = ({

try {
const tableWrapper = dropdownRef.current?.closest(
'[data-streamdown="table-wrapper"]'
'[data-streamdown="table-wrapper"], [data-streamdown="table-fullscreen"]'
);
const tableElement = tableWrapper?.querySelector(
"table"
Expand Down
6 changes: 4 additions & 2 deletions packages/streamdown/lib/table/download-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export const TableDownloadButton = ({
try {
// Find the closest table element
const button = event.currentTarget;
const tableWrapper = button.closest('[data-streamdown="table-wrapper"]');
const tableWrapper = button.closest(
'[data-streamdown="table-wrapper"], [data-streamdown="table-fullscreen"]'
);
const tableElement = tableWrapper?.querySelector(
"table"
) as HTMLTableElement;
Expand Down Expand Up @@ -117,7 +119,7 @@ export const TableDownloadDropdown = ({
const downloadTableData = (format: "csv" | "markdown") => {
try {
const tableWrapper = dropdownRef.current?.closest(
'[data-streamdown="table-wrapper"]'
'[data-streamdown="table-wrapper"], [data-streamdown="table-fullscreen"]'
);
const tableElement = tableWrapper?.querySelector(
"table"
Expand Down
Loading