Skip to content

Commit 57e0964

Browse files
authored
Merge pull request #2889 from kev1n77/fmy/ui-main
fix(web): reveal full content for truncated text
2 parents 8b71484 + 7e4b896 commit 57e0964

36 files changed

Lines changed: 549 additions & 116 deletions

File tree

‎design-system/packages/ui/README.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Use `OverflowText` for single-line, non-editable labels instead of local
2525
defaults to **fade-out truncation with an interaction marquee**: a background-independent
2626
gradient mask at the inline end, followed by scrolling on hover or keyboard focus.
2727
Both effects apply only when the text actually overflows. Short labels remain untouched.
28+
Overflowing labels also open a wrapping, selectable tooltip on hover or keyboard
29+
focus, including when motion is reduced. The tooltip uses the owning
30+
`data-overflow-trigger` control and groups its clipped text slots into one popup.
2831
Standard button, menu, navigation, card, selection, and disclosure text slots
2932
already use this primitive; consumers should not wrap those slots a second time.
3033

@@ -55,17 +58,23 @@ Rich children default to fade to preserve the label's existing inline compositio
5558
Composite containers keep their icons/actions fixed and give each text slot its
5659
own `OverflowText`. Marquee measures and
5760
translates one inline text span; keep icons, badges, and action buttons outside
58-
it. Complete text stays in the accessibility tree. Clipped string/number labels
59-
get a native title unless the caller supplies one; rich content should use its
60-
own full-text tooltip or detail view. Do not use marquee as the sole way to
61-
access information on touch surfaces.
61+
it. Complete text stays in the accessibility tree. Plain-text arrays and rich
62+
labels use their complete rendered text in the tooltip. A supplied `title`
63+
overrides that text; `title=""` opts out when a surrounding native title owns the
64+
content. An explicit enclosing `Tooltip` suppresses automatic nested tooltips.
65+
Do not use marquee as the sole way to access information on touch surfaces.
6266

6367
Multi-line descriptions should normally wrap. Editable fields, source code,
6468
structured paths that need to preserve their suffix, and native controls keep
6569
their appropriate text treatment instead of receiving a blanket fade rule.
6670
Mobile sheet/page titles and row descriptions wrap for touch access. Tooltips
6771
also wrap: a full-text fallback must not truncate its own content.
6872

73+
For a compact multiline preview, use `<OverflowText as="p" lines={2}>` (or `div`
74+
to preserve the existing semantics). It measures vertical clipping as well as
75+
horizontal overflow and exposes the same full-text tooltip. Keep existing
76+
click-to-open details or expansion controls available on touch surfaces.
77+
6978
The Web UI uses this contract in shell/navigation and search, workspace/session
7079
lists, model and context pickers, file/Git lists, settings, tool-card summaries,
7180
usage reports, and the Canvas SDK's truncating text/file labels. Remaining local

‎design-system/packages/ui/src/components/Listbox/Listbox.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,15 +242,15 @@ export const ListboxOption = forwardRef<HTMLButtonElement, ListboxOptionProps>(
242242
</span>
243243
)}
244244
<span className={styles.content} data-openbitfun-part="content">
245-
<OverflowText className={styles.label} data-openbitfun-part="label">{children}</OverflowText>
245+
<OverflowText className={styles.label} data-openbitfun-part="label" marqueeActive={active}>{children}</OverflowText>
246246
{description !== undefined && description !== null && (
247247
<span className={styles.description} data-openbitfun-part="description">
248248
{description}
249249
</span>
250250
)}
251251
</span>
252252
{metadata !== undefined && metadata !== null && (
253-
<OverflowText className={styles.metadata} data-openbitfun-part="metadata">{metadata}</OverflowText>
253+
<OverflowText className={styles.metadata} data-openbitfun-part="metadata" marqueeActive={active}>{metadata}</OverflowText>
254254
)}
255255
<span aria-hidden="true" className={styles.indicator} data-openbitfun-part="indicator">
256256
{indicator ?? (selected ? <Icon name="check-line" /> : null)}

