Skip to content
Merged
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/shared-overlay-portal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": patch
---

Add a top-level `portal` prop for configuring the container used by Mermaid fullscreen, table fullscreen, and the built-in link safety modal.
23 changes: 23 additions & 0 deletions apps/website/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ Math rendering and CJK support require installing separate plugins. See [Mathema
type: "LinkSafetyConfig",
default: "{ enabled: true }",
},
portal: {
description:
"DOM node (or getter) used as the portal target for built-in overlays. Useful for micro-frontends and scoped or prefixed CSS.",
type: "HTMLElement | null | (() => HTMLElement | null)",
default: "document.body",
},
plugins: {
description: "Plugin configuration for math, mermaid, code highlighting, and CJK support. See [Plugins](/docs/plugins).",
type: "PluginConfig",
Expand Down Expand Up @@ -213,6 +219,23 @@ Math rendering and CJK support require installing separate plugins. See [Mathema
}}
/>

### Portal Target

By default, Mermaid fullscreen, table fullscreen, and the built-in link safety modal render into `document.body`. Use `portal` to keep these overlays inside a micro-frontend or another subtree that provides scoped CSS, prefixed Tailwind utilities, or a specific stacking context.

```tsx title="app/page.tsx"
const portalRef = useRef<HTMLDivElement>(null);

return (
<div className="my-app">
<div ref={portalRef} />
<Streamdown portal={() => portalRef.current}>{markdown}</Streamdown>
</div>
);
```

The getter form is useful when the portal element is assigned after the first render. Returning `null` falls back to `document.body`. A custom `linkSafety.renderModal` controls its own placement and is not moved by this prop. Like `urlTransform` and `allowElement`, `portal` is an initializing prop: swapping it for a different target alone does not re-render, so prefer the getter form — it is resolved each time an overlay opens.

### Mermaid Options

The `mermaid` prop accepts an object with the following properties:
Expand Down
23 changes: 23 additions & 0 deletions packages/streamdown/__tests__/link-modal-keyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Streamdown } from "../index";
import { LinkSafetyModal } from "../lib/link-modal";

describe("LinkSafetyModal keyboard and interaction", () => {
Expand Down Expand Up @@ -228,4 +229,26 @@ describe("LinkSafetyModal keyboard and interaction", () => {
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});

it("should portal the default modal to the configured portal target", () => {
const portalRoot = document.createElement("div");
document.body.appendChild(portalRoot);

const { container, unmount } = render(
<Streamdown portal={() => portalRoot}>
{"[External link](https://example.com)"}
</Streamdown>
);

const link = container.querySelector('[data-streamdown="link"]');
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(link!);

expect(
portalRoot.querySelector('[data-streamdown="link-safety-modal"]')
).toBeTruthy();

unmount();
portalRoot.remove();
});
});
23 changes: 23 additions & 0 deletions packages/streamdown/__tests__/mermaid-fullscreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,4 +348,27 @@ describe("MermaidFullscreenButton", () => {
document.querySelector('button[title="Download diagram"]')
).toBeFalsy();
});

it("should portal fullscreen overlay to the configured portal target", async () => {
const portalRoot = document.createElement("div");
document.body.appendChild(portalRoot);

const { container, unmount } = renderWithContext(
{ chart: "graph TD; A-->B" },
{ portal: () => portalRoot }
);

const openBtn = container.querySelector('button[title="View fullscreen"]');
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(openBtn!);

await waitFor(() => {
expect(
portalRoot.querySelector('[data-streamdown="mermaid-fullscreen"]')
).toBeTruthy();
});

unmount();
portalRoot.remove();
});
});
53 changes: 53 additions & 0 deletions packages/streamdown/__tests__/portal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { fireEvent, render } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { Streamdown } from "../index";
import { resolvePortalTarget } from "../lib/portal";

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

describe("portal target", () => {
it("should fall back to document.body when the portal getter returns null", () => {
expect(resolvePortalTarget(() => null)).toBe(document.body);
});

it("should not resolve the portal target during server rendering", () => {
const getPortal = vi.fn(() => document.body);

renderToString(<Streamdown portal={getPortal}>Content</Streamdown>);

expect(getPortal).not.toHaveBeenCalled();
});

it("should treat portal as an initializing prop", () => {
const firstRoot = document.createElement("div");
const secondRoot = document.createElement("div");
document.body.append(firstRoot, secondRoot);

const { container, rerender, unmount } = render(
<Streamdown portal={firstRoot}>{markdownWithTable}</Streamdown>
);

rerender(<Streamdown portal={secondRoot}>{markdownWithTable}</Streamdown>);

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

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

unmount();
firstRoot.remove();
secondRoot.remove();
});
});
21 changes: 21 additions & 0 deletions packages/streamdown/__tests__/table-fullscreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,27 @@ describe("TableFullscreenButton", () => {
overlay?.querySelector('button[title="Download table as Markdown"]')
).toBeTruthy();
});

it("should portal fullscreen overlay to the configured portal target", () => {
const portalRoot = document.createElement("div");
document.body.appendChild(portalRoot);

const { container, unmount } = render(
<Streamdown portal={portalRoot}>{markdownWithTable}</Streamdown>
);

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

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

unmount();
portalRoot.remove();
});
});

