Skip to content
25 changes: 25 additions & 0 deletions apps/web/src/components/ui/middle-truncate.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<div>
<MiddleTruncate value={value} tail={tail} data-testid="split" />
<span data-testid="whole" className="whitespace-nowrap">
{value}
</span>
</div>,
);
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(<MiddleTruncate value={"longprefix a b.txt"} />);

expect(screen.container.querySelector("[title] > span:last-child")?.textContent).toBe(
"x a b.txt",
);
});
});
19 changes: 16 additions & 3 deletions apps/web/src/components/ui/middle-truncate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLSpanElement>(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
Expand All @@ -42,10 +43,12 @@ export function MiddleTruncate({
{split ? (
<>
{/* The tail carries the ellipsis so it sits flush; a CSS ellipsis leaves a ragged gap. */}
<span ref={headRef} className="peer min-w-0 overflow-hidden">
<span ref={headRef} className="peer min-w-0 overflow-hidden whitespace-pre">
{split.head}
</span>
<span className="shrink-0 peer-data-overflow:before:content-['…']">{split.tail}</span>
<span className="shrink-0 whitespace-pre peer-data-overflow:before:content-['…']">
{split.tail}
</span>
</>
) : (
// Keyed so React does not reuse the head node and its imperative clip.
Expand Down Expand Up @@ -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;
Expand Down
Loading