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 3fbf36523254..91de6b169f64 100644 --- a/apps/web/src/components/ui/middle-truncate.browser.test.tsx +++ b/apps/web/src/components/ui/middle-truncate.browser.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { render } from "../../../test/render"; import { MiddleTruncate } from "./middle-truncate"; @@ -24,6 +24,24 @@ describe("MiddleTruncate", () => { expect(label()).toHaveStyle({ clipPath: "none" }); }); + it("fits every head mounted in the same commit", async () => { + const values = ["t3/investigate-release-build-times", "t3/remove-stale-worktree-branches"]; + await using screen = await render( + <> + {values.map((value) => ( +
{narrow(value)}
+ ))} + , + ); + const heads = () => [ + ...screen.container.querySelectorAll("[title] > span:first-child"), + ]; + + await vi.waitFor(() => + expect(heads().filter((head) => head.hasAttribute("data-overflow"))).toHaveLength(2), + ); + }); + it.each([ ["Fix the sidebar", 7], ["report\u2003\u2003draft.txt", 9], diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index faf8ab6b3f39..a5213209f0ed 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -25,8 +25,8 @@ export function MiddleTruncate({ const element = headRef.current; // A new head can keep the same box size, so refit here, not only on resize. if (!element || head === undefined) return; - fitHead(element); - return observeResize(element, () => fitHead(element)); + scheduleFit(element); + return observeResize(element, () => scheduleFit(element)); }, [head]); return ( (); + +function scheduleFit(head: HTMLElement) { + if (pendingFits.size === 0) queueMicrotask(fitPendingHeads); + pendingFits.add(head); +} + // An overflowing head is cut after its last whole character: clip the partial glyph // and slide the tail left over the slack. Neither changes layout, so the observed // size stays the true available width. +function fitPendingHeads() { + const heads = [...pendingFits].filter((head) => head.isConnected); + pendingFits.clear(); + const overflowing = heads.map( + (head) => head.scrollWidth > head.clientWidth && head.firstChild instanceof Text, + ); + heads.forEach((head, index) => head.toggleAttribute("data-overflow", overflowing[index])); + const slacks = heads.map((head, index) => (overflowing[index] ? measureSlack(head) : 0)); + heads.forEach((head, index) => { + const slack = slacks[index]!; + head.style.clipPath = slack > 0 ? `inset(-0.25em ${slack}px -0.25em 0)` : ""; + const tail = head.nextElementSibling; + if (tail instanceof HTMLElement) tail.style.translate = slack > 0 ? `-${slack}px 0` : ""; + }); +} -function fitHead(head: HTMLElement) { - const text = head.firstChild; - const overflowing = head.scrollWidth > head.clientWidth && text instanceof Text; - head.toggleAttribute("data-overflow", overflowing); - let slack = 0; - if (overflowing) { - const range = document.createRange(); - const left = head.getBoundingClientRect().left; - const widthTo = (end: number) => { - range.setStart(text, 0); - range.setEnd(text, end); - return range.getBoundingClientRect().right - left; - }; - let fits = 0; - let over = text.length; - while (over - fits > 1) { - const mid = (fits + over) >> 1; - if (widthTo(mid) <= head.clientWidth) fits = mid; - else over = mid; - } - if (/[\uD800-\uDBFF]/.test(text.data[fits - 1] ?? "")) fits -= 1; - slack = Math.max(0, head.clientWidth - widthTo(fits)); +function measureSlack(head: HTMLElement) { + const text = head.firstChild as Text; + const range = document.createRange(); + const left = head.getBoundingClientRect().left; + const width = head.clientWidth; + const widthTo = (end: number) => { + range.setStart(text, 0); + range.setEnd(text, end); + return range.getBoundingClientRect().right - left; + }; + let fits = 0; + let over = text.length; + while (over - fits > 1) { + const mid = (fits + over) >> 1; + if (widthTo(mid) <= width) fits = mid; + else over = mid; } - head.style.clipPath = slack > 0 ? `inset(-0.25em ${slack}px -0.25em 0)` : ""; - const tail = head.nextElementSibling; - if (tail instanceof HTMLElement) tail.style.translate = slack > 0 ? `-${slack}px 0` : ""; + if (/[\uD800-\uDBFF]/.test(text.data[fits - 1] ?? "")) fits -= 1; + return Math.max(0, width - widthTo(fits)); } // The segments render pre so the space at the cut survives, so collapse here what CSS