Skip to content

Commit da10eb4

Browse files
committed
fix(web): truncate draft project names in the middle
1 parent ffb4af8 commit da10eb4

4 files changed

Lines changed: 126 additions & 5 deletions

File tree

‎apps/web/src/components/chat/DraftHeroHeadline.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from "../ui/combobox";
4141
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4242
import { InlineButton } from "../ui/button";
43+
import { DraftProjectName } from "./DraftProjectName";
4344
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
4445

4546
// Picker value for the "Add project" row; real entries are keyed by logical
@@ -308,9 +309,9 @@ export function DraftHeroHeadline({
308309
/>
309310
}
310311
>
311-
<span className="min-w-0 truncate">
312-
{isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")}
313-
</span>
312+
<DraftProjectName
313+
value={isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")}
314+
/>
314315
</TooltipTrigger>
315316
{activeProjectDisplayName && !isScratchDraft ? (
316317
<TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup>
@@ -335,8 +336,8 @@ export function DraftHeroHeadline({
335336
<ProjectFavicon project={entry.group} className="size-4 shrink-0" />
336337
) : null}
337338
<Tooltip>
338-
<TooltipTrigger render={<span className="min-w-0 flex-1 truncate text-sm" />}>
339-
{item.label}
339+
<TooltipTrigger render={<span className="min-w-0 flex-1 text-sm" />}>
340+
<DraftProjectName value={item.label} />
340341
</TooltipTrigger>
341342
<TooltipPopup side="top">{item.label}</TooltipPopup>
342343
</Tooltip>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { truncateProjectName } from "./DraftProjectName.logic";
4+
5+
const measure = (text: string) => Array.from(text).length;
6+
7+
describe("truncateProjectName", () => {
8+
it("keeps distinguishing suffixes in one string without adding whitespace", () => {
9+
expect(truncateProjectName("ExampleCompany-CustomerPortal-App", 19, measure)).toBe(
10+
"ExampleCo…ortal-App",
11+
);
12+
expect(truncateProjectName("ExampleCompany-CustomerPortal-Env", 19, measure)).toBe(
13+
"ExampleCo…ortal-Env",
14+
);
15+
});
16+
17+
it("leaves names that fit unchanged", () => {
18+
expect(truncateProjectName("ShortRepo", 9, measure)).toBe("ShortRepo");
19+
});
20+
21+
it("fits proportional character widths", () => {
22+
const proportional = (text: string) =>
23+
Array.from(text).reduce((width, character) => width + (character === "W" ? 3 : 1), 0);
24+
expect(truncateProjectName("WWWW-project-App", 12, proportional)).toBe("WW…App");
25+
});
26+
27+
it("keeps combining marks and emoji sequences intact", () => {
28+
const value = "e\u0301👩‍💻-customer-portal-App";
29+
const result = truncateProjectName(value, 18, measure);
30+
expect(result.startsWith("e\u0301👩‍💻")).toBe(true);
31+
expect(result.endsWith("App")).toBe(true);
32+
expect(measure(result)).toBeLessThanOrEqual(18);
33+
});
34+
35+
it("handles space for only the ellipsis or no text", () => {
36+
expect(truncateProjectName("ExampleCompany", 1, measure)).toBe("…");
37+
expect(truncateProjectName("ExampleCompany", 0, measure)).toBe("");
38+
});
39+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
2+
3+
export function truncateProjectName(
4+
value: string,
5+
availableWidth: number,
6+
measure: (text: string) => number,
7+
): string {
8+
if (measure(value) <= availableWidth) return value;
9+
if (measure("…") > availableWidth) return "";
10+
11+
const characters = Array.from(graphemes.segment(value), (part) => part.segment);
12+
const candidate = (count: number) => {
13+
const head = characters.slice(0, Math.floor(count / 2)).join("");
14+
const tailCount = Math.ceil(count / 2);
15+
const tail = tailCount > 0 ? characters.slice(-tailCount).join("") : "";
16+
return `${head}…${tail}`;
17+
};
18+
19+
let low = 0;
20+
let high = characters.length - 1;
21+
while (low < high) {
22+
const count = Math.ceil((low + high) / 2);
23+
if (measure(candidate(count)) <= availableWidth) low = count;
24+
else high = count - 1;
25+
}
26+
return candidate(low);
27+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useLayoutEffect, useRef, useState } from "react";
2+
3+
import { observeResize } from "~/lib/observeResize";
4+
5+
import { truncateProjectName } from "./DraftProjectName.logic";
6+
7+
export function DraftProjectName({ value }: { value: string }) {
8+
const containerRef = useRef<HTMLSpanElement>(null);
9+
const [displayName, setDisplayName] = useState(value);
10+
11+
useLayoutEffect(() => {
12+
const container = containerRef.current;
13+
const context = document.createElement("canvas").getContext("2d");
14+
if (!container || !context) return;
15+
16+
const update = () => {
17+
const style = getComputedStyle(container);
18+
context.font = style.font;
19+
context.letterSpacing = style.letterSpacing === "normal" ? "0px" : style.letterSpacing;
20+
setDisplayName(
21+
truncateProjectName(
22+
value,
23+
container.getBoundingClientRect().width,
24+
(text) => context.measureText(text).width,
25+
),
26+
);
27+
};
28+
update();
29+
const stopObserving = observeResize(container, update);
30+
document.fonts.addEventListener("loadingdone", update);
31+
return () => {
32+
stopObserving();
33+
document.fonts.removeEventListener("loadingdone", update);
34+
};
35+
}, [value]);
36+
37+
return (
38+
<span
39+
ref={containerRef}
40+
className="inline-grid min-w-0 max-w-full overflow-hidden align-baseline whitespace-nowrap"
41+
>
42+
{/* Keep the full name's intrinsic width so fitting the visible text cannot shrink its budget. */}
43+
<span
44+
aria-hidden="true"
45+
className="invisible col-start-1 row-start-1 min-w-0 overflow-hidden select-none"
46+
>
47+
{value}
48+
</span>
49+
<span aria-label={value} className="col-start-1 row-start-1 min-w-0 overflow-hidden">
50+
{displayName}
51+
</span>
52+
</span>
53+
);
54+
}

0 commit comments

Comments
 (0)