diff --git a/apps/web/src/components/ui/middle-truncate.browser.test.tsx b/apps/web/src/components/ui/middle-truncate.browser.test.tsx index 3b9b114d2f9f4..3fbf36523254b 100644 --- a/apps/web/src/components/ui/middle-truncate.browser.test.tsx +++ b/apps/web/src/components/ui/middle-truncate.browser.test.tsx @@ -23,4 +23,29 @@ describe("MiddleTruncate", () => { expect(label()).not.toHaveAttribute("data-overflow"); expect(label()).toHaveStyle({ clipPath: "none" }); }); + + it.each([ + ["Fix the sidebar", 7], + ["report\u2003\u2003draft.txt", 9], + ])("renders %j split as wide as whole", async (value, tail) => { + await using screen = await render( +
+ + + {value} + +
, + ); + const width = (id: string) => screen.getByTestId(id).element().getBoundingClientRect().width; + + expect(width("split")).toBeCloseTo(width("whole"), 0); + }); + + it("keeps the raw value's tail when collapsing its spaces", async () => { + await using screen = await render(); + + expect(screen.container.querySelector("[title] > span:last-child")?.textContent).toBe( + "x a b.txt", + ); + }); }); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index 62dd3c58ecce3..faf8ab6b3f397 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -16,7 +16,8 @@ export function MiddleTruncate({ showTitle?: boolean; variant?: "default" | "cap-aligned"; }) { - const split = splitForMiddleTruncate(value, tail); + const raw = splitForMiddleTruncate(value, tail); + const split = raw && collapseSegments(raw); const headRef = useRef(null); const head = split?.head; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fits the head span's DOM text and refits it through a ResizeObserver @@ -42,10 +43,12 @@ export function MiddleTruncate({ {split ? ( <> {/* The tail carries the ellipsis so it sits flush; a CSS ellipsis leaves a ragged gap. */} - + {split.head} - {split.tail} + + {split.tail} + ) : ( // Keyed so React does not reuse the head node and its imperative clip. @@ -89,6 +92,16 @@ function fitHead(head: HTMLElement) { if (tail instanceof HTMLElement) tail.style.translate = slack > 0 ? `-${slack}px 0` : ""; } +// The segments render pre so the space at the cut survives, so collapse here what CSS +// would have (not \s: em and no-break spaces are kept). Split first, so the tail length +// still counts the raw value. +function collapseSegments(split: { head: string; tail: string }) { + const collapse = (text: string) => text.replace(/[ \t\n\r\f]+/g, " "); + const head = collapse(split.head).replace(/^ /, ""); + const tail = collapse(split.tail).replace(/ $/, ""); + return { head, tail: head.endsWith(" ") ? tail.replace(/^ /, "") : tail }; +} + const DEFAULT_TAIL = 10; const MAX_SEGMENT_TAIL = 16; const MIN_WORD_TAIL = 4;