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/logical-rtl-classes.md
Original file line number Diff line number Diff line change
@@ -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"`.
23 changes: 20 additions & 3 deletions packages/streamdown/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Blockquote className="border-l-2 pl-8" node={null as any}>
Quote text
</Blockquote>
);
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", () => {
Expand Down Expand Up @@ -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");
});
Expand Down
8 changes: 4 additions & 4 deletions packages/streamdown/__tests__/pan-zoom-interaction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,13 @@ describe("PanZoom pointer interactions", () => {
</PanZoom>
);

// 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(
<PanZoom fullscreen={false}>
<div>Content</div>
Expand All @@ -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");
});
});
38 changes: 38 additions & 0 deletions packages/streamdown/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.spyOn>;
let revokeObjectURLSpy: ReturnType<typeof vi.spyOn>;
Expand Down
14 changes: 7 additions & 7 deletions packages/streamdown/lib/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ const MemoOl = memo<OlProps>(
return (
<ol
className={cn(
"list-inside list-decimal whitespace-normal [li_&]:pl-6",
"list-inside list-decimal whitespace-normal [li_&]:ps-6",
className
)}
data-streamdown="ordered-list"
Expand Down Expand Up @@ -265,7 +265,7 @@ const MemoUl = memo<UlProps>(
return (
<ul
className={cn(
"list-inside list-disc whitespace-normal [li_&]:pl-6",
"list-inside list-disc whitespace-normal [li_&]:ps-6",
className
)}
data-streamdown="unordered-list"
Expand Down Expand Up @@ -371,7 +371,7 @@ const LinkComponent = ({
<>
<button
className={cn(
"wrap-anywhere appearance-none text-left font-medium text-primary underline",
"wrap-anywhere appearance-none text-start font-medium text-primary underline",
className
)}
data-incomplete={isIncomplete}
Expand Down Expand Up @@ -606,7 +606,7 @@ const MemoTh = memo<ThProps>(
return (
<th
className={cn(
"whitespace-nowrap px-4 py-2 text-left font-semibold text-sm",
"whitespace-nowrap px-4 py-2 text-start font-semibold text-sm",
className
)}
data-streamdown="table-header-cell"
Expand Down Expand Up @@ -645,7 +645,7 @@ const MemoBlockquote = memo<BlockquoteProps>(
return (
<blockquote
className={cn(
"my-4 border-muted-foreground/30 border-l-4 pl-4 text-muted-foreground italic",
"my-4 border-muted-foreground/30 border-s-4 ps-4 text-muted-foreground italic",
className
)}
data-streamdown="blockquote"
Expand Down Expand Up @@ -943,12 +943,12 @@ const CodeComponent = ({
"flex h-8 items-center text-muted-foreground text-xs"
)}
>
<span className={cn("ml-1 font-mono lowercase")}>mermaid</span>
<span className={cn("ms-1 font-mono lowercase")}>mermaid</span>
</div>
{shouldShowMermaidControls ? (
<div
className={cn(
"pointer-events-none absolute top-2 right-2 z-10 flex items-center"
"pointer-events-none absolute end-2 top-2 z-10 flex items-center"
)}
>
<div
Expand Down
2 changes: 1 addition & 1 deletion packages/streamdown/lib/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export const ImageComponent = ({
{showDownload && (
<button
className={cn(
"absolute right-2 bottom-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-border bg-background/90 shadow-sm backdrop-blur-sm transition-all duration-200 hover:bg-background",
"absolute end-2 bottom-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-border bg-background/90 shadow-sm backdrop-blur-sm transition-all duration-200 hover:bg-background",
"opacity-0 group-hover:opacity-100"
)}
onClick={downloadImage}
Expand Down
2 changes: 1 addition & 1 deletion packages/streamdown/lib/link-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const LinkSafetyModal = ({
>
<button
className={cn(
"absolute top-4 right-4 rounded-md p-1 text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
"absolute end-4 top-4 rounded-md p-1 text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
)}
onClick={onClose}
title={t.close}
Expand Down
8 changes: 4 additions & 4 deletions packages/streamdown/lib/mermaid/download-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,13 @@ export const MermaidDownloadDropdown = ({
{isOpen ? (
<div
className={cn(
"absolute top-full right-0 z-10 mt-1 min-w-[120px] overflow-hidden rounded-md border border-border bg-background shadow-lg"
"absolute end-0 top-full z-10 mt-1 min-w-[120px] overflow-hidden rounded-md border border-border bg-background shadow-lg"
)}
>
<button
aria-label={t.downloadDiagramAsSvg}
className={cn(
"w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
"w-full px-3 py-2 text-start text-sm transition-colors hover:bg-muted/40"
)}
onClick={() => downloadMermaid("svg")}
title={t.downloadDiagramAsSvg}
Expand All @@ -143,7 +143,7 @@ export const MermaidDownloadDropdown = ({
<button
aria-label={t.downloadDiagramAsPng}
className={cn(
"w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
"w-full px-3 py-2 text-start text-sm transition-colors hover:bg-muted/40"
)}
onClick={() => downloadMermaid("png")}
title={t.downloadDiagramAsPng}
Expand All @@ -154,7 +154,7 @@ export const MermaidDownloadDropdown = ({
<button
aria-label={t.downloadDiagramAsMmd}
className={cn(
"w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
"w-full px-3 py-2 text-start text-sm transition-colors hover:bg-muted/40"
)}
onClick={() => downloadMermaid("mmd")}
title={t.downloadDiagramAsMmd}
Expand Down
2 changes: 1 addition & 1 deletion packages/streamdown/lib/mermaid/fullscreen-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export const MermaidFullscreenButton = ({
{/* biome-ignore lint/a11y/noStaticElementInteractions: "div with role=presentation is used for event propagation control" */}
<div
className={cn(
"absolute top-4 right-4 z-10 flex items-center gap-1"
"absolute end-4 top-4 z-10 flex items-center gap-1"
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
Expand Down
2 changes: 1 addition & 1 deletion packages/streamdown/lib/mermaid/pan-zoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ export const PanZoom = ({
<div
className={cn(
"absolute z-10 flex flex-col gap-1 rounded-md border border-border bg-background/80 p-1 supports-[backdrop-filter]:bg-background/70 supports-[backdrop-filter]:backdrop-blur-sm",
fullscreen ? "bottom-4 left-4" : "bottom-2 left-2"
fullscreen ? "start-4 bottom-4" : "start-2 bottom-2"
)}
>
<button
Expand Down
8 changes: 4 additions & 4 deletions packages/streamdown/lib/table/copy-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,12 @@ export const TableCopyDropdown = ({
{isOpen ? (
<div
className={cn(
"absolute top-full right-0 z-20 mt-1 min-w-[120px] overflow-hidden rounded-md border border-border bg-background shadow-lg"
"absolute end-0 top-full z-20 mt-1 min-w-[120px] overflow-hidden rounded-md border border-border bg-background shadow-lg"
)}
>
<button
className={cn(
"w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
"w-full px-3 py-2 text-start text-sm transition-colors hover:bg-muted/40"
)}
onClick={() => copyTableData("md")}
title={t.copyTableAsMarkdown}
Expand All @@ -132,7 +132,7 @@ export const TableCopyDropdown = ({
</button>
<button
className={cn(
"w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
"w-full px-3 py-2 text-start text-sm transition-colors hover:bg-muted/40"
)}
onClick={() => copyTableData("csv")}
title={t.copyTableAsCsv}
Expand All @@ -142,7 +142,7 @@ export const TableCopyDropdown = ({
</button>
<button
className={cn(
"w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
"w-full px-3 py-2 text-start text-sm transition-colors hover:bg-muted/40"
)}
onClick={() => copyTableData("tsv")}
title={t.copyTableAsTsv}
Expand Down
6 changes: 3 additions & 3 deletions packages/streamdown/lib/table/download-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,12 @@ export const TableDownloadDropdown = ({
{isOpen ? (
<div
className={cn(
"absolute top-full right-0 z-20 mt-1 min-w-[120px] overflow-hidden rounded-md border border-border bg-background shadow-lg"
"absolute end-0 top-full z-20 mt-1 min-w-[120px] overflow-hidden rounded-md border border-border bg-background shadow-lg"
)}
>
<button
className={cn(
"w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
"w-full px-3 py-2 text-start text-sm transition-colors hover:bg-muted/40"
)}
onClick={() => downloadTableData("csv")}
title={t.downloadTableAsCsv}
Expand All @@ -198,7 +198,7 @@ export const TableDownloadDropdown = ({
</button>
<button
className={cn(
"w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
"w-full px-3 py-2 text-start text-sm transition-colors hover:bg-muted/40"
)}
onClick={() => downloadTableData("markdown")}
title={t.downloadTableAsMarkdown}
Expand Down
40 changes: 39 additions & 1 deletion packages/streamdown/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { extendTailwindMerge } from 'tailwind-merge';

/**
* Logical utilities (`ps`, `pe`, `ms`, `me`, `border-s`, `border-e`, `start`,
* `end`) and their physical counterparts control the same box edge, but
* tailwind-merge does not know that: by default `twMerge("ps-4", "pl-2")`
* keeps both, so a consumer `className` override is decided by stylesheet
* order instead of by the order the caller wrote the classes.
*
* Direction is a runtime property, so a static merge cannot tell whether `ps`
* resolves to `pl` or `pr`. Treating both as conflicting means the last class
* written wins, which matches caller intent.
*/
const logicalPhysicalConflicts = {
ps: ['pl', 'pr'],
pe: ['pl', 'pr'],
pl: ['ps', 'pe'],
pr: ['ps', 'pe'],
px: ['ps', 'pe'],
ms: ['ml', 'mr'],
me: ['ml', 'mr'],
ml: ['ms', 'me'],
mr: ['ms', 'me'],
mx: ['ms', 'me'],
'border-w-s': ['border-w-l', 'border-w-r'],
'border-w-e': ['border-w-l', 'border-w-r'],
'border-w-l': ['border-w-s', 'border-w-e'],
'border-w-r': ['border-w-s', 'border-w-e'],
'border-w-x': ['border-w-s', 'border-w-e'],
start: ['left', 'right'],
end: ['left', 'right'],
left: ['start', 'end'],
right: ['start', 'end'],
'inset-x': ['start', 'end'],
} as const;

const twMerge = extendTailwindMerge({
extend: { conflictingClassGroups: logicalPhysicalConflicts },
});

export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

Expand Down
Loading