diff --git a/.changeset/logical-rtl-classes.md b/.changeset/logical-rtl-classes.md new file mode 100644 index 00000000..53a6a8e8 --- /dev/null +++ b/.changeset/logical-rtl-classes.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +fix: use logical Tailwind classes (`ps-*`, `border-s-*`, `text-start`, `start-*`, `end-*`, `ms-*`) instead of physical left/right classes so layout mirrors in RTL text. `cn` now treats logical and physical utilities on the same axis as conflicting, so a consumer `className="pl-8"` still overrides the built-in padding. The code block keeps physical classes because it is pinned `dir="ltr"`. diff --git a/packages/streamdown/__tests__/components.test.tsx b/packages/streamdown/__tests__/components.test.tsx index 03ba1b07..52f98946 100644 --- a/packages/streamdown/__tests__/components.test.tsx +++ b/packages/streamdown/__tests__/components.test.tsx @@ -236,10 +236,27 @@ describe("Markdown Components", () => { const blockquote = container.querySelector("blockquote"); expect(blockquote).toBeTruthy(); expect(blockquote?.className).toContain("my-4"); - expect(blockquote?.className).toContain("border-l-4"); - expect(blockquote?.className).toContain("pl-4"); + expect(blockquote?.className).toContain("border-s-4"); + expect(blockquote?.className).toContain("ps-4"); expect(blockquote?.className).toContain("italic"); }); + + it("lets a physical className override the logical blockquote padding", () => { + const Blockquote = components.blockquote; + if (!Blockquote) { + throw new Error("Blockquote component not found"); + } + const { container } = render( +
+ Quote text +
+ ); + const classes = container.querySelector("blockquote")?.classList; + expect(classes?.contains("pl-8")).toBe(true); + expect(classes?.contains("ps-4")).toBe(false); + expect(classes?.contains("border-l-2")).toBe(true); + expect(classes?.contains("border-s-4")).toBe(false); + }); }); describe("Code Components", () => { @@ -525,7 +542,7 @@ describe("Markdown Components", () => { expect(th?.className).toContain("whitespace-nowrap"); expect(th?.className).toContain("px-4"); expect(th?.className).toContain("py-2"); - expect(th?.className).toContain("text-left"); + expect(th?.className).toContain("text-start"); expect(th?.className).toContain("font-semibold"); expect(th?.className).toContain("text-sm"); }); diff --git a/packages/streamdown/__tests__/pan-zoom-interaction.test.tsx b/packages/streamdown/__tests__/pan-zoom-interaction.test.tsx index 54239915..5828bc66 100644 --- a/packages/streamdown/__tests__/pan-zoom-interaction.test.tsx +++ b/packages/streamdown/__tests__/pan-zoom-interaction.test.tsx @@ -233,13 +233,13 @@ describe("PanZoom pointer interactions", () => { ); - // Fullscreen controls should use bottom-4 left-4 + // Fullscreen controls should use bottom-4 start-4 const controls = container.querySelector(".absolute.z-10"); expect(controls?.className).toContain("bottom-4"); - expect(controls?.className).toContain("left-4"); + expect(controls?.className).toContain("start-4"); }); - it("should position controls at bottom-2 left-2 when not fullscreen", () => { + it("should position controls at bottom-2 start-2 when not fullscreen", () => { const { container } = render(
Content
@@ -248,6 +248,6 @@ describe("PanZoom pointer interactions", () => { const controls = container.querySelector(".absolute.z-10"); expect(controls?.className).toContain("bottom-2"); - expect(controls?.className).toContain("left-2"); + expect(controls?.className).toContain("start-2"); }); }); diff --git a/packages/streamdown/__tests__/utils.test.ts b/packages/streamdown/__tests__/utils.test.ts index c71819bf..f75ea29b 100644 --- a/packages/streamdown/__tests__/utils.test.ts +++ b/packages/streamdown/__tests__/utils.test.ts @@ -71,6 +71,44 @@ describe("cn utility", () => { }); }); +describe("cn logical/physical conflicts", () => { + const overrides: [string, string, string][] = [ + ["ps-4", "pl-8", "pl-8"], + ["pl-4", "ps-2", "ps-2"], + ["pe-4", "pr-2", "pr-2"], + ["ms-1", "ml-3", "ml-3"], + ["me-1", "mr-3", "mr-3"], + ["border-s-4", "border-l-2", "border-l-2"], + ["border-l-4", "border-s-2", "border-s-2"], + ["end-0", "right-2", "right-2"], + ["start-0", "left-2", "left-2"], + ["text-start", "text-left", "text-left"], + ]; + for (const [base, override, expected] of overrides) { + it(`cn("${base}", "${override}") === "${expected}"`, () => { + expect(cn(base, override)).toBe(expected); + }); + } + + const untouched: [string, string][] = [ + ["ps-4", "pt-2"], + ["ps-4", "mr-2"], + ["border-s-4", "pl-2"], + ["end-0", "top-2"], + ["border-s-4", "border-muted"], + ]; + for (const [a, b] of untouched) { + it(`cn("${a}", "${b}") keeps both`, () => { + expect(cn(a, b)).toBe(`${a} ${b}`); + }); + } + + it("keeps a narrow logical utility after a broad axis, like the physical one", () => { + expect(cn("px-4", "ps-2")).toBe("px-4 ps-2"); + expect(cn("px-4", "pl-2")).toBe("px-4 pl-2"); + }); +}); + describe("save utility", () => { let createObjectURLSpy: ReturnType; let revokeObjectURLSpy: ReturnType; diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx index fb0470a2..fa81cee4 100644 --- a/packages/streamdown/lib/components.tsx +++ b/packages/streamdown/lib/components.tsx @@ -211,7 +211,7 @@ const MemoOl = memo( return (
    ( return (