From 41d9aabdb0008cbf0061a5f842ca33ff568ffb56 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 23 Jul 2026 00:13:15 +0530 Subject: [PATCH 1/5] fix(table): extend table wrapper selector to include fullscreen mode --- packages/streamdown/lib/table/copy-dropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 1583960879f77a5411c75b4b017aebaea2047b0a Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 23 Jul 2026 00:13:23 +0530 Subject: [PATCH 2/5] fix(table): update table wrapper selector to include fullscreen mode --- packages/streamdown/lib/table/download-dropdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/streamdown/lib/table/download-dropdown.tsx b/packages/streamdown/lib/table/download-dropdown.tsx index ead50cf8..7c0f739b 100644 --- a/packages/streamdown/lib/table/download-dropdown.tsx +++ b/packages/streamdown/lib/table/download-dropdown.tsx @@ -36,7 +36,7 @@ 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 +117,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" From a6cbbd50e014be1c3b91365e7a718e5eb5fa2fc3 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 23 Jul 2026 00:23:04 +0530 Subject: [PATCH 3/5] chore: add changeset for fullscreen table copy/download fix --- .changeset/shaggy-terms-grin.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/shaggy-terms-grin.md 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. From 7ba19f5525c2e5cc6fa558f80df2e8f66ecdc872 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 23 Jul 2026 00:27:22 +0530 Subject: [PATCH 4/5] test: add fullscreen copy and download functionality for tables --- .../__tests__/table-dropdowns.test.tsx | 134 +++++++++++++++ .../__tests__/table-fullscreen.test.tsx | 158 +++++++++++++++++- 2 files changed, 290 insertions(+), 2 deletions(-) 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( + +
+ + + + + + + + + + + + + +
NameAge
Alice30
+ +
+
+ ); + + 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( + +
+ + + + + + + + + + + + + +
NameAge
Alice30
+ +
+
+ ); + + 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( + +
+ + + + + + + + + + + + + +
NameAge
Alice30
+ +
+
+ ); + + 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" + ); + }); }); From 4ed363b0c88a33ad4b55e0dfccb1f592d5f76453 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 23 Jul 2026 00:27:38 +0530 Subject: [PATCH 5/5] refactor(download-dropdown): format table wrapper selector for improved readability --- packages/streamdown/lib/table/download-dropdown.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/streamdown/lib/table/download-dropdown.tsx b/packages/streamdown/lib/table/download-dropdown.tsx index 7c0f739b..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"], [data-streamdown="table-fullscreen"]'); + const tableWrapper = button.closest( + '[data-streamdown="table-wrapper"], [data-streamdown="table-fullscreen"]' + ); const tableElement = tableWrapper?.querySelector( "table" ) as HTMLTableElement;