diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 6b838180ef..b43b1db6b0 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -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. @@ -55,10 +58,11 @@ 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 @@ -66,6 +70,11 @@ 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 `` (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 diff --git a/design-system/packages/ui/src/components/Listbox/Listbox.tsx b/design-system/packages/ui/src/components/Listbox/Listbox.tsx index 5d404356a4..3732f58337 100644 --- a/design-system/packages/ui/src/components/Listbox/Listbox.tsx +++ b/design-system/packages/ui/src/components/Listbox/Listbox.tsx @@ -242,7 +242,7 @@ export const ListboxOption = forwardRef( )} - {children} + {children} {description !== undefined && description !== null && ( {description} @@ -250,7 +250,7 @@ export const ListboxOption = forwardRef( )} {metadata !== undefined && metadata !== null && ( - {metadata} + {metadata} )}
{skill.source ? ( sourceLabel !== skill.source ? ( diff --git a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx b/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx index 385f468333..d2309a5959 100644 --- a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx +++ b/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Button, Icon } from '@openbitfun/ui'; +import { Button, Icon, OverflowText } from '@openbitfun/ui'; import type { AnnouncementCard } from '../types'; import { useAnnouncementStore } from '../store/announcementStore'; import { useAnnouncementI18n } from '../hooks/useAnnouncementI18n'; @@ -71,7 +71,7 @@ const AnnouncementToastItem: React.FC = ({ card }) => { > {/* Row 1: title + close (with optional countdown ring) */}
-
{resolve(toast.title)}
+ {resolve(toast.title)} {toast.dismissible && (
{autoDismissMs && ( @@ -100,7 +100,7 @@ const AnnouncementToastItem: React.FC = ({ card }) => {
{/* Row 2: description */} -

{resolve(toast.description)}

+ {resolve(toast.description)} {/* Row 3: action buttons */}
diff --git a/src/web-ui/src/shared/context-system/core/types/WebElementContextImpl.tsx b/src/web-ui/src/shared/context-system/core/types/WebElementContextImpl.tsx index 620b92b817..5811f7a3f3 100644 --- a/src/web-ui/src/shared/context-system/core/types/WebElementContextImpl.tsx +++ b/src/web-ui/src/shared/context-system/core/types/WebElementContextImpl.tsx @@ -100,7 +100,7 @@ export class WebElementCardRenderer implements ContextCardRenderer<'web-element' {v && ( <> = - + "{v.length > 20 ? `${v.slice(0, 20)}…` : v}" @@ -110,7 +110,7 @@ export class WebElementCardRenderer implements ContextCardRenderer<'web-element'
)} {context.textContent && ( -
+
{context.textContent.length > 80 ? `${context.textContent.slice(0, 80)}…` : context.textContent} diff --git a/src/web-ui/src/shared/ui/OverflowText.test.tsx b/src/web-ui/src/shared/ui/OverflowText.test.tsx new file mode 100644 index 0000000000..e9f693cf2b --- /dev/null +++ b/src/web-ui/src/shared/ui/OverflowText.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ListboxOption, OverflowText, Select, Tooltip } from '@openbitfun/ui'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('overflow text full-content access', () => { + let host: HTMLDivElement; + let root: Root; + let availableWidth: number; + const resizeCallbacks = new Set<() => void>(); + const longLabel = 'Run independent tasks concurrently whenever possible'; + + const render = (content: React.ReactNode) => act(() => root.render(content)); + const hover = (element: Element) => act(() => { + element.dispatchEvent(new MouseEvent('mouseenter')); + }); + const reveal = () => { + act(() => vi.advanceTimersByTime(500)); + act(() => vi.advanceTimersByTime(20)); + }; + const tooltip = () => document.querySelector('[role="tooltip"]'); + + beforeEach(() => { + vi.useFakeTimers(); + availableWidth = 100; + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-overflow') ? availableWidth : 0; + }); + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-overflow') || this.hasAttribute('data-overflow-content') + ? (this.textContent?.length ?? 0) * 8 + : 0; + }); + vi.stubGlobal('ResizeObserver', class { + constructor(private callback: () => void) { resizeCallbacks.add(callback); } + observe() {} + disconnect() { resizeCallbacks.delete(this.callback); } + }); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + resizeCallbacks.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('shows the complete label from the entire owning control and keeps its click behavior', () => { + const onClick = vi.fn(); + render(); + const button = host.querySelector('button')!; + hover(button); + reveal(); + expect(tooltip()?.textContent).toBe(longLabel); + expect(button.getAttribute('aria-describedby')).toContain(tooltip()!.id); + expect(host.querySelector('[title]')).toBeNull(); + expect(host.querySelector('[tabindex]')).toBeNull(); + act(() => button.click()); + expect(onClick).toHaveBeenCalledOnce(); + expect(tooltip()).toBeNull(); + expect(button.getAttribute('aria-describedby')).toBe('help'); + }); + + it('reveals rich labels and plain-text arrays without waiting for a marquee', () => { + render(); + hover(host.querySelector('button')!); + reveal(); + expect(tooltip()?.textContent).toBe(longLabel); + render(); + expect(tooltip()?.textContent).toBe(longLabel); + }); + + it('opens on keyboard focus, dismisses with Escape, and clears pending opens on blur', () => { + render(); + const button = host.querySelector('button')!; + act(() => button.focus()); + reveal(); + expect(tooltip()?.textContent).toBe(longLabel); + act(() => button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + reveal(); + expect(tooltip()).toBeNull(); + act(() => { button.blur(); button.focus(); button.blur(); }); + reveal(); + expect(tooltip()).toBeNull(); + }); + + it('combines overflowing label and metadata into one tooltip per control', () => { + const metadata = 'A second long metadata string'; + render(); + hover(host.querySelector('button')!); + reveal(); + expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1); + expect(tooltip()?.textContent).toBe(`${longLabel}\n${metadata}`); + }); + + it('cancels a delayed tooltip when the pointer leaves or Escape is pressed before opening', () => { + render(); + const button = host.querySelector('button')!; + hover(button); + act(() => button.dispatchEvent(new MouseEvent('mouseleave'))); + reveal(); + expect(tooltip()).toBeNull(); + act(() => button.focus()); + act(() => button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + reveal(); + expect(tooltip()).toBeNull(); + }); + + it('uses an explicit full title for shortened text and preserves the empty-title opt-out', () => { + render(Run independent tasks...); + hover(host.querySelector('[data-overflow]')!); + reveal(); + expect(tooltip()?.textContent).toBe(longLabel); + render({longLabel}); + hover(host.querySelector('[data-overflow]')!); + reveal(); + expect(tooltip()).toBeNull(); + }); + + it('updates the open text and stops showing a tooltip when it fits after resizing', () => { + render({longLabel}); + hover(host.querySelector('[data-overflow]')!); + reveal(); + const updated = `${longLabel} on the selected host`; + render({updated}); + expect(tooltip()?.textContent).toBe(updated); + availableWidth = 1000; + act(() => resizeCallbacks.forEach(callback => callback())); + expect(tooltip()).toBeNull(); + hover(host.querySelector('[data-overflow]')!); + reveal(); + expect(tooltip()).toBeNull(); + }); + + it('keeps an explicit tooltip as the sole owner of the control', () => { + render( + + ); + act(() => host.querySelector('button')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))); + reveal(); + expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1); + expect(tooltip()?.textContent).toBe('Existing full description'); + }); + + it('detects vertical clipping and preserves multiline paragraph semantics', () => { + availableWidth = 1000; + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(40); + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(120); + render(); + expect(host.querySelector('p')?.getAttribute('data-overflow')).toBe('true'); + expect(host.querySelector('p')?.getAttribute('data-overflow-behavior')).toBe('fade'); + expect(host.querySelector('[data-overflow-content]')).toBeNull(); + hover(host.querySelector('button')!); + reveal(); + expect(tooltip()?.textContent).toBe(longLabel); + }); + + it('leaves short, fully visible labels without a tooltip', () => { + render(); + hover(host.querySelector('button')!); + act(() => host.querySelector('button')!.focus()); + reveal(); + expect(tooltip()).toBeNull(); + }); + + it('reveals a virtually focused listbox option without adding a tab stop', () => { + render({longLabel}); + reveal(); + expect(tooltip()?.textContent).toBe(longLabel); + render({longLabel}); + reveal(); + expect(tooltip()).toBeNull(); + expect(host.querySelector('button')?.tabIndex).toBe(-1); + }); + + it('shows only the hovered option when another option still has keyboard focus', () => { + const other = 'Another long option with a complete description'; + render(<>{longLabel}{other}); + const [first, second] = host.querySelectorAll('button'); + act(() => first.focus()); + reveal(); + hover(second); + reveal(); + expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1); + expect(tooltip()?.textContent).toBe(other); + }); + + it('covers both the settings Select value and its portalled options', () => { + render(