Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Use `OverflowText` for single-line, non-editable labels instead of local
defaults to **fade-out truncation with an interaction marquee**: a background-independent
gradient mask at the inline end, followed by scrolling on hover or keyboard focus.
Both effects apply only when the text actually overflows. Short labels remain untouched.
Overflowing labels also open a wrapping, selectable tooltip on hover or keyboard
focus, including when motion is reduced. The tooltip uses the owning
`data-overflow-trigger` control and groups its clipped text slots into one popup.
Standard button, menu, navigation, card, selection, and disclosure text slots
already use this primitive; consumers should not wrap those slots a second time.

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

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

For a compact multiline preview, use `<OverflowText as="p" lines={2}>` (or `div`
to preserve the existing semantics). It measures vertical clipping as well as
horizontal overflow and exposes the same full-text tooltip. Keep existing
click-to-open details or expansion controls available on touch surfaces.

The Web UI uses this contract in shell/navigation and search, workspace/session
lists, model and context pickers, file/Git lists, settings, tool-card summaries,
usage reports, and the Canvas SDK's truncating text/file labels. Remaining local
Expand Down
4 changes: 2 additions & 2 deletions design-system/packages/ui/src/components/Listbox/Listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,15 +242,15 @@ export const ListboxOption = forwardRef<HTMLButtonElement, ListboxOptionProps>(
</span>
)}
<span className={styles.content} data-openbitfun-part="content">
<OverflowText className={styles.label} data-openbitfun-part="label">{children}</OverflowText>
<OverflowText className={styles.label} data-openbitfun-part="label" marqueeActive={active}>{children}</OverflowText>
{description !== undefined && description !== null && (
<span className={styles.description} data-openbitfun-part="description">
{description}
</span>
)}
</span>
{metadata !== undefined && metadata !== null && (
<OverflowText className={styles.metadata} data-openbitfun-part="metadata">{metadata}</OverflowText>
<OverflowText className={styles.metadata} data-openbitfun-part="metadata" marqueeActive={active}>{metadata}</OverflowText>
)}
<span aria-hidden="true" className={styles.indicator} data-openbitfun-part="indicator">
{indicator ?? (selected ? <Icon name="check-line" /> : null)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
.content {
position: relative;
z-index: 1;
max-inline-size: var(--openbitfun-overlay-tooltip-max-inline-size);
max-inline-size: min(var(--openbitfun-overlay-tooltip-max-inline-size), calc(100vw - 16px));
max-block-size: min(var(--openbitfun-overlay-tooltip-max-block-size), calc(100vh - 24px));
overflow-y: auto;
overscroll-behavior: contain;
Expand All @@ -46,7 +46,8 @@
font-weight: var(--openbitfun-type-support-font-weight);
line-height: var(--openbitfun-type-support-line-height);
letter-spacing: var(--openbitfun-type-support-letter-spacing);
overflow-wrap: break-word;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
box-shadow: var(--openbitfun-shadow-sm);
backdrop-filter: var(--openbitfun-effect-blur-base);
Expand Down
150 changes: 127 additions & 23 deletions design-system/packages/ui/src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ import {
type ReactElement,
type ReactNode,
type Ref,
type RefObject,
} from "react";
import { classNames } from "../../internal/classNames";
import { TooltipTriggerContext } from "../../internal/tooltipTriggerContext";
import { Portal } from "../../overlay/Portal";
import { useDesignSystem } from "../../overlay/useDesignSystem";
import styles from "./Tooltip.module.css";

export type TooltipPlacement = "top" | "bottom" | "left" | "right";
export type TooltipTrigger = "hover" | "click" | "focus";
export type TooltipTrigger = "hover" | "click" | "focus" | "hover-focus";

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

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

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

function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
Expand Down Expand Up @@ -166,6 +175,9 @@ export function Tooltip({
interactive = false,
placement = "top",
trigger = "hover",
triggerRef: externalTriggerRef,
active = false,
onBeforeShow,
}: TooltipProps) {
const designSystem = useDesignSystem();
const resolvedDelayMs = delay ?? designSystem.tooltipDelay ?? DEFAULT_TOOLTIP_DELAY_MS;
Expand All @@ -181,13 +193,15 @@ export function Tooltip({
ready: false,
});
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const internalTriggerRef = useRef<HTMLElement | null>(null);
const triggerRef = externalTriggerRef ?? internalTriggerRef;
const tooltipRef = useRef<HTMLDivElement | null>(null);
const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestMousePositionRef = useRef<{ x: number; y: number } | null>(null);
const recalcFrameRef = useRef<number | null>(null);
const instantRef = useRef(false);
const hideCurrentRef = useRef<() => void>(() => {});

const calculatePosition = useCallback(() => {
if (!tooltipRef.current) return;
Expand Down Expand Up @@ -226,8 +240,11 @@ export function Tooltip({
});
}, [calculatePosition]);

const showTooltip = useCallback((event?: ReactMouseEvent) => {
const showTooltip = useCallback((event?: Pick<MouseEvent, "clientX" | "clientY">) => {
if (disabled) return;
const element = triggerRef.current;
if (!element || element.closest('[hidden], [aria-hidden="true"]')) return;
if (onBeforeShow && !onBeforeShow()) return;
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
Expand All @@ -236,19 +253,24 @@ export function Tooltip({
if (followCursor && event) {
latestMousePositionRef.current = { x: event.clientX, y: event.clientY };
}
const openDelay = trigger === "hover" && Date.now() < tooltipWarmUntil
const openDelay = (trigger === "hover" || trigger === "hover-focus") && Date.now() < tooltipWarmUntil
? 0
: resolvedDelayMs;
instantRef.current = openDelay === 0;
showTimeoutRef.current = setTimeout(() => {
showTimeoutRef.current = null;
if (!element.isConnected || element.closest('[hidden], [aria-hidden="true"]')) return;
if (onBeforeShow && !onBeforeShow()) return;
const previous = activeTooltips.get(element.ownerDocument);
if (previous && previous.id !== tooltipId) previous.hide();
activeTooltips.set(element.ownerDocument, { id: tooltipId, hide: () => hideCurrentRef.current() });
if (followCursor) {
setMousePosition(latestMousePositionRef.current);
}
setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev));
setVisible(true);
}, openDelay);
}, [disabled, followCursor, resolvedDelayMs, trigger]);
}, [disabled, followCursor, onBeforeShow, resolvedDelayMs, tooltipId, trigger, triggerRef]);

const hideTooltip = useCallback(() => {
if (showTimeoutRef.current) {
Expand All @@ -262,16 +284,20 @@ export function Tooltip({
if (visible) {
tooltipWarmUntil = Date.now() + WARM_WINDOW_MS;
}
const ownerDocument = triggerRef.current?.ownerDocument;
if (ownerDocument && activeTooltips.get(ownerDocument)?.id === tooltipId) activeTooltips.delete(ownerDocument);
setVisible(false);
setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev));
if (followCursor) {
latestMousePositionRef.current = null;
setMousePosition(null);
}
}, [followCursor, visible]);
}, [followCursor, tooltipId, triggerRef, visible]);

useEffect(() => { hideCurrentRef.current = hideTooltip; }, [hideTooltip]);

const scheduleHideTooltip = useCallback(() => {
if (!interactive) {
if (!interactive || !visible) {
hideTooltip();
return;
}
Expand All @@ -281,7 +307,7 @@ export function Tooltip({
hideTimeoutRef.current = null;
hideTooltip();
}, INTERACTIVE_HIDE_DELAY_MS);
}, [hideTooltip, interactive]);
}, [hideTooltip, interactive, visible]);

useEffect(() => {
setLayout((prev) => (prev.placement === placement ? prev : { ...prev, placement }));
Expand Down Expand Up @@ -314,26 +340,89 @@ export function Tooltip({
};
}, [visible, followCursor, scheduleCalculatePosition]);

useEffect(() => () => {
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
}, []);
useEffect(() => {
const ownerDocument = triggerRef.current?.ownerDocument;
return () => {
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
if (ownerDocument && activeTooltips.get(ownerDocument)?.id === tooltipId) activeTooltips.delete(ownerDocument);
};
}, [tooltipId, triggerRef]);

// Delegated text slots use the owning button/row for hover and keyboard focus.
// Keep the actual label in place so this also works inside portalled listboxes.
useEffect(() => {
const element = externalTriggerRef?.current;
if (!element) return;
const onEnter = (event: MouseEvent) => {
if (trigger === "hover" || trigger === "hover-focus") showTooltip(event);
};
const onLeave = () => {
if (trigger === "hover-focus" && element.contains(element.ownerDocument.activeElement)) return;
if (trigger === "hover" || trigger === "hover-focus") scheduleHideTooltip();
};
const onFocus = () => {
if (trigger === "focus" || trigger === "hover-focus") showTooltip();
};
const onBlur = (event: FocusEvent) => {
if (event.relatedTarget && element.contains(event.relatedTarget as Node)) return;
if (trigger === "focus" || trigger === "hover-focus") hideTooltip();
};
const onClick = () => {
if (trigger === "click" && !visible) showTooltip();
else hideTooltip();
};
element.addEventListener("mouseenter", onEnter);
element.addEventListener("mouseleave", onLeave);
element.addEventListener("focusin", onFocus);
element.addEventListener("focusout", onBlur);
element.addEventListener("click", onClick);
return () => {
element.removeEventListener("mouseenter", onEnter);
element.removeEventListener("mouseleave", onLeave);
element.removeEventListener("focusin", onFocus);
element.removeEventListener("focusout", onBlur);
element.removeEventListener("click", onClick);
};
}, [externalTriggerRef, hideTooltip, scheduleHideTooltip, showTooltip, trigger, visible]);

// A measured text slot can mount after focus has already reached its owner.
// Only replay focus/virtual activation on a transition, not on visibility updates.
const activationRef = useRef(false);
useEffect(() => {
const element = triggerRef.current;
const activated = !disabled && (active || Boolean(externalTriggerRef
&& element?.contains(element.ownerDocument.activeElement)));
if (activated === activationRef.current) return;
activationRef.current = activated;
if (activated) showTooltip();
else hideTooltip();
}, [active, disabled, externalTriggerRef, hideTooltip, showTooltip, triggerRef]);

useEffect(() => {
const ownerDocument = triggerRef.current?.ownerDocument;
const onEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") hideTooltip();
};
ownerDocument?.addEventListener("keydown", onEscape, true);
return () => ownerDocument?.removeEventListener("keydown", onEscape, true);
}, [hideTooltip, triggerRef]);

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

const handleTriggerRef = useCallback((node: HTMLElement | null) => {
triggerRef.current = node;
internalTriggerRef.current = node;
assignRef(childRef, node);
}, [childRef]);

const handleMouseEnter = (event: ReactMouseEvent) => {
if (trigger === "hover") showTooltip(event);
if (trigger === "hover" || trigger === "hover-focus") showTooltip(event);
(childProps.onMouseEnter as ((event: ReactMouseEvent) => void) | undefined)?.(event);
};

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

Expand Down Expand Up @@ -361,18 +450,31 @@ export function Tooltip({
};

const handleFocus = (event: ReactFocusEvent) => {
if (trigger === "focus") showTooltip();
if (trigger === "focus" || trigger === "hover-focus") showTooltip();
(childProps.onFocus as ((event: ReactFocusEvent) => void) | undefined)?.(event);
};

const handleBlur = (event: ReactFocusEvent) => {
if (trigger === "focus") hideTooltip();
if (trigger === "focus" || trigger === "hover-focus") hideTooltip();
(childProps.onBlur as ((event: ReactFocusEvent) => void) | undefined)?.(event);
};

const isShown = visible && layout.ready;

const triggerElement = cloneElement(children as ReactElement<Record<string, unknown>>, {
useEffect(() => {
const element = externalTriggerRef?.current;
if (!element || !isShown) return;
const descriptions = new Set(element.getAttribute("aria-describedby")?.split(/\s+/).filter(Boolean));
descriptions.add(tooltipId);
element.setAttribute("aria-describedby", [...descriptions].join(" "));
return () => {
const remaining = element.getAttribute("aria-describedby")?.split(/\s+/).filter(id => id && id !== tooltipId) ?? [];
if (remaining.length) element.setAttribute("aria-describedby", remaining.join(" "));
else element.removeAttribute("aria-describedby");
};
}, [externalTriggerRef, isShown, tooltipId]);

const triggerElement = children ? cloneElement(children as ReactElement<Record<string, unknown>>, {
ref: handleTriggerRef,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
Expand All @@ -383,11 +485,13 @@ export function Tooltip({
"aria-describedby": isShown
? [childProps["aria-describedby"], tooltipId].filter(Boolean).join(" ")
: childProps["aria-describedby"],
} as Record<string, unknown>);
} as Record<string, unknown>) : null;

return (
<>
{triggerElement}
<TooltipTriggerContext.Provider value={!disabled}>
{triggerElement}
</TooltipTriggerContext.Provider>
{visible && (
<Portal ownerDocument={triggerRef.current?.ownerDocument}>
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createContext } from "react";

/** An explicit tooltip owns this subtree; text slots should not open another one. */
export const TooltipTriggerContext = createContext(false);
Loading
Loading