Files
vercel__streamdown/packages/streamdown/__tests__/download-dropdown.test.tsx
Dmitrii e129f09aa5 feat: add translations prop for i18n support (#415)
* feat: add translations/i18n support via translations prop

Implements #190 - adds a `translations` prop to the Streamdown component
that allows customizing all UI strings for internationalization.

Changes:
- Add `packages/streamdown/lib/translations-context.tsx` with
  StreamdownTranslations type, defaultTranslations, TranslationsContext,
  and useTranslations hook
- Add `translations?: Partial<StreamdownTranslations>` prop to Streamdown
- Wrap render output with TranslationsContext.Provider (both static and
  streaming modes)
- Export StreamdownTranslations type and defaultTranslations from index
- Replace all hardcoded UI strings in sub-components with translations:
  - code-block/copy-button: copyCode
  - code-block/download-button: downloadFile
  - mermaid/download-button: downloadDiagram, downloadDiagramAs{Svg,Png,Mmd},
    mermaidFormat{Svg,Png,Mmd}
  - mermaid/fullscreen-button: viewFullscreen, exitFullscreen
  - table/copy-dropdown: copyTable, copyTableAs{Markdown,Csv,Tsv},
    tableFormat{Markdown,Csv,Tsv}
  - table/download-dropdown: downloadTable, downloadTableAs{Csv,Markdown},
    tableFormat{Csv,Markdown}
  - image: imageNotAvailable, downloadImage
  - link-modal: openExternalLink, externalLinkWarning, close, copyLink,
    copied, openLink
- Add __tests__/translations.test.tsx with 12 tests covering defaults,
  custom translations, partial overrides, and context access
- Update download-dropdown.test.tsx to match new translation-based title

All 804 tests pass.

* Add changeset for translations prop

* Fix misplaced comments in translations type and broken test type assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use value equality for translations memo to support inline objects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Troitskii <jsleitor@gmail.com>
Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:17:11 -08:00

739 lines
20 KiB
TypeScript

import { fireEvent, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { StreamdownContext } from "../index";
import {
TableDownloadButton,
TableDownloadDropdown,
} from "../lib/table/download-dropdown";
// Setup global URL mocks before any tests run
if (typeof URL.createObjectURL === "undefined") {
URL.createObjectURL = vi.fn();
URL.revokeObjectURL = vi.fn();
}
// Mock the utils
vi.mock("../lib/utils", async () => {
const actual = await vi.importActual("../lib/utils");
return {
...actual,
save: vi.fn(),
};
});
describe("TableDownloadButton", () => {
let mockTable: HTMLTableElement;
let mockWrapper: HTMLDivElement;
beforeEach(() => {
// Create a mock table structure
mockTable = document.createElement("table");
mockTable.innerHTML = `
<thead>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>John</td>
<td>30</td>
</tr>
</tbody>
`;
mockWrapper = document.createElement("div");
mockWrapper.setAttribute("data-streamdown", "table-wrapper");
mockWrapper.appendChild(mockTable);
document.body.appendChild(mockWrapper);
vi.clearAllMocks();
});
afterEach(() => {
document.body.removeChild(mockWrapper);
});
it("should render button with default icon", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadButton />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector("button");
expect(button).toBeTruthy();
expect(button?.getAttribute("title")).toContain("CSV");
});
it("should render button with custom children", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadButton>
<span>Custom Download</span>
</TableDownloadButton>
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
expect(container.textContent).toContain("Custom Download");
});
it("should be disabled when animating", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: true,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadButton />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector("button");
expect(button?.hasAttribute("disabled")).toBe(true);
});
it("should download CSV by default", () => {
const onDownload = vi.fn();
// Mock DOM methods for download
const mockAnchor = {
href: "",
download: "",
click: vi.fn(),
} as unknown as HTMLAnchorElement;
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = vi
.spyOn(document, "createElement")
.mockImplementation((tag) => {
if (tag === "a") {
return mockAnchor;
}
return originalCreateElement(tag);
});
const appendChildSpy = vi
.spyOn(document.body, "appendChild")
.mockImplementation(() => mockAnchor);
const removeChildSpy = vi
.spyOn(document.body, "removeChild")
.mockImplementation(() => mockAnchor);
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:mock");
const revokeObjectURLSpy = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => {
// Intentionally empty mock
});
// Create wrapper and render button inside it properly
const wrapper = document.createElement("div");
wrapper.setAttribute("data-streamdown", "table-wrapper");
wrapper.appendChild(mockTable.cloneNode(true));
document.body.appendChild(wrapper);
const buttonDiv = document.createElement("div");
wrapper.appendChild(buttonDiv);
render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadButton onDownload={onDownload} />
</StreamdownContext.Provider>,
{ container: buttonDiv }
);
const button = buttonDiv.querySelector("button");
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
expect(onDownload).toHaveBeenCalled();
document.body.removeChild(wrapper);
createElementSpy.mockRestore();
appendChildSpy.mockRestore();
removeChildSpy.mockRestore();
createObjectURLSpy.mockRestore();
revokeObjectURLSpy.mockRestore();
});
it("should download markdown when format is specified", () => {
const onDownload = vi.fn();
// Mock DOM methods for download
const mockAnchor = {
href: "",
download: "",
click: vi.fn(),
} as unknown as HTMLAnchorElement;
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = vi
.spyOn(document, "createElement")
.mockImplementation((tag) => {
if (tag === "a") {
return mockAnchor;
}
return originalCreateElement(tag);
});
const appendChildSpy = vi
.spyOn(document.body, "appendChild")
.mockImplementation(() => mockAnchor);
const removeChildSpy = vi
.spyOn(document.body, "removeChild")
.mockImplementation(() => mockAnchor);
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:mock");
const revokeObjectURLSpy = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => {
// Intentionally empty mock
});
// Create wrapper and render button inside it properly
const wrapper = document.createElement("div");
wrapper.setAttribute("data-streamdown", "table-wrapper");
wrapper.appendChild(mockTable.cloneNode(true));
document.body.appendChild(wrapper);
const buttonDiv = document.createElement("div");
wrapper.appendChild(buttonDiv);
render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadButton format="markdown" onDownload={onDownload} />
</StreamdownContext.Provider>,
{ container: buttonDiv }
);
const button = buttonDiv.querySelector("button");
expect(button?.getAttribute("title")).toContain("Markdown");
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
expect(onDownload).toHaveBeenCalled();
document.body.removeChild(wrapper);
createElementSpy.mockRestore();
appendChildSpy.mockRestore();
removeChildSpy.mockRestore();
createObjectURLSpy.mockRestore();
revokeObjectURLSpy.mockRestore();
});
it("should use custom filename", () => {
const onDownload = vi.fn();
// Mock DOM methods for download
const mockAnchor = {
href: "",
download: "",
click: vi.fn(),
} as unknown as HTMLAnchorElement;
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = vi
.spyOn(document, "createElement")
.mockImplementation((tag) => {
if (tag === "a") {
return mockAnchor;
}
return originalCreateElement(tag);
});
const appendChildSpy = vi
.spyOn(document.body, "appendChild")
.mockImplementation(() => mockAnchor);
const removeChildSpy = vi
.spyOn(document.body, "removeChild")
.mockImplementation(() => mockAnchor);
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:mock");
const revokeObjectURLSpy = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => {
// Intentionally empty mock
});
// Create wrapper and render button inside it properly
const wrapper = document.createElement("div");
wrapper.setAttribute("data-streamdown", "table-wrapper");
wrapper.appendChild(mockTable.cloneNode(true));
document.body.appendChild(wrapper);
const buttonDiv = document.createElement("div");
wrapper.appendChild(buttonDiv);
render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadButton filename="custom-table" onDownload={onDownload} />
</StreamdownContext.Provider>,
{ container: buttonDiv }
);
const button = buttonDiv.querySelector("button");
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
expect(onDownload).toHaveBeenCalled();
document.body.removeChild(wrapper);
createElementSpy.mockRestore();
appendChildSpy.mockRestore();
removeChildSpy.mockRestore();
createObjectURLSpy.mockRestore();
revokeObjectURLSpy.mockRestore();
});
it("should call onError when table is not found", () => {
const onError = vi.fn();
// Render button outside of table wrapper
const buttonDiv = document.createElement("div");
document.body.appendChild(buttonDiv);
render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadButton onError={onError} />
</StreamdownContext.Provider>,
{ container: buttonDiv }
);
const button = buttonDiv.querySelector("button");
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: "Table not found",
})
);
document.body.removeChild(buttonDiv);
});
it("should apply custom className", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadButton className="custom-button-class" />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector("button");
expect(button?.className).toContain("custom-button-class");
});
});
describe("TableDownloadDropdown", () => {
let mockTable: HTMLTableElement;
let mockWrapper: HTMLDivElement;
beforeEach(() => {
// Create a mock table structure
mockTable = document.createElement("table");
mockTable.innerHTML = `
<thead>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>John</td>
<td>30</td>
</tr>
</tbody>
`;
mockWrapper = document.createElement("div");
mockWrapper.setAttribute("data-streamdown", "table-wrapper");
mockWrapper.appendChild(mockTable);
document.body.appendChild(mockWrapper);
vi.clearAllMocks();
});
afterEach(() => {
document.body.removeChild(mockWrapper);
});
it("should render dropdown button", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector('button[title="Download table"]');
expect(button).toBeTruthy();
});
it("should render custom children", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown>
<span>Custom Text</span>
</TableDownloadDropdown>
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
expect(container.textContent).toContain("Custom Text");
});
it("should be disabled when animating", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: true,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector("button");
expect(button?.hasAttribute("disabled")).toBe(true);
});
it("should toggle dropdown on button click", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector('button[title="Download table"]');
// Dropdown should be closed initially
expect(container.querySelector(".absolute")).toBeFalsy();
// Click to open
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
expect(container.querySelector(".absolute")).toBeTruthy();
// Click to close
if (button) {
fireEvent.click(button);
}
expect(container.querySelector(".absolute")).toBeFalsy();
});
it("should show CSV and Markdown options when open", () => {
const { container, getByText } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector('button[title="Download table"]');
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
expect(getByText("CSV")).toBeTruthy();
expect(getByText("Markdown")).toBeTruthy();
});
it("should download CSV when CSV option is clicked", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();
// Append dropdown to wrapper
const dropdownContainer = document.createElement("div");
mockWrapper.appendChild(dropdownContainer);
const { container, getByText } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown onDownload={onDownload} />
</StreamdownContext.Provider>,
{ container: dropdownContainer }
);
const button = container.querySelector('button[title="Download table"]');
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
const csvButton = getByText("CSV");
fireEvent.click(csvButton);
expect(save).toHaveBeenCalledWith(
"table.csv",
expect.stringContaining("Name,Age"),
"text/csv"
);
expect(onDownload).toHaveBeenCalledWith("csv");
mockWrapper.removeChild(dropdownContainer);
});
it("should download Markdown when Markdown option is clicked", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();
// Append dropdown to wrapper
const dropdownContainer = document.createElement("div");
mockWrapper.appendChild(dropdownContainer);
const { container, getByText } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown onDownload={onDownload} />
</StreamdownContext.Provider>,
{ container: dropdownContainer }
);
const button = container.querySelector('button[title="Download table"]');
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
const markdownButton = getByText("Markdown");
fireEvent.click(markdownButton);
expect(save).toHaveBeenCalledWith(
"table.md",
expect.stringContaining("| Name | Age |"),
"text/markdown"
);
expect(onDownload).toHaveBeenCalledWith("markdown");
mockWrapper.removeChild(dropdownContainer);
});
it("should close dropdown on outside click", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector('button[title="Download table"]');
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
expect(container.querySelector(".absolute")).toBeTruthy();
// Click outside
fireEvent.mouseDown(document.body);
expect(container.querySelector(".absolute")).toBeFalsy();
});
it("should call onError when table is not found", () => {
const onError = vi.fn();
// Render dropdown outside of table wrapper
const dropdownDiv = document.createElement("div");
document.body.appendChild(dropdownDiv);
const { container, getByText } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown onError={onError} />
</StreamdownContext.Provider>,
{ container: dropdownDiv }
);
const button = container.querySelector('button[title="Download table"]');
expect(button).toBeTruthy();
if (button) {
fireEvent.click(button);
}
const csvButton = getByText("CSV");
fireEvent.click(csvButton);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: "Table not found",
})
);
document.body.removeChild(dropdownDiv);
});
it("should apply custom className", () => {
const { container } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown className="custom-dropdown-class" />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
const button = container.querySelector("button");
expect(button?.className).toContain("custom-dropdown-class");
});
it("should cleanup event listener on unmount", () => {
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = render(
<StreamdownContext.Provider
value={{
isAnimating: false,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
controls: true,
}}
>
<TableDownloadDropdown />
</StreamdownContext.Provider>,
{ container: mockWrapper }
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
"mousedown",
expect.any(Function)
);
});
});