describe("TableFullscreenButton copy and download", () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export interface MermaidOptions {
}

export type AllowedTags = Record<string, string[]>;
export type PortalTarget = HTMLElement | null | (() => HTMLElement | null);

export type StreamdownProps = Options & {
mode?: "static" | "streaming";
Expand Down Expand Up @@ -230,6 +231,11 @@ export type StreamdownProps = Options & {
plugins?: PluginConfig;
remend?: RemendOptions;
linkSafety?: LinkSafetyConfig;
/**
* DOM node for Streamdown overlays, or a function returning one.
* Defaults to `document.body`.
*/
portal?: PortalTarget;
/** Custom tags to allow through sanitization with their permitted attributes */
allowedTags?: AllowedTags;
/**
Expand Down Expand Up @@ -320,6 +326,7 @@ export interface StreamdownContextType {
linkSafety?: LinkSafetyConfig;
mermaid?: MermaidOptions;
mode: "static" | "streaming";
portal?: PortalTarget;
shikiTheme: [ThemeInput, ThemeInput];
/** Max height for tables. @default 300 */
tableMaxHeight: number | string;
Expand All @@ -343,6 +350,7 @@ const defaultStreamdownContext: StreamdownContextType = {
mode: "streaming",
mermaid: undefined,
linkSafety: defaultLinkSafetyConfig,
portal: undefined,
tableMaxHeight: 300,
};

Expand Down Expand Up @@ -496,6 +504,7 @@ export const Streamdown = memo(
plugins,
remend: remendOptions,
linkSafety = defaultLinkSafetyConfig,
portal,
lineNumbers = true,
allowedTags,
literalTagContent,
Expand Down Expand Up @@ -688,6 +697,7 @@ export const Streamdown = memo(
mode,
mermaid,
linkSafety,
portal,
tableMaxHeight,
}),
[
Expand All @@ -699,6 +709,7 @@ export const Streamdown = memo(
mode,
mermaid,
linkSafety,
portal,
plugins?.code,
tableMaxHeight,
]
Expand Down
7 changes: 5 additions & 2 deletions packages/streamdown/lib/link-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { StreamdownContext } from "../index";
import { useIcons } from "./icon-context";
import { resolvePortalTarget } from "./portal";
import { useCn } from "./prefix-context";
import { lockBodyScroll, unlockBodyScroll } from "./scroll-lock";
import { useTranslations } from "./translations-context";
Expand All @@ -22,6 +24,7 @@ export const LinkSafetyModal = ({
const cn = useCn();
const [copied, setCopied] = useState(false);
const t = useTranslations();
const { portal } = useContext(StreamdownContext);

const handleCopy = useCallback(async () => {
try {
Expand Down Expand Up @@ -150,5 +153,5 @@ export const LinkSafetyModal = ({
</div>
);

return createPortal(modal, document.body);
return createPortal(modal, resolvePortalTarget(portal));
};
10 changes: 7 additions & 3 deletions packages/streamdown/lib/mermaid/fullscreen-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StreamdownContext } from "../../index";
import { CodeBlockCopyButton } from "../code-block/copy-button";
import { useIcons } from "../icon-context";
import type { MermaidConfig } from "../plugin-types";
import { resolvePortalTarget } from "../portal";
import { useCn } from "../prefix-context";
import { lockBodyScroll, unlockBodyScroll } from "../scroll-lock";
import { useTranslations } from "../translations-context";
Expand All @@ -28,8 +29,11 @@ export const MermaidFullscreenButton = ({
const { Maximize2Icon, XIcon } = useIcons();
const cn = useCn();
const [isFullscreen, setIsFullscreen] = useState(false);
const { isAnimating, controls: controlsConfig } =
useContext(StreamdownContext);
const {
isAnimating,
controls: controlsConfig,
portal,
} = useContext(StreamdownContext);
const t = useTranslations();
const showPanZoomControls = (() => {
if (typeof controlsConfig === "boolean") {
Expand Down Expand Up @@ -179,7 +183,7 @@ export const MermaidFullscreenButton = ({
/>
</div>
</div>,
document.body
resolvePortalTarget(portal)
)
: null}
</>
Expand Down
8 changes: 8 additions & 0 deletions packages/streamdown/lib/portal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { PortalTarget } from "../index";

export const resolvePortalTarget = (
portal: PortalTarget | undefined
): HTMLElement => {
const container = typeof portal === "function" ? portal() : portal;
return container ?? document.body;
};
5 changes: 3 additions & 2 deletions packages/streamdown/lib/table/fullscreen-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { StreamdownContext } from "../../index";
import { useIcons } from "../icon-context";
import { resolvePortalTarget } from "../portal";
import { useCn } from "../prefix-context";
import { lockBodyScroll, unlockBodyScroll } from "../scroll-lock";
import { useTranslations } from "../translations-context";
Expand All @@ -24,7 +25,7 @@ export const TableFullscreenButton = ({
const { Maximize2Icon, XIcon } = useIcons();
const cn = useCn();
const [isFullscreen, setIsFullscreen] = useState(false);
const { isAnimating } = useContext(StreamdownContext);
const { isAnimating, portal } = useContext(StreamdownContext);
const t = useTranslations();

const handleOpen = () => {
Expand Down Expand Up @@ -122,7 +123,7 @@ export const TableFullscreenButton = ({
</div>
</div>
</div>,
document.body
resolvePortalTarget(portal)
)
: null}
</>
Expand Down
Loading