diff --git a/.changeset/shaggy-terms-grin.md b/.changeset/shaggy-terms-grin.md
new file mode 100644
index 00000000..a7a671bf
--- /dev/null
+++ b/.changeset/shaggy-terms-grin.md
@@ -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.
diff --git a/packages/streamdown/__tests__/table-dropdowns.test.tsx b/packages/streamdown/__tests__/table-dropdowns.test.tsx
index ec1e47d8..08e9391a 100644
--- a/packages/streamdown/__tests__/table-dropdowns.test.tsx
+++ b/packages/streamdown/__tests__/table-dropdowns.test.tsx
@@ -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(
+
+
+
+
+
+ | Name |
+ Age |
+
+
+
+
+ | Alice |
+ 30 |
+
+
+
+
+
+
+ );
+
+ 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(() => {
@@ -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(
+
+
+
+
+
+ | Name |
+ Age |
+
+
+
+
+ | Alice |
+ 30 |
+
+
+
+
+
+
+ );
+
+ const btn = container.querySelector("button");
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(btn!);
+
+ expect(onDownload).toHaveBeenCalled();
+ });
});
describe("TableCopyDropdown", () => {
@@ -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(
+
+
+
+
+
+ | Name |
+ Age |
+
+
+
+
+ | Alice |
+ 30 |
+
+
+
+
+
+
+ );
+
+ 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();
diff --git a/packages/streamdown/__tests__/table-fullscreen.test.tsx b/packages/streamdown/__tests__/table-fullscreen.test.tsx
index 84d74699..2199a395 100644
--- a/packages/streamdown/__tests__/table-fullscreen.test.tsx
+++ b/packages/streamdown/__tests__/table-fullscreen.test.tsx
@@ -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 |
|------|-----|
@@ -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({markdownWithTable});
+
+ 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({markdownWithTable});
+
+ 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;
+ constructor(data: Record) {
+ 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({markdownWithTable});
+
+ 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({markdownWithTable});
+
+ 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"
+ );
+ });
});
diff --git a/packages/streamdown/lib/table/copy-dropdown.tsx b/packages/streamdown/lib/table/copy-dropdown.tsx
index f019a9fd..361271f0 100644
--- a/packages/streamdown/lib/table/copy-dropdown.tsx
+++ b/packages/streamdown/lib/table/copy-dropdown.tsx
@@ -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"
diff --git a/packages/streamdown/lib/table/download-dropdown.tsx b/packages/streamdown/lib/table/download-dropdown.tsx
index ead50cf8..d8178aa6 100644
--- a/packages/streamdown/lib/table/download-dropdown.tsx
+++ b/packages/streamdown/lib/table/download-dropdown.tsx
@@ -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;
@@ -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"