@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.
@@ -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 };
@@ -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.202610232103.2cb6e31ce186/`);
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.202610232103.2cb6e31ce186",
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
+ }