diff --git a/.changeset/shared-overlay-portal.md b/.changeset/shared-overlay-portal.md new file mode 100644 index 00000000..de14e3b3 --- /dev/null +++ b/.changeset/shared-overlay-portal.md @@ -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. diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index 2539a1be..4225a07d 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -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", @@ -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(null); + +return ( +
+
+ portalRef.current}>{markdown} +
+); +``` + +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: diff --git a/packages/streamdown/__tests__/link-modal-keyboard.test.tsx b/packages/streamdown/__tests__/link-modal-keyboard.test.tsx index 4de1c905..a77afe0f 100644 --- a/packages/streamdown/__tests__/link-modal-keyboard.test.tsx +++ b/packages/streamdown/__tests__/link-modal-keyboard.test.tsx @@ -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", () => { @@ -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( + portalRoot}> + {"[External link](https://example.com)"} + + ); + + 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(); + }); }); diff --git a/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx b/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx index 5b48eb9e..fd5900cf 100644 --- a/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx +++ b/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx @@ -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(); + }); }); diff --git a/packages/streamdown/__tests__/portal.test.tsx b/packages/streamdown/__tests__/portal.test.tsx new file mode 100644 index 00000000..b4066749 --- /dev/null +++ b/packages/streamdown/__tests__/portal.test.tsx @@ -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(Content); + + 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( + {markdownWithTable} + ); + + rerender({markdownWithTable}); + + 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(); + }); +}); diff --git a/packages/streamdown/__tests__/table-fullscreen.test.tsx b/packages/streamdown/__tests__/table-fullscreen.test.tsx index a7c3b2c4..2fbe42b1 100644 --- a/packages/streamdown/__tests__/table-fullscreen.test.tsx +++ b/packages/streamdown/__tests__/table-fullscreen.test.tsx @@ -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( + {markdownWithTable} + ); + + 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", () => { diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 2c6bbe1b..bc01484a 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -192,6 +192,7 @@ export interface MermaidOptions { } export type AllowedTags = Record; +export type PortalTarget = HTMLElement | null | (() => HTMLElement | null); export type StreamdownProps = Options & { mode?: "static" | "streaming"; @@ -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; /** @@ -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; @@ -343,6 +350,7 @@ const defaultStreamdownContext: StreamdownContextType = { mode: "streaming", mermaid: undefined, linkSafety: defaultLinkSafetyConfig, + portal: undefined, tableMaxHeight: 300, }; @@ -496,6 +504,7 @@ export const Streamdown = memo( plugins, remend: remendOptions, linkSafety = defaultLinkSafetyConfig, + portal, lineNumbers = true, allowedTags, literalTagContent, @@ -688,6 +697,7 @@ export const Streamdown = memo( mode, mermaid, linkSafety, + portal, tableMaxHeight, }), [ @@ -699,6 +709,7 @@ export const Streamdown = memo( mode, mermaid, linkSafety, + portal, plugins?.code, tableMaxHeight, ] diff --git a/packages/streamdown/lib/link-modal.tsx b/packages/streamdown/lib/link-modal.tsx index f98ed64a..f5ea26e4 100644 --- a/packages/streamdown/lib/link-modal.tsx +++ b/packages/streamdown/lib/link-modal.tsx @@ -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"; @@ -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 { @@ -150,5 +153,5 @@ export const LinkSafetyModal = ({
); - return createPortal(modal, document.body); + return createPortal(modal, resolvePortalTarget(portal)); }; diff --git a/packages/streamdown/lib/mermaid/fullscreen-button.tsx b/packages/streamdown/lib/mermaid/fullscreen-button.tsx index 0f4efae8..a0be28ae 100644 --- a/packages/streamdown/lib/mermaid/fullscreen-button.tsx +++ b/packages/streamdown/lib/mermaid/fullscreen-button.tsx @@ -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"; @@ -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") { @@ -179,7 +183,7 @@ export const MermaidFullscreenButton = ({ /> , - document.body + resolvePortalTarget(portal) ) : null} diff --git a/packages/streamdown/lib/portal.ts b/packages/streamdown/lib/portal.ts new file mode 100644 index 00000000..908c4c16 --- /dev/null +++ b/packages/streamdown/lib/portal.ts @@ -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; +}; diff --git a/packages/streamdown/lib/table/fullscreen-button.tsx b/packages/streamdown/lib/table/fullscreen-button.tsx index 4589e602..27c3d446 100644 --- a/packages/streamdown/lib/table/fullscreen-button.tsx +++ b/packages/streamdown/lib/table/fullscreen-button.tsx @@ -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"; @@ -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 = () => { @@ -122,7 +123,7 @@ export const TableFullscreenButton = ({ , - document.body + resolvePortalTarget(portal) ) : null}