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;