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
5 changes: 5 additions & 0 deletions .changeset/free-years-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": minor
---

Add fullscreen view for tables with granular controls
1 change: 1 addition & 0 deletions apps/website/content/docs/styling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ Target specific Streamdown elements using these data attributes:
[data-streamdown="table-row"] { }
[data-streamdown="table-header-cell"] { }
[data-streamdown="table-cell"] { }
[data-streamdown="table-fullscreen"] { }

/* Other */
[data-streamdown="superscript"] { }
Expand Down
98 changes: 98 additions & 0 deletions packages/streamdown/__tests__/show-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,104 @@ graph TD
});
});

describe("granular table configuration", () => {
it("should hide only fullscreen when table.fullscreen is false", () => {
const { container } = render(
<Streamdown controls={{ table: { fullscreen: false } }}>
{markdownWithTable}
</Streamdown>
);

const tableWrapper = container.querySelector(
'[data-streamdown="table-wrapper"]'
);
const fullscreenBtn = tableWrapper?.querySelector(
'button[title="View fullscreen"]'
);
const copyBtn = tableWrapper?.querySelector(
'button[title="Copy table"]'
);
const downloadBtn = tableWrapper?.querySelector(
'button[title="Download table"]'
);

expect(fullscreenBtn).toBeFalsy();
expect(copyBtn).toBeTruthy();
expect(downloadBtn).toBeTruthy();
});

it("should show only copy when download and fullscreen are false", () => {
const { container } = render(
<Streamdown
controls={{ table: { download: false, fullscreen: false } }}
>
{markdownWithTable}
</Streamdown>
);

const tableWrapper = container.querySelector(
'[data-streamdown="table-wrapper"]'
);
const fullscreenBtn = tableWrapper?.querySelector(
'button[title="View fullscreen"]'
);
const copyBtn = tableWrapper?.querySelector(
'button[title="Copy table"]'
);
const downloadBtn = tableWrapper?.querySelector(
'button[title="Download table"]'
);

expect(fullscreenBtn).toBeFalsy();
expect(downloadBtn).toBeFalsy();
expect(copyBtn).toBeTruthy();
});

it("should show all table controls with empty object config", () => {
const { container } = render(
<Streamdown controls={{ table: {} }}>
{markdownWithTable}
</Streamdown>
);

const tableWrapper = container.querySelector(
'[data-streamdown="table-wrapper"]'
);
const fullscreenBtn = tableWrapper?.querySelector(
'button[title="View fullscreen"]'
);
const copyBtn = tableWrapper?.querySelector(
'button[title="Copy table"]'
);
const downloadBtn = tableWrapper?.querySelector(
'button[title="Download table"]'
);

expect(fullscreenBtn).toBeTruthy();
expect(copyBtn).toBeTruthy();
expect(downloadBtn).toBeTruthy();
});

it("should hide all table controls when no sub-controls are visible", () => {
const { container } = render(
<Streamdown
controls={{
table: { copy: false, download: false, fullscreen: false },
}}
>
{markdownWithTable}
</Streamdown>
);

const tableWrapper = container.querySelector(
'[data-streamdown="table-wrapper"]'
);
const buttons = tableWrapper?.querySelectorAll("button");

expect(buttons?.length).toBe(0);
});
});

