@patterninc/pattern-ui 0.0.0-main.202610232103.2cb6e31ce186 → 0.0.0-main.202610232106.7015de2e4ed0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/@patterninc/pattern-ui/trim-text-fn.js +1 -0
- package/dist/browser/@patterninc/pattern-ui/trim-text.js +1 -0
- package/dist/browser/_chunks/trim-text-DHZGuJ9c.js +1 -0
- package/dist/components/TrimText/TrimText.js +258 -0
- package/dist/lib/utils/trim-text.js +17 -0
- package/dist/play-cdn-claude.js +1 -1
- package/package.json +9 -1
- package/src/components/TrimText/TrimText.tsx +169 -0
- package/src/lib/utils/trim-text.ts +13 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export{t as trimText}from"../../_chunks/trim-text-DHZGuJ9c.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as e}from"../../_chunks/rolldown-runtime-BWaJ3C7D.js";import{t}from"../../_chunks/react-CUaU2Z9V.js";import{t as n}from"../../_chunks/compiler-runtime-B3BlmaWz.js";import{t as r}from"../../_chunks/jsx-runtime-Dgn0yPdi.js";import{t as i}from"../../_chunks/dist-BNSTMZct.js";import{a,n as o,r as s}from"../../_chunks/Tooltip-abTz0p9j.js";import{t as c}from"../../_chunks/trim-text-DHZGuJ9c.js";var l=n(),u=/* @__PURE__ */ e(r(),1),d=/* @__PURE__ */ e(t(),1);function f(e,t){typeof e==`function`?e(t):e&&(e.current=t)}function p(e){return e.scrollWidth>e.clientWidth}function m(e,t){let n=()=>t(p(e));n();let r=requestAnimationFrame(n),i=new ResizeObserver(n);i.observe(e);let a=e.parentElement;a?.addEventListener(`focusin`,n),a?.addEventListener(`pointerenter`,n);let o=typeof document<`u`?document.fonts:void 0;return o?.addEventListener(`loadingdone`,n),o?.ready.then(()=>{e.isConnected&&n()}),()=>{cancelAnimationFrame(r),i.disconnect(),a?.removeEventListener(`focusin`,n),a?.removeEventListener(`pointerenter`,n),o?.removeEventListener(`loadingdone`,n)}}function h(e){let t=(0,l.c)(57),n,r,h,g,_,v,y;t[0]===e?(n=t[1],r=t[2],h=t[3],g=t[4],_=t[5],v=t[6],y=t[7]):({text:y,limit:r,side:_,className:n,qaTestId:v,ref:g,...h}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=h,t[4]=g,t[5]=_,t[6]=v,t[7]=y);let b=_===void 0?`bottom`:_,x=v===void 0?`trim-text`:v,S=(0,d.useRef)(null),C=(0,d.useRef)(null),[w,T]=(0,d.useState)(!1),[E,D]=(0,d.useState)(!1),O,k,A,j,M,N,P,F;if(t[8]!==n||t[9]!==r||t[10]!==w||t[11]!==h||t[12]!==x||t[13]!==g||t[14]!==b||t[15]!==y){F=Symbol.for(`react.early_return_sentinel`);bb0:{let e=i(`pui-text-body-medium text-text-strong`,n);if(r!=null&&r>=1){let n;t[24]!==r||t[25]!==y?(n=c(y,r),t[24]=r,t[25]=y,t[26]=n):n=t[26];let i=n;if(i===y){F=/* @__PURE__ */ (0,u.jsx)(`span`,{ref:g,className:e,"qa-test-id":x,...h,children:y});break bb0}let l=/* @__PURE__ */ (0,u.jsx)(a,{asChild:!0,children:/* @__PURE__ */ (0,u.jsx)(`span`,{ref:g,className:e,"qa-test-id":x,...h,tabIndex:0,children:i})}),d;t[27]!==b||t[28]!==y?(d=/* @__PURE__ */ (0,u.jsx)(s,{side:b,className:`max-w-xs break-words whitespace-normal`,children:y}),t[27]=b,t[28]=y,t[29]=d):d=t[29];let f;t[30]!==l||t[31]!==d?(f=/* @__PURE__ */ (0,u.jsxs)(o,{children:[l,d]}),t[30]=l,t[31]=d,t[32]=f):f=t[32],F=f;break bb0}k=o,N=w,t[33]===Symbol.for(`react.memo_cache_sentinel`)?(P=e=>{let t=S.current;T(e&&t!=null&&p(t))},t[33]=P):P=t[33],O=a,M=!0,t[34]!==g||t[35]!==y?(A=e=>{C.current?.(),C.current=null,S.current=e,f(g,e),e&&(y.length===0?D(!1):C.current=m(e,D))},t[34]=g,t[35]=y,t[36]=A):A=t[36],j=i(e,`block w-full min-w-0 truncate`)}t[8]=n,t[9]=r,t[10]=w,t[11]=h,t[12]=x,t[13]=g,t[14]=b,t[15]=y,t[16]=O,t[17]=k,t[18]=A,t[19]=j,t[20]=M,t[21]=N,t[22]=P,t[23]=F}else O=t[16],k=t[17],A=t[18],j=t[19],M=t[20],N=t[21],P=t[22],F=t[23];if(F!==Symbol.for(`react.early_return_sentinel`))return F;let I=E?0:void 0,L;t[37]!==h||t[38]!==x||t[39]!==A||t[40]!==j||t[41]!==I||t[42]!==y?(L=/* @__PURE__ */ (0,u.jsx)(`span`,{ref:A,className:j,"qa-test-id":x,...h,tabIndex:I,children:y}),t[37]=h,t[38]=x,t[39]=A,t[40]=j,t[41]=I,t[42]=y,t[43]=L):L=t[43];let R;t[44]!==O||t[45]!==L||t[46]!==M?(R=/* @__PURE__ */ (0,u.jsx)(O,{asChild:M,children:L}),t[44]=O,t[45]=L,t[46]=M,t[47]=R):R=t[47];let z;t[48]!==b||t[49]!==y?(z=/* @__PURE__ */ (0,u.jsx)(s,{side:b,className:`max-w-xs break-words whitespace-normal`,children:y}),t[48]=b,t[49]=y,t[50]=z):z=t[50];let B;return t[51]!==k||t[52]!==R||t[53]!==z||t[54]!==N||t[55]!==P?(B=/* @__PURE__ */ (0,u.jsxs)(k,{open:N,onOpenChange:P,children:[R,z]}),t[51]=k,t[52]=R,t[53]=z,t[54]=N,t[55]=P,t[56]=B):B=t[56],B}export{h as TrimText,c as trimText};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function e(e=``,t){return t<1||e.length<=t?e:`${e.substring(0,t).trim()}...`}export{e as t};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip/Tooltip.js";
|
|
2
|
+
import { trimText } from "../../lib/utils/trim-text.js";
|
|
3
|
+
import { c } from "react/compiler-runtime";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { useRef, useState } from "react";
|
|
6
|
+
import { cn } from "cn";
|
|
7
|
+
|
|
8
|
+
//#region src/components/TrimText/TrimText.tsx
|
|
9
|
+
function assignRef(ref, node) {
|
|
10
|
+
if (typeof ref === "function") ref(node);
|
|
11
|
+
else if (ref) ref.current = node;
|
|
12
|
+
}
|
|
13
|
+
function isOverflowed(el) {
|
|
14
|
+
return el.scrollWidth > el.clientWidth;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Width clipping depends on layout, not on props: the span is `w-full`, so its border box follows
|
|
18
|
+
* the container while `scrollWidth` follows the text. ResizeObserver misses a font swap or a
|
|
19
|
+
* letter-spacing change that leaves that box the same size. Measure on the node itself (no effect),
|
|
20
|
+
* again when `document.fonts` settles, and again when the parent is hovered or focused so the tab
|
|
21
|
+
* stop is current before the user tabs onward.
|
|
22
|
+
*/
|
|
23
|
+
function watchWidthOverflow(node, onChange) {
|
|
24
|
+
const measure = () => onChange(isOverflowed(node));
|
|
25
|
+
measure();
|
|
26
|
+
const frame = requestAnimationFrame(measure);
|
|
27
|
+
const observer = new ResizeObserver(measure);
|
|
28
|
+
observer.observe(node);
|
|
29
|
+
const parent = node.parentElement;
|
|
30
|
+
parent?.addEventListener("focusin", measure);
|
|
31
|
+
parent?.addEventListener("pointerenter", measure);
|
|
32
|
+
const fonts = typeof document !== "undefined" ? document.fonts : void 0;
|
|
33
|
+
fonts?.addEventListener("loadingdone", measure);
|
|
34
|
+
fonts?.ready.then(() => {
|
|
35
|
+
if (node.isConnected) measure();
|
|
36
|
+
});
|
|
37
|
+
return () => {
|
|
38
|
+
cancelAnimationFrame(frame);
|
|
39
|
+
observer.disconnect();
|
|
40
|
+
parent?.removeEventListener("focusin", measure);
|
|
41
|
+
parent?.removeEventListener("pointerenter", measure);
|
|
42
|
+
fonts?.removeEventListener("loadingdone", measure);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function TrimText(t0) {
|
|
46
|
+
const $ = c(57);
|
|
47
|
+
let className;
|
|
48
|
+
let limit;
|
|
49
|
+
let props;
|
|
50
|
+
let ref;
|
|
51
|
+
let t1;
|
|
52
|
+
let t2;
|
|
53
|
+
let text;
|
|
54
|
+
if ($[0] !== t0) {
|
|
55
|
+
({text, limit, side: t1, className, qaTestId: t2, ref, ...props} = t0);
|
|
56
|
+
$[0] = t0;
|
|
57
|
+
$[1] = className;
|
|
58
|
+
$[2] = limit;
|
|
59
|
+
$[3] = props;
|
|
60
|
+
$[4] = ref;
|
|
61
|
+
$[5] = t1;
|
|
62
|
+
$[6] = t2;
|
|
63
|
+
$[7] = text;
|
|
64
|
+
} else {
|
|
65
|
+
className = $[1];
|
|
66
|
+
limit = $[2];
|
|
67
|
+
props = $[3];
|
|
68
|
+
ref = $[4];
|
|
69
|
+
t1 = $[5];
|
|
70
|
+
t2 = $[6];
|
|
71
|
+
text = $[7];
|
|
72
|
+
}
|
|
73
|
+
const side = t1 === void 0 ? "bottom" : t1;
|
|
74
|
+
const qaTestId = t2 === void 0 ? "trim-text" : t2;
|
|
75
|
+
const spanRef = useRef(null);
|
|
76
|
+
const overflowWatchRef = useRef(null);
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
const [overflowed, setOverflowed] = useState(false);
|
|
79
|
+
let T0;
|
|
80
|
+
let T1;
|
|
81
|
+
let t3;
|
|
82
|
+
let t4;
|
|
83
|
+
let t5;
|
|
84
|
+
let t6;
|
|
85
|
+
let t7;
|
|
86
|
+
let t8;
|
|
87
|
+
if ($[8] !== className || $[9] !== limit || $[10] !== open || $[11] !== props || $[12] !== qaTestId || $[13] !== ref || $[14] !== side || $[15] !== text) {
|
|
88
|
+
t8 = Symbol.for("react.early_return_sentinel");
|
|
89
|
+
bb0: {
|
|
90
|
+
const textClassName = cn("pui-text-body-medium text-text-strong", className);
|
|
91
|
+
if (limit != null && limit >= 1) {
|
|
92
|
+
let t9;
|
|
93
|
+
if ($[24] !== limit || $[25] !== text) {
|
|
94
|
+
t9 = trimText(text, limit);
|
|
95
|
+
$[24] = limit;
|
|
96
|
+
$[25] = text;
|
|
97
|
+
$[26] = t9;
|
|
98
|
+
} else t9 = $[26];
|
|
99
|
+
const trimmed = t9;
|
|
100
|
+
if (trimmed === text) {
|
|
101
|
+
t8 = /* @__PURE__ */ jsx("span", {
|
|
102
|
+
ref,
|
|
103
|
+
className: textClassName,
|
|
104
|
+
"qa-test-id": qaTestId,
|
|
105
|
+
...props,
|
|
106
|
+
children: text
|
|
107
|
+
});
|
|
108
|
+
break bb0;
|
|
109
|
+
}
|
|
110
|
+
const t10 = /* @__PURE__ */ jsx(TooltipTrigger, {
|
|
111
|
+
asChild: true,
|
|
112
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
113
|
+
ref,
|
|
114
|
+
className: textClassName,
|
|
115
|
+
"qa-test-id": qaTestId,
|
|
116
|
+
...props,
|
|
117
|
+
tabIndex: 0,
|
|
118
|
+
children: trimmed
|
|
119
|
+
})
|
|
120
|
+
});
|
|
121
|
+
let t11;
|
|
122
|
+
if ($[27] !== side || $[28] !== text) {
|
|
123
|
+
t11 = /* @__PURE__ */ jsx(TooltipContent, {
|
|
124
|
+
side,
|
|
125
|
+
className: "max-w-xs break-words whitespace-normal",
|
|
126
|
+
children: text
|
|
127
|
+
});
|
|
128
|
+
$[27] = side;
|
|
129
|
+
$[28] = text;
|
|
130
|
+
$[29] = t11;
|
|
131
|
+
} else t11 = $[29];
|
|
132
|
+
let t12;
|
|
133
|
+
if ($[30] !== t10 || $[31] !== t11) {
|
|
134
|
+
t12 = /* @__PURE__ */ jsxs(Tooltip, { children: [t10, t11] });
|
|
135
|
+
$[30] = t10;
|
|
136
|
+
$[31] = t11;
|
|
137
|
+
$[32] = t12;
|
|
138
|
+
} else t12 = $[32];
|
|
139
|
+
t8 = t12;
|
|
140
|
+
break bb0;
|
|
141
|
+
}
|
|
142
|
+
T1 = Tooltip;
|
|
143
|
+
t6 = open;
|
|
144
|
+
if ($[33] === Symbol.for("react.memo_cache_sentinel")) {
|
|
145
|
+
t7 = (next) => {
|
|
146
|
+
const el = spanRef.current;
|
|
147
|
+
setOpen(next && el != null && isOverflowed(el));
|
|
148
|
+
};
|
|
149
|
+
$[33] = t7;
|
|
150
|
+
} else t7 = $[33];
|
|
151
|
+
T0 = TooltipTrigger;
|
|
152
|
+
t5 = true;
|
|
153
|
+
if ($[34] !== ref || $[35] !== text) {
|
|
154
|
+
t3 = (node) => {
|
|
155
|
+
overflowWatchRef.current?.();
|
|
156
|
+
overflowWatchRef.current = null;
|
|
157
|
+
spanRef.current = node;
|
|
158
|
+
assignRef(ref, node);
|
|
159
|
+
if (!node) return;
|
|
160
|
+
if (text.length === 0) {
|
|
161
|
+
setOverflowed(false);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
overflowWatchRef.current = watchWidthOverflow(node, setOverflowed);
|
|
165
|
+
};
|
|
166
|
+
$[34] = ref;
|
|
167
|
+
$[35] = text;
|
|
168
|
+
$[36] = t3;
|
|
169
|
+
} else t3 = $[36];
|
|
170
|
+
t4 = cn(textClassName, "block w-full min-w-0 truncate");
|
|
171
|
+
}
|
|
172
|
+
$[8] = className;
|
|
173
|
+
$[9] = limit;
|
|
174
|
+
$[10] = open;
|
|
175
|
+
$[11] = props;
|
|
176
|
+
$[12] = qaTestId;
|
|
177
|
+
$[13] = ref;
|
|
178
|
+
$[14] = side;
|
|
179
|
+
$[15] = text;
|
|
180
|
+
$[16] = T0;
|
|
181
|
+
$[17] = T1;
|
|
182
|
+
$[18] = t3;
|
|
183
|
+
$[19] = t4;
|
|
184
|
+
$[20] = t5;
|
|
185
|
+
$[21] = t6;
|
|
186
|
+
$[22] = t7;
|
|
187
|
+
$[23] = t8;
|
|
188
|
+
} else {
|
|
189
|
+
T0 = $[16];
|
|
190
|
+
T1 = $[17];
|
|
191
|
+
t3 = $[18];
|
|
192
|
+
t4 = $[19];
|
|
193
|
+
t5 = $[20];
|
|
194
|
+
t6 = $[21];
|
|
195
|
+
t7 = $[22];
|
|
196
|
+
t8 = $[23];
|
|
197
|
+
}
|
|
198
|
+
if (t8 !== Symbol.for("react.early_return_sentinel")) return t8;
|
|
199
|
+
const t9 = overflowed ? 0 : void 0;
|
|
200
|
+
let t10;
|
|
201
|
+
if ($[37] !== props || $[38] !== qaTestId || $[39] !== t3 || $[40] !== t4 || $[41] !== t9 || $[42] !== text) {
|
|
202
|
+
t10 = /* @__PURE__ */ jsx("span", {
|
|
203
|
+
ref: t3,
|
|
204
|
+
className: t4,
|
|
205
|
+
"qa-test-id": qaTestId,
|
|
206
|
+
...props,
|
|
207
|
+
tabIndex: t9,
|
|
208
|
+
children: text
|
|
209
|
+
});
|
|
210
|
+
$[37] = props;
|
|
211
|
+
$[38] = qaTestId;
|
|
212
|
+
$[39] = t3;
|
|
213
|
+
$[40] = t4;
|
|
214
|
+
$[41] = t9;
|
|
215
|
+
$[42] = text;
|
|
216
|
+
$[43] = t10;
|
|
217
|
+
} else t10 = $[43];
|
|
218
|
+
let t11;
|
|
219
|
+
if ($[44] !== T0 || $[45] !== t10 || $[46] !== t5) {
|
|
220
|
+
t11 = /* @__PURE__ */ jsx(T0, {
|
|
221
|
+
asChild: t5,
|
|
222
|
+
children: t10
|
|
223
|
+
});
|
|
224
|
+
$[44] = T0;
|
|
225
|
+
$[45] = t10;
|
|
226
|
+
$[46] = t5;
|
|
227
|
+
$[47] = t11;
|
|
228
|
+
} else t11 = $[47];
|
|
229
|
+
let t12;
|
|
230
|
+
if ($[48] !== side || $[49] !== text) {
|
|
231
|
+
t12 = /* @__PURE__ */ jsx(TooltipContent, {
|
|
232
|
+
side,
|
|
233
|
+
className: "max-w-xs break-words whitespace-normal",
|
|
234
|
+
children: text
|
|
235
|
+
});
|
|
236
|
+
$[48] = side;
|
|
237
|
+
$[49] = text;
|
|
238
|
+
$[50] = t12;
|
|
239
|
+
} else t12 = $[50];
|
|
240
|
+
let t13;
|
|
241
|
+
if ($[51] !== T1 || $[52] !== t11 || $[53] !== t12 || $[54] !== t6 || $[55] !== t7) {
|
|
242
|
+
t13 = /* @__PURE__ */ jsxs(T1, {
|
|
243
|
+
open: t6,
|
|
244
|
+
onOpenChange: t7,
|
|
245
|
+
children: [t11, t12]
|
|
246
|
+
});
|
|
247
|
+
$[51] = T1;
|
|
248
|
+
$[52] = t11;
|
|
249
|
+
$[53] = t12;
|
|
250
|
+
$[54] = t6;
|
|
251
|
+
$[55] = t7;
|
|
252
|
+
$[56] = t13;
|
|
253
|
+
} else t13 = $[56];
|
|
254
|
+
return t13;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
258
|
+
export { TrimText, trimText };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/lib/utils/trim-text.ts
|
|
2
|
+
/**
|
|
3
|
+
* Truncate `text` to `characterLength` characters and append `...` when it does not fit. Mirrors
|
|
4
|
+
* React UI `trimText`: a length below 1 returns `text` unchanged (so a bad limit never collapses to
|
|
5
|
+
* a lone ellipsis), and the kept slice is trimmed before the ellipsis.
|
|
6
|
+
*
|
|
7
|
+
* Truncation is signaled by the return value. When the text was shortened, the result differs from
|
|
8
|
+
* `text` and ends with `...`. When it was not, the result is `text`.
|
|
9
|
+
*/
|
|
10
|
+
function trimText(text = "", characterLength) {
|
|
11
|
+
if (characterLength < 1) return text;
|
|
12
|
+
if (text.length <= characterLength) return text;
|
|
13
|
+
return `${text.substring(0, characterLength).trim()}...`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
export { trimText };
|
package/dist/play-cdn-claude.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const NPM_CDN = "https://cdn.jsdelivr.net/npm/";
|
|
3
3
|
const SHARED = /^(@patterninc\/pattern-ui\/|(react|react-dom|@lingui\/core|@lingui\/react|sucrase)(\/|$))/;
|
|
4
4
|
const scriptUrl = document.currentScript instanceof HTMLScriptElement ? document.currentScript.src : "";
|
|
5
|
-
const packageUrl = new URL("../", scriptUrl || location.href).href.replace(/\/@patterninc\/pattern-ui(@[^/]*)?\//, `/@patterninc/pattern-ui@0.0.0-main.
|
|
5
|
+
const packageUrl = new URL("../", scriptUrl || location.href).href.replace(/\/@patterninc\/pattern-ui(@[^/]*)?\//, `/@patterninc/pattern-ui@0.0.0-main.202610232106.7015de2e4ed0/`);
|
|
6
6
|
const browserUrl = `${packageUrl}dist/browser/`;
|
|
7
7
|
globalThis.patternPlayCdnUrl = (specifier) => {
|
|
8
8
|
if (specifier.endsWith(".css")) return `${browserUrl}${specifier}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patterninc/pattern-ui",
|
|
3
|
-
"version": "0.0.0-main.
|
|
3
|
+
"version": "0.0.0-main.202610232106.7015de2e4ed0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/patterninc/pattern-exp.git",
|
|
@@ -753,6 +753,14 @@
|
|
|
753
753
|
"./tone": {
|
|
754
754
|
"types": "./src/utils/tone-context.tsx",
|
|
755
755
|
"default": "./dist/utils/tone-context.js"
|
|
756
|
+
},
|
|
757
|
+
"./trim-text": {
|
|
758
|
+
"types": "./src/components/TrimText/TrimText.tsx",
|
|
759
|
+
"default": "./dist/components/TrimText/TrimText.js"
|
|
760
|
+
},
|
|
761
|
+
"./trim-text-fn": {
|
|
762
|
+
"types": "./src/lib/utils/trim-text.ts",
|
|
763
|
+
"default": "./dist/lib/utils/trim-text.js"
|
|
756
764
|
}
|
|
757
765
|
},
|
|
758
766
|
"publishConfig": {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { cn } from 'cn'
|
|
2
|
+
import { type ComponentProps, type Ref, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { trimText } from '../../lib/utils/trim-text'
|
|
5
|
+
// Pattern UI Tooltip (same primitive UploadItem uses for a clipped filename). The full string is
|
|
6
|
+
// shown on hover and keyboard focus, and only when the text is actually truncated. A plain span
|
|
7
|
+
// is not in the tab order, and Radix only opens the tooltip when the trigger is focusable, so the
|
|
8
|
+
// truncated span gets tabIndex={0}.
|
|
9
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../Tooltip/Tooltip'
|
|
10
|
+
|
|
11
|
+
export { trimText } from '../../lib/utils/trim-text'
|
|
12
|
+
|
|
13
|
+
export type TrimTextSide = 'top' | 'right' | 'bottom' | 'left'
|
|
14
|
+
|
|
15
|
+
export interface TrimTextProps extends Omit<ComponentProps<'span'>, 'children'> {
|
|
16
|
+
/** The text to display. */
|
|
17
|
+
text: string
|
|
18
|
+
/**
|
|
19
|
+
* Characters to keep before the ellipsis. Omit it, or pass a value below 1, to ellipsize to the
|
|
20
|
+
* container width instead of counting characters.
|
|
21
|
+
*/
|
|
22
|
+
limit?: number
|
|
23
|
+
/**
|
|
24
|
+
* Preferred tooltip side. This is React UI `TrimText`'s `position`, named for Pattern UI
|
|
25
|
+
* `TooltipContent`.
|
|
26
|
+
*
|
|
27
|
+
* @default 'bottom'
|
|
28
|
+
*/
|
|
29
|
+
side?: TrimTextSide
|
|
30
|
+
/** Optional test id rendered as the `qa-test-id` attribute for QA testing */
|
|
31
|
+
qaTestId?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assignRef(ref: Ref<HTMLSpanElement> | undefined, node: HTMLSpanElement | null) {
|
|
35
|
+
if (typeof ref === 'function') ref(node)
|
|
36
|
+
else if (ref) ref.current = node
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isOverflowed(el: HTMLElement): boolean {
|
|
40
|
+
// Integers, so a 1px overflow is already ellipsized. Strict `>` matches React UI TrimText.
|
|
41
|
+
return el.scrollWidth > el.clientWidth
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Width clipping depends on layout, not on props: the span is `w-full`, so its border box follows
|
|
46
|
+
* the container while `scrollWidth` follows the text. ResizeObserver misses a font swap or a
|
|
47
|
+
* letter-spacing change that leaves that box the same size. Measure on the node itself (no effect),
|
|
48
|
+
* again when `document.fonts` settles, and again when the parent is hovered or focused so the tab
|
|
49
|
+
* stop is current before the user tabs onward.
|
|
50
|
+
*/
|
|
51
|
+
function watchWidthOverflow(
|
|
52
|
+
node: HTMLSpanElement,
|
|
53
|
+
onChange: (overflowed: boolean) => void,
|
|
54
|
+
): () => void {
|
|
55
|
+
const measure = () => onChange(isOverflowed(node))
|
|
56
|
+
measure()
|
|
57
|
+
const frame = requestAnimationFrame(measure)
|
|
58
|
+
|
|
59
|
+
const observer = new ResizeObserver(measure)
|
|
60
|
+
observer.observe(node)
|
|
61
|
+
|
|
62
|
+
const parent = node.parentElement
|
|
63
|
+
parent?.addEventListener('focusin', measure)
|
|
64
|
+
parent?.addEventListener('pointerenter', measure)
|
|
65
|
+
|
|
66
|
+
// jsdom has no FontFaceSet. Apps that render TrimText in Vitest stub ResizeObserver only, so a
|
|
67
|
+
// missing `document.fonts` must not throw inside the ref callback.
|
|
68
|
+
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
|
|
69
|
+
fonts?.addEventListener('loadingdone', measure)
|
|
70
|
+
void fonts?.ready.then(() => {
|
|
71
|
+
if (node.isConnected) measure()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
cancelAnimationFrame(frame)
|
|
76
|
+
observer.disconnect()
|
|
77
|
+
parent?.removeEventListener('focusin', measure)
|
|
78
|
+
parent?.removeEventListener('pointerenter', measure)
|
|
79
|
+
fonts?.removeEventListener('loadingdone', measure)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function TrimText({
|
|
84
|
+
text,
|
|
85
|
+
limit,
|
|
86
|
+
side = 'bottom',
|
|
87
|
+
className,
|
|
88
|
+
qaTestId = 'trim-text',
|
|
89
|
+
ref,
|
|
90
|
+
...props
|
|
91
|
+
}: TrimTextProps) {
|
|
92
|
+
const spanRef = useRef<HTMLSpanElement>(null)
|
|
93
|
+
const overflowWatchRef = useRef<(() => void) | null>(null)
|
|
94
|
+
const [open, setOpen] = useState(false)
|
|
95
|
+
const [overflowed, setOverflowed] = useState(false)
|
|
96
|
+
const textClassName = cn('pui-text-body-medium text-text-strong', className)
|
|
97
|
+
|
|
98
|
+
if (limit != null && limit >= 1) {
|
|
99
|
+
const trimmed = trimText(text, limit)
|
|
100
|
+
if (trimmed === text) {
|
|
101
|
+
return (
|
|
102
|
+
<span ref={ref} className={textClassName} qa-test-id={qaTestId} {...props}>
|
|
103
|
+
{text}
|
|
104
|
+
</span>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Tooltip>
|
|
110
|
+
<TooltipTrigger asChild>
|
|
111
|
+
<span
|
|
112
|
+
ref={ref}
|
|
113
|
+
className={textClassName}
|
|
114
|
+
qa-test-id={qaTestId}
|
|
115
|
+
{...props}
|
|
116
|
+
// Truncated text is the tooltip trigger. Radix opens on focus only when it is tabbable.
|
|
117
|
+
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- span trigger, tabbable only while truncated
|
|
118
|
+
tabIndex={0}
|
|
119
|
+
>
|
|
120
|
+
{trimmed}
|
|
121
|
+
</span>
|
|
122
|
+
</TooltipTrigger>
|
|
123
|
+
<TooltipContent side={side} className='max-w-xs break-words whitespace-normal'>
|
|
124
|
+
{text}
|
|
125
|
+
</TooltipContent>
|
|
126
|
+
</Tooltip>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Tooltip
|
|
132
|
+
open={open}
|
|
133
|
+
onOpenChange={(next) => {
|
|
134
|
+
const el = spanRef.current
|
|
135
|
+
setOpen(next && el != null && isOverflowed(el))
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<TooltipTrigger asChild>
|
|
139
|
+
<span
|
|
140
|
+
ref={(node) => {
|
|
141
|
+
overflowWatchRef.current?.()
|
|
142
|
+
overflowWatchRef.current = null
|
|
143
|
+
spanRef.current = node
|
|
144
|
+
assignRef(ref, node)
|
|
145
|
+
if (!node) return
|
|
146
|
+
// `text` is read so a new string re-attaches the watcher. An empty line is not
|
|
147
|
+
// clipped; clear the previous tab stop instead of leaving `overflowed` true.
|
|
148
|
+
if (text.length === 0) {
|
|
149
|
+
setOverflowed(false)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
overflowWatchRef.current = watchWidthOverflow(node, setOverflowed)
|
|
153
|
+
}}
|
|
154
|
+
className={cn(textClassName, 'block w-full min-w-0 truncate')}
|
|
155
|
+
qa-test-id={qaTestId}
|
|
156
|
+
{...props}
|
|
157
|
+
// Only the clipped line is a tab stop. `undefined` drops the attribute when it fits.
|
|
158
|
+
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- span trigger, tabbable only while truncated
|
|
159
|
+
tabIndex={overflowed ? 0 : undefined}
|
|
160
|
+
>
|
|
161
|
+
{text}
|
|
162
|
+
</span>
|
|
163
|
+
</TooltipTrigger>
|
|
164
|
+
<TooltipContent side={side} className='max-w-xs break-words whitespace-normal'>
|
|
165
|
+
{text}
|
|
166
|
+
</TooltipContent>
|
|
167
|
+
</Tooltip>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truncate `text` to `characterLength` characters and append `...` when it does not fit. Mirrors
|
|
3
|
+
* React UI `trimText`: a length below 1 returns `text` unchanged (so a bad limit never collapses to
|
|
4
|
+
* a lone ellipsis), and the kept slice is trimmed before the ellipsis.
|
|
5
|
+
*
|
|
6
|
+
* Truncation is signaled by the return value. When the text was shortened, the result differs from
|
|
7
|
+
* `text` and ends with `...`. When it was not, the result is `text`.
|
|
8
|
+
*/
|
|
9
|
+
export function trimText(text = '', characterLength: number): string {
|
|
10
|
+
if (characterLength < 1) return text
|
|
11
|
+
if (text.length <= characterLength) return text
|
|
12
|
+
return `${text.substring(0, characterLength).trim()}...`
|
|
13
|
+
}
|