Open History to see the last commit
1import { type ComponentProps, useLayoutEffect, useRef } from "react";
2
3import { observeResize } from "~/lib/observeResize";
4import { cn } from "~/lib/utils";
5
6export function MiddleTruncate({
7 value,
8 tail,
9 showTitle = true,
10 variant = "default",
11 className,
12 ...props
13}: Omit<ComponentProps<"span">, "children"> & {
14 value: string;
15 tail?: number;
16 showTitle?: boolean;
17 variant?: "default" | "cap-aligned";
18}) {
19 const split = splitForMiddleTruncate(value, tail);
20 const headRef = useRef<HTMLSpanElement>(null);
21 const head = split?.head;
22 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fits the head span's DOM text and refits it through a ResizeObserver
23 useLayoutEffect(() => {
24 const element = headRef.current;
25 // A new head can keep the same box size, so refit here, not only on resize.
26 if (!element || head === undefined) return;
27 fitHead(element);
28 return observeResize(element, () => fitHead(element));
29 }, [head]);
30 return (
31 <span
32 {...(showTitle ? { title: value } : {})}
33 className={cn(
34 "inline-flex min-w-0 max-w-full overflow-hidden whitespace-nowrap",
35 variant === "cap-aligned" &&
36 // Padding keeps descenders inside the box, so the cut does not clip them.
37 "overflow-x-clip overflow-y-visible [&>span]:[text-box:trim-both_cap_alphabetic] [&>span]:-mb-1 [&>span]:pb-1 [&>span]:overflow-x-clip [&>span]:overflow-y-visible",
38 className,
39 )}
40 {...props}
41 >
42 {split ? (
43 <>
44 {/* The tail carries the ellipsis so it sits flush; a CSS ellipsis leaves a ragged gap. */}
45 <span ref={headRef} className="peer min-w-0 overflow-hidden">
46 {split.head}
47 </span>
48 <span className="shrink-0 peer-data-overflow:before:content-['…']">{split.tail}</span>
49 </>
50 ) : (
51 // Keyed so React does not reuse the head node and its imperative clip.
52 <span key="whole" className="min-w-0 truncate">
53 {value}
54 </span>
55 )}
56 </span>
57 );
58}
59
60// An overflowing head is cut after its last whole character: clip the partial glyph
61// and slide the tail left over the slack. Neither changes layout, so the observed
62// size stays the true available width.
63
64function fitHead(head: HTMLElement) {
65 const text = head.firstChild;
66 const overflowing = head.scrollWidth > head.clientWidth && text instanceof Text;
67 head.toggleAttribute("data-overflow", overflowing);
68 let slack = 0;
69 if (overflowing) {
70 const range = document.createRange();
71 const left = head.getBoundingClientRect().left;
72 const widthTo = (end: number) => {
73 range.setStart(text, 0);
74 range.setEnd(text, end);
75 return range.getBoundingClientRect().right - left;
76 };
77 let fits = 0;
78 let over = text.length;
79 while (over - fits > 1) {
80 const mid = (fits + over) >> 1;
81 if (widthTo(mid) <= head.clientWidth) fits = mid;
82 else over = mid;
83 }
84 if (/[\uD800-\uDBFF]/.test(text.data[fits - 1] ?? "")) fits -= 1;
85 slack = Math.max(0, head.clientWidth - widthTo(fits));
86 }
87 head.style.clipPath = slack > 0 ? `inset(-0.25em ${slack}px -0.25em 0)` : "";
88 const tail = head.nextElementSibling;
89 if (tail instanceof HTMLElement) tail.style.translate = slack > 0 ? `-${slack}px 0` : "";
90}
91
92const DEFAULT_TAIL = 10;
93const MAX_SEGMENT_TAIL = 16;
94const MIN_WORD_TAIL = 4;
95const WORD_BREAK = /[-_./]/;
96
97export function splitForMiddleTruncate(
98 value: string,
99 tail?: number,
100): { head: string; tail: string } | null {
101 // Split by code point to avoid cutting surrogate pairs into broken glyphs.
102 const chars = Array.from(value);
103 let keep = tail ?? DEFAULT_TAIL;
104 if (tail === undefined) {
105 const slash = chars.lastIndexOf("/");
106 if (slash > 0 && slash < chars.length - 1) {
107 const segment = chars.length - slash - 1;
108 keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL;
109 }
110 }
111 if (keep <= 0 || chars.length <= keep + 4) return null;
112 let cut = chars.length - keep;
113 // Start an automatic tail on a word ("machine", not "er-machine"). A shorter tail is
114 // preferred so the head stays readable; a longer one is the fallback.
115 if (tail === undefined && !WORD_BREAK.test(chars[cut - 1]!)) {
116 const startsWord = (index: number) => WORD_BREAK.test(chars[index - 1]!);
117 let snapped = -1;
118 for (let index = cut + 1; index <= chars.length - MIN_WORD_TAIL; index++) {
119 if (startsWord(index)) {
120 snapped = index;
121 break;
122 }
123 }
124 for (
125 let index = cut - 1;
126 snapped < 0 && index >= chars.length - MAX_SEGMENT_TAIL && index > 4;
127 index--
128 ) {
129 if (startsWord(index)) snapped = index;
130 }
131 if (snapped > 0) cut = snapped;
132 }
133 return { head: chars.slice(0, cut).join(""), tail: chars.slice(cut).join("") };
134}