describe("with custom components", () => {
it("should respect controls with custom component overrides", () => {
const CustomParagraph = ({ children }: any) => (
Expand Down
242 changes: 242 additions & 0 deletions packages/streamdown/__tests__/table-fullscreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Streamdown, StreamdownContext } from "../index";

const markdownWithTable = `
| Name | Age |
|------|-----|
| Alice | 30 |
| Bob | 25 |
`;

describe("TableFullscreenButton", () => {
it("should render fullscreen button when controls are enabled", () => {
const { container } = render(
<Streamdown>{markdownWithTable}</Streamdown>
);

const btn = container.querySelector('button[title="View fullscreen"]');
expect(btn).toBeTruthy();
});

it("should not render fullscreen button when controls are false", () => {
const { container } = render(
<Streamdown controls={false}>{markdownWithTable}</Streamdown>
);

const btn = container.querySelector('button[title="View fullscreen"]');
expect(btn).toBeFalsy();
});

it("should not render fullscreen button when table fullscreen is false", () => {
const { container } = render(
<Streamdown controls={{ table: { fullscreen: false } }}>
{markdownWithTable}
</Streamdown>
);

const btn = container.querySelector('button[title="View fullscreen"]');
expect(btn).toBeFalsy();
});

it("should open fullscreen overlay on click", () => {
const { container } = render(
<Streamdown>{markdownWithTable}</Streamdown>
);

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

fireEvent.click(btn);

const overlay = document.querySelector(
'[data-streamdown="table-fullscreen"]'
);
expect(overlay).toBeTruthy();
});

it("should close fullscreen overlay on close button click", () => {
const { container } = render(
<Streamdown>{markdownWithTable}</Streamdown>
);

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

const closeBtn = document.querySelector(
'button[title="Exit fullscreen"]'
) as HTMLButtonElement;
expect(closeBtn).toBeTruthy();

fireEvent.click(closeBtn);

const overlay = document.querySelector(
'[data-streamdown="table-fullscreen"]'
);
expect(overlay).toBeFalsy();
});

it("should close fullscreen overlay on Escape key", () => {
const { container } = render(
<Streamdown>{markdownWithTable}</Streamdown>
);

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

expect(
document.querySelector('[data-streamdown="table-fullscreen"]')
).toBeTruthy();

fireEvent.keyDown(document, { key: "Escape" });

expect(
document.querySelector('[data-streamdown="table-fullscreen"]')
).toBeFalsy();
});

it("should lock body scroll when fullscreen is open", () => {
const { container } = render(
<Streamdown>{markdownWithTable}</Streamdown>
);

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

expect(document.body.style.overflow).toBe("hidden");

const closeBtn = document.querySelector(
'button[title="Exit fullscreen"]'
) as HTMLButtonElement;
fireEvent.click(closeBtn);

expect(document.body.style.overflow).toBe("");
});

it("should disable fullscreen button when isAnimating", () => {
const { container } = render(
<Streamdown isAnimating={true}>{markdownWithTable}</Streamdown>
);

const btn = container.querySelector(
'button[title="View fullscreen"]'
) as HTMLButtonElement;
expect(btn).toBeTruthy();
expect(btn.disabled).toBe(true);
});

it("should render table content inside fullscreen overlay", () => {
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 table = overlay?.querySelector('[data-streamdown="table"]');
expect(table).toBeTruthy();
});

it("should show copy and download controls 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"]');
const downloadBtn = overlay?.querySelector(
'button[title="Download table"]'
);
expect(copyBtn).toBeTruthy();
expect(downloadBtn).toBeTruthy();
});

it("should not close fullscreen when clicking controls inside overlay", () => {
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"]'
);
expect(overlay).toBeTruthy();

const copyBtn = overlay?.querySelector(
'button[title="Copy table"]'
) as HTMLButtonElement;
expect(copyBtn).toBeTruthy();
fireEvent.click(copyBtn);

expect(
document.querySelector('[data-streamdown="table-fullscreen"]')
).toBeTruthy();
});

it("should not close fullscreen when clicking table content", () => {
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 table = overlay?.querySelector(
'[data-streamdown="table"]'
) as HTMLTableElement;
expect(table).toBeTruthy();
fireEvent.click(table);

expect(
document.querySelector('[data-streamdown="table-fullscreen"]')
).toBeTruthy();
});

it("should hide copy in fullscreen when table copy is false", () => {
const { container } = render(
<Streamdown controls={{ table: { copy: false } }}>
{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"]');
expect(copyBtn).toBeFalsy();
});
});
8 changes: 7 additions & 1 deletion packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ export type {
export type ControlsConfig =
| boolean
| {
table?: boolean;
table?:
| boolean
| {
copy?: boolean;
download?: boolean;
fullscreen?: boolean;
};
code?: boolean;
mermaid?:
| boolean
Expand Down
10 changes: 2 additions & 8 deletions packages/streamdown/lib/code-block/body.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ComponentProps, type CSSProperties, memo, useMemo } from "react";
import type { HighlightResult } from "../plugin-types";
import { cn } from "../utils";
import { cn, getTokenStyle } from "../utils";

type CodeBlockBodyProps = ComponentProps<"pre"> & {
result: HighlightResult;
Expand Down Expand Up @@ -95,13 +95,7 @@ export const CodeBlockBody = memo(
)}
// biome-ignore lint/suspicious/noArrayIndexKey: "This is a stable key."
key={tokenIndex}
style={
{
...(token.color ? { "--sdm-c": token.color } : {}),
...(token.bgColor ? { "--sdm-tbg": token.bgColor } : {}),
...token.htmlStyle,
} as CSSProperties
}
style={getTokenStyle(token)}
{...token.htmlAttrs}
>
{token.content}
Expand Down
Loading