‎design-system/packages/ui/src/components/Tooltip/Tooltip.module.css‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
.content {
3333
position: relative;
3434
z-index: 1;
35-
max-inline-size: var(--openbitfun-overlay-tooltip-max-inline-size);
35+
max-inline-size: min(var(--openbitfun-overlay-tooltip-max-inline-size), calc(100vw - 16px));
3636
max-block-size: min(var(--openbitfun-overlay-tooltip-max-block-size), calc(100vh - 24px));
3737
overflow-y: auto;
3838
overscroll-behavior: contain;
@@ -46,7 +46,8 @@
4646
font-weight: var(--openbitfun-type-support-font-weight);
4747
line-height: var(--openbitfun-type-support-line-height);
4848
letter-spacing: var(--openbitfun-type-support-letter-spacing);
49-
overflow-wrap: break-word;
49+
white-space: pre-wrap;
50+
overflow-wrap: anywhere;
5051
user-select: text;
5152
box-shadow: var(--openbitfun-shadow-sm);
5253
backdrop-filter: var(--openbitfun-effect-blur-base);

‎design-system/packages/ui/src/components/Tooltip/Tooltip.tsx‎

Lines changed: 127 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ import {
1010
type ReactElement,
1111
type ReactNode,
1212
type Ref,
13+
type RefObject,
1314
} from "react";
1415
import { classNames } from "../../internal/classNames";
16+
import { TooltipTriggerContext } from "../../internal/tooltipTriggerContext";
1517
import { Portal } from "../../overlay/Portal";
1618
import { useDesignSystem } from "../../overlay/useDesignSystem";
1719
import styles from "./Tooltip.module.css";
1820

1921
export type TooltipPlacement = "top" | "bottom" | "left" | "right";
20-
export type TooltipTrigger = "hover" | "click" | "focus";
22+
export type TooltipTrigger = "hover" | "click" | "focus" | "hover-focus";
2123

2224
const DEFAULT_TOOLTIP_DELAY_MS = 450;
2325
const INTERACTIVE_HIDE_DELAY_MS = 400;
@@ -27,6 +29,7 @@ const INTERACTIVE_HIDE_DELAY_MS = 400;
2729
*/
2830
const WARM_WINDOW_MS = 300;
2931
let tooltipWarmUntil = 0;
32+
const activeTooltips = new WeakMap<Document, { id: string; hide: () => void }>();
3033

3134
/** Cursor offset when followCursor: right and down so the tooltip never covers the cursor. */
3235
const CURSOR_OFFSET_X = 12;
@@ -36,7 +39,7 @@ const VIEWPORT_PADDING = 8;
3639

3740
export interface TooltipProps {
3841
/** Single focusable trigger element the tooltip describes. */
39-
children: ReactElement;
42+
children?: ReactElement;
4043
className?: string;
4144
content: ReactNode;
4245
/** Open delay in milliseconds. Falls back to the provider value, then 450ms. */
@@ -49,6 +52,12 @@ export interface TooltipProps {
4952
/** Preferred side of the trigger; flips to the opposite side when space runs out. */
5053
placement?: TooltipPlacement;
5154
trigger?: TooltipTrigger;
55+
/** Bind to an existing control without adding a wrapper or another tab stop. */
56+
triggerRef?: RefObject<HTMLElement | null>;
57+
/** Reveal a virtually focused option, for example in an aria-activedescendant listbox. */
58+
active?: boolean;
59+
/** Refresh lazy content or decline opening when the trigger no longer needs a tooltip. */
60+
onBeforeShow?: () => boolean;
5261
}
5362

5463
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
@@ -166,6 +175,9 @@ export function Tooltip({
166175
interactive = false,
167176
placement = "top",
168177
trigger = "hover",
178+
triggerRef: externalTriggerRef,
179+
active = false,
180+
onBeforeShow,
169181
}: TooltipProps) {
170182
const designSystem = useDesignSystem();
171183
const resolvedDelayMs = delay ?? designSystem.tooltipDelay ?? DEFAULT_TOOLTIP_DELAY_MS;
@@ -181,13 +193,15 @@ export function Tooltip({
181193
ready: false,
182194
});
183195
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
184-
const triggerRef = useRef<HTMLElement | null>(null);
196+
const internalTriggerRef = useRef<HTMLElement | null>(null);
197+
const triggerRef = externalTriggerRef ?? internalTriggerRef;
185198
const tooltipRef = useRef<HTMLDivElement | null>(null);
186199
const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
187200
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
188201
const latestMousePositionRef = useRef<{ x: number; y: number } | null>(null);
189202
const recalcFrameRef = useRef<number | null>(null);
190203
const instantRef = useRef(false);
204+
const hideCurrentRef = useRef<() => void>(() => {});
191205

192206
const calculatePosition = useCallback(() => {
193207
if (!tooltipRef.current) return;
@@ -226,8 +240,11 @@ export function Tooltip({
226240
});
227241
}, [calculatePosition]);
228242

229-
const showTooltip = useCallback((event?: ReactMouseEvent) => {
243+
const showTooltip = useCallback((event?: Pick<MouseEvent, "clientX" | "clientY">) => {
230244
if (disabled) return;
245+
const element = triggerRef.current;
246+
if (!element || element.closest('[hidden], [aria-hidden="true"]')) return;
247+
if (onBeforeShow && !onBeforeShow()) return;
231248
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
232249
if (hideTimeoutRef.current) {
233250
clearTimeout(hideTimeoutRef.current);
@@ -236,19 +253,24 @@ export function Tooltip({
236253
if (followCursor && event) {
237254
latestMousePositionRef.current = { x: event.clientX, y: event.clientY };
238255
}
239-
const openDelay = trigger === "hover" && Date.now() < tooltipWarmUntil
256+
const openDelay = (trigger === "hover" || trigger === "hover-focus") && Date.now() < tooltipWarmUntil
240257
? 0
241258
: resolvedDelayMs;
242259
instantRef.current = openDelay === 0;
243260
showTimeoutRef.current = setTimeout(() => {
244261
showTimeoutRef.current = null;
262+
if (!element.isConnected || element.closest('[hidden], [aria-hidden="true"]')) return;
263+
if (onBeforeShow && !onBeforeShow()) return;
264+
const previous = activeTooltips.get(element.ownerDocument);
265+
if (previous && previous.id !== tooltipId) previous.hide();
266+
activeTooltips.set(element.ownerDocument, { id: tooltipId, hide: () => hideCurrentRef.current() });
245267
if (followCursor) {
246268
setMousePosition(latestMousePositionRef.current);
247269
}
248270
setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev));
249271
setVisible(true);
250272
}, openDelay);
251-
}, [disabled, followCursor, resolvedDelayMs, trigger]);
273+
}, [disabled, followCursor, onBeforeShow, resolvedDelayMs, tooltipId, trigger, triggerRef]);
252274

253275
const hideTooltip = useCallback(() => {
254276
if (showTimeoutRef.current) {
@@ -262,16 +284,20 @@ export function Tooltip({
262284
if (visible) {
263285
tooltipWarmUntil = Date.now() + WARM_WINDOW_MS;
264286
}
287+
const ownerDocument = triggerRef.current?.ownerDocument;
288+
if (ownerDocument && activeTooltips.get(ownerDocument)?.id === tooltipId) activeTooltips.delete(ownerDocument);
265289
setVisible(false);
266290
setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev));
267291
if (followCursor) {
268292
latestMousePositionRef.current = null;
269293
setMousePosition(null);
270294
}
271-
}, [followCursor, visible]);
295+
}, [followCursor, tooltipId, triggerRef, visible]);
296+
297+
useEffect(() => { hideCurrentRef.current = hideTooltip; }, [hideTooltip]);
272298

273299
const scheduleHideTooltip = useCallback(() => {
274-
if (!interactive) {
300+
if (!interactive || !visible) {
275301
hideTooltip();
276302
return;
277303
}
@@ -281,7 +307,7 @@ export function Tooltip({
281307
hideTimeoutRef.current = null;
282308
hideTooltip();
283309
}, INTERACTIVE_HIDE_DELAY_MS);
284-
}, [hideTooltip, interactive]);
310+
}, [hideTooltip, interactive, visible]);
285311

286312
useEffect(() => {
287313
setLayout((prev) => (prev.placement === placement ? prev : { ...prev, placement }));
@@ -314,26 +340,89 @@ export function Tooltip({
314340
};
315341
}, [visible, followCursor, scheduleCalculatePosition]);
316342

317-
useEffect(() => () => {
318-
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
319-
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
320-
}, []);
343+
useEffect(() => {
344+
const ownerDocument = triggerRef.current?.ownerDocument;
345+
return () => {
346+
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
347+
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
348+
if (ownerDocument && activeTooltips.get(ownerDocument)?.id === tooltipId) activeTooltips.delete(ownerDocument);
349+
};
350+
}, [tooltipId, triggerRef]);
351+
352+
// Delegated text slots use the owning button/row for hover and keyboard focus.
353+
// Keep the actual label in place so this also works inside portalled listboxes.
354+
useEffect(() => {
355+
const element = externalTriggerRef?.current;
356+
if (!element) return;
357+
const onEnter = (event: MouseEvent) => {
358+
if (trigger === "hover" || trigger === "hover-focus") showTooltip(event);
359+
};
360+
const onLeave = () => {
361+
if (trigger === "hover-focus" && element.contains(element.ownerDocument.activeElement)) return;
362+
if (trigger === "hover" || trigger === "hover-focus") scheduleHideTooltip();
363+
};
364+
const onFocus = () => {
365+
if (trigger === "focus" || trigger === "hover-focus") showTooltip();
366+
};
367+
const onBlur = (event: FocusEvent) => {
368+
if (event.relatedTarget && element.contains(event.relatedTarget as Node)) return;
369+
if (trigger === "focus" || trigger === "hover-focus") hideTooltip();
370+
};
371+
const onClick = () => {
372+
if (trigger === "click" && !visible) showTooltip();
373+
else hideTooltip();
374+
};
375+
element.addEventListener("mouseenter", onEnter);
376+
element.addEventListener("mouseleave", onLeave);
377+
element.addEventListener("focusin", onFocus);
378+
element.addEventListener("focusout", onBlur);
379+
element.addEventListener("click", onClick);
380+
return () => {
381+
element.removeEventListener("mouseenter", onEnter);
382+
element.removeEventListener("mouseleave", onLeave);
383+
element.removeEventListener("focusin", onFocus);
384+
element.removeEventListener("focusout", onBlur);
385+
element.removeEventListener("click", onClick);
386+
};
387+
}, [externalTriggerRef, hideTooltip, scheduleHideTooltip, showTooltip, trigger, visible]);
388+
389+
// A measured text slot can mount after focus has already reached its owner.
390+
// Only replay focus/virtual activation on a transition, not on visibility updates.
391+
const activationRef = useRef(false);
392+
useEffect(() => {
393+
const element = triggerRef.current;
394+
const activated = !disabled && (active || Boolean(externalTriggerRef
395+
&& element?.contains(element.ownerDocument.activeElement)));
396+
if (activated === activationRef.current) return;
397+
activationRef.current = activated;
398+
if (activated) showTooltip();
399+
else hideTooltip();
400+
}, [active, disabled, externalTriggerRef, hideTooltip, showTooltip, triggerRef]);
401+
402+
useEffect(() => {
403+
const ownerDocument = triggerRef.current?.ownerDocument;
404+
const onEscape = (event: KeyboardEvent) => {
405+
if (event.key === "Escape") hideTooltip();
406+
};
407+
ownerDocument?.addEventListener("keydown", onEscape, true);
408+
return () => ownerDocument?.removeEventListener("keydown", onEscape, true);
409+
}, [hideTooltip, triggerRef]);
321410

322-
const childProps = children.props as Record<string, unknown>;
323-
const childRef = (children as ReactElement & { ref?: Ref<HTMLElement> }).ref;
411+
const childProps = (children?.props ?? {}) as Record<string, unknown>;
412+
const childRef = (children as (ReactElement & { ref?: Ref<HTMLElement> }) | undefined)?.ref;
324413

325414
const handleTriggerRef = useCallback((node: HTMLElement | null) => {
326-
triggerRef.current = node;
415+
internalTriggerRef.current = node;
327416
assignRef(childRef, node);
328417
}, [childRef]);
329418

330419
const handleMouseEnter = (event: ReactMouseEvent) => {
331-
if (trigger === "hover") showTooltip(event);
420+
if (trigger === "hover" || trigger === "hover-focus") showTooltip(event);
332421
(childProps.onMouseEnter as ((event: ReactMouseEvent) => void) | undefined)?.(event);
333422
};
334423

335424
const handleMouseLeave = (event: ReactMouseEvent) => {
336-
if (trigger === "hover") scheduleHideTooltip();
425+
if (trigger === "hover" || (trigger === "hover-focus" && !event.currentTarget.contains(event.currentTarget.ownerDocument.activeElement))) scheduleHideTooltip();
337426
(childProps.onMouseLeave as ((event: ReactMouseEvent) => void) | undefined)?.(event);
338427
};
339428

@@ -361,18 +450,31 @@ export function Tooltip({
361450
};
362451

363452
const handleFocus = (event: ReactFocusEvent) => {
364-
if (trigger === "focus") showTooltip();
453+
if (trigger === "focus" || trigger === "hover-focus") showTooltip();
365454
(childProps.onFocus as ((event: ReactFocusEvent) => void) | undefined)?.(event);
366455
};
367456

368457
const handleBlur = (event: ReactFocusEvent) => {
369-
if (trigger === "focus") hideTooltip();
458+
if (trigger === "focus" || trigger === "hover-focus") hideTooltip();
370459
(childProps.onBlur as ((event: ReactFocusEvent) => void) | undefined)?.(event);
371460
};
372461

373462
const isShown = visible && layout.ready;
374463

375-
const triggerElement = cloneElement(children as ReactElement<Record<string, unknown>>, {
464+
useEffect(() => {
465+
const element = externalTriggerRef?.current;
466+
if (!element || !isShown) return;
467+
const descriptions = new Set(element.getAttribute("aria-describedby")?.split(/\s+/).filter(Boolean));
468+
descriptions.add(tooltipId);
469+
element.setAttribute("aria-describedby", [...descriptions].join(" "));
470+
return () => {
471+
const remaining = element.getAttribute("aria-describedby")?.split(/\s+/).filter(id => id && id !== tooltipId) ?? [];
472+
if (remaining.length) element.setAttribute("aria-describedby", remaining.join(" "));
473+
else element.removeAttribute("aria-describedby");
474+
};
475+
}, [externalTriggerRef, isShown, tooltipId]);
476+
477+
const triggerElement = children ? cloneElement(children as ReactElement<Record<string, unknown>>, {
376478
ref: handleTriggerRef,
377479
onMouseEnter: handleMouseEnter,
378480
onMouseLeave: handleMouseLeave,
@@ -383,11 +485,13 @@ export function Tooltip({
383485
"aria-describedby": isShown
384486
? [childProps["aria-describedby"], tooltipId].filter(Boolean).join(" ")
385487
: childProps["aria-describedby"],
386-
} as Record<string, unknown>);
488+
} as Record<string, unknown>) : null;
387489

388490
return (
389491
<>
390-
{triggerElement}
492+
<TooltipTriggerContext.Provider value={!disabled}>
493+
{triggerElement}
494+
</TooltipTriggerContext.Provider>
391495
{visible && (
392496
<Portal ownerDocument={triggerRef.current?.ownerDocument}>
393497
<div
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createContext } from "react";
2+
3+
/** An explicit tooltip owns this subtree; text slots should not open another one. */
4+
export const TooltipTriggerContext = createContext(false);

0 commit comments

Comments
 (0)