improvement(ui): standardize overflow text (#7175)

* improvement(ui): standardize overflow text

* fix(ui): remeasure overflow text changes

* fix(ui): refresh overflow after font loads

* fix(ui): stabilize overflow font test

* fix(ui): preserve overflow text interactions

* fix(test): update overflow component mocks

* fix(ui): keep overflow tooltips in sync

* fix(ui): preserve overflow server boundary

* test(ui): cover strict mode overflow replay

* fix(ui): restore overflow observers on effect replay

* fix(ui): harden overflow measurement defaults
This commit is contained in:
Waleed
2026-08-27 15:44:44 -07:00
committed by GitHub
parent 474c105f3d
commit 72363b3823
67 changed files with 983 additions and 350 deletions
+1
View File
@@ -32,6 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`ChipDatePicker`** — chip-styled date field.
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, single-line clipping, the conditional 18px edge fade, and the full-value floating tooltip; consumers pass only layout/typography through `className`. Never combine the fade with `truncate`, which paints an ellipsis beneath the mask. Keep ordinary `truncate` for editable or mirrored input values, code/log/path content, dense or virtualized grids, and composite rows where masking the container would also fade icons or actions. Multiline copy uses an intentional `line-clamp-*` treatment instead. A non-editable `Combobox` visual overlay passes its plain value through `overlayLabel`; render its visible `OverflowText` as a constrained block with `tooltipEnabled={false}` so the interactive trigger owns the single accessible tooltip.
## Modal keyboard defaults
+8
View File
@@ -50,6 +50,14 @@ Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`=
Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`.
## Text Overflow
Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, single-line clipping, the conditional edge fade, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`, which leaves an ellipsis beneath the mask. Pass the full label to this component instead of shortening it in JavaScript first.
For a non-editable `Combobox` visual overlay, pass the same plain value as `overlayLabel` and render the visible `OverflowText` with `block w-full` (or `block flex-1` beside an icon) plus `tooltipEnabled={false}`. The transparent interactive layer then owns the one reachable full-value tooltip while the visual layer owns the measured fade.
Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment.
## Font Weight
Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body.
+8
View File
@@ -44,6 +44,14 @@ Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`=
Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`.
## Text Overflow
Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, single-line clipping, the conditional edge fade, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`, which leaves an ellipsis beneath the mask. Pass the full label to this component instead of shortening it in JavaScript first.
For a non-editable `Combobox` visual overlay, pass the same plain value as `overlayLabel` and render the visible `OverflowText` with `block w-full` (or `block flex-1` beside an icon) plus `tooltipEnabled={false}`. The transparent interactive layer then owns the one reachable full-value tooltip while the visual layer owns the measured fade.
Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment.
## Color Tokens
Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; borders `--border-1` (fields) / `--border` (dividers); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
+2 -2
View File
@@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
import { Chip } from '@sim/emcn'
import { Chip, OverflowText } from '@sim/emcn'
import { Download } from '@sim/emcn/icons'
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -85,7 +85,7 @@ export function PublicFileView({
</>
)}
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{name}</span>
<OverflowText label={name} className='text-[var(--text-body)] text-sm' />
{provenance ? (
<span className='truncate text-[12px] text-[var(--text-muted)]'>{provenance}</span>
) : null}
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
import { cn, OverflowText } from '@sim/emcn'
interface ConversationListItemProps {
title: string
@@ -23,7 +23,7 @@ export function ConversationListItem({
const showStatusDot = isActive || isUnread
return (
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
<span className={cn('min-w-0 flex-1 truncate', titleClassName)}>{title}</span>
<OverflowText label={title} className={cn('flex-1', titleClassName)} />
{showStatusDot && (
<span
aria-hidden='true'
@@ -1,51 +1,3 @@
'use client'
import type React from 'react'
import { memo } from 'react'
import { cn, FloatingTooltip, isTextClipped, useFloatingTooltip, useIsOverflowing } from '@sim/emcn'
interface FloatingOverflowTextProps {
/** Full text shown in the tooltip and used as the default visible content. */
label: string
/** Optional custom visible content (e.g. highlighted text); defaults to `label`. */
children?: React.ReactNode
className?: string
/** Forces the tooltip even when the text is not visually clipped (e.g. content truncated upstream). */
showWhen?: boolean
}
/**
* Truncating text that fades its clipped edge and reveals the full value in a
* pointer-reactive floating tooltip on hover or focus.
*/
export const FloatingOverflowText = memo(function FloatingOverflowText({
label,
children,
className,
showWhen,
}: FloatingOverflowTextProps) {
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
const { state, handlers } = useFloatingTooltip(() => {
const element = node.current
if (!element || label.length === 0) return false
return Boolean(showWhen) || isTextClipped(element)
})
return (
<>
<span
ref={textRef}
className={cn(
'min-w-0',
isOverflowing &&
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] hover:[mask-image:none] focus-visible:[mask-image:none]',
className
)}
{...handlers}
>
{children ?? label}
</span>
<FloatingTooltip label={label} state={state} />
</>
)
})
export { OverflowText as FloatingOverflowText } from '@sim/emcn'
@@ -24,6 +24,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
FloatingTooltip,
OverflowText,
overflowTextFadeClass,
POPOVER_ANIMATION_CLASSES,
Popover,
PopoverAnchor,
@@ -664,7 +666,7 @@ function BreadcrumbLocationItem({
<span className='size-1.5 rounded-full bg-[var(--text-muted)]' />
)}
</span>
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
<OverflowText label={label} className='flex-1 text-left' />
</>
)
@@ -702,8 +704,9 @@ const BreadcrumbLabel = memo(
ref={ref}
className={cn(
'min-w-0 truncate text-[var(--text-body)]',
isOverflowing && overflowTextFadeClass,
isOverflowing &&
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] group-hover:[mask-image:none] group-focus-visible:[mask-image:none]'
'group-hover:[-webkit-mask-image:none] group-hover:[mask-image:none] group-focus-visible:[-webkit-mask-image:none] group-focus-visible:[mask-image:none]'
)}
>
{label}
@@ -35,7 +35,7 @@ afterEach(() => {
})
describe('SortDropdown', () => {
it('renders long option labels in the truncatable span between both icons', () => {
it('renders long option labels in the overflow span between both icons', () => {
act(() => {
root.render(
<SortDropdown
@@ -63,7 +63,8 @@ describe('SortDropdown', () => {
const label = item?.querySelector('span')
expect(label).not.toBeNull()
expect(label).toHaveTextContent(LONG_COLUMN_LABEL)
expect(label).toHaveClass('min-w-0', 'block', 'truncate')
expect(label).toHaveClass('min-w-0', 'block', 'overflow-hidden', 'text-clip')
expect(label).not.toHaveClass('truncate')
expect(item?.querySelector('[data-testid="column-icon"]')).not.toBeNull()
expect(item?.querySelectorAll('svg')).toHaveLength(2)
})
@@ -232,7 +232,7 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
active={search.highlightedTagIndex === i}
className='max-w-[280px] shrink-0'
>
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block truncate'>
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block'>
{tag.label}: {tag.value}
</FloatingOverflowText>
</Chip>
@@ -330,7 +330,7 @@ export const SortDropdown = memo(function SortDropdown({
}}
>
{Icon && <Icon />}
<FloatingOverflowText label={option.label} className='block truncate' />
<FloatingOverflowText label={option.label} className='block' />
{DirectionIcon && (
<DirectionIcon className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
)}
@@ -11,6 +11,7 @@ import {
Folder,
FolderPlus,
Loader,
OverflowText,
Pencil,
Plus,
Trash,
@@ -1968,8 +1969,13 @@ export function Files() {
multiSelect
multiSelectValues={typeFilter}
onMultiSelectChange={setTypeFilter}
overlayLabel={typeDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{typeDisplayLabel}</span>
<OverflowText
label={typeDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
showAllOption
allOptionLabel='All'
@@ -1987,8 +1993,13 @@ export function Files() {
multiSelect
multiSelectValues={sizeFilter}
onMultiSelectChange={setSizeFilter}
overlayLabel={sizeDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{sizeDisplayLabel}</span>
<OverflowText
label={sizeDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
showAllOption
allOptionLabel='All'
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn'
import { ShimmerText } from '@/components/ui'
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
@@ -209,7 +209,7 @@ export function AgentGroup({
{isWorking ? (
<ShimmerText className='min-w-0 truncate text-sm'>{headerText}</ShimmerText>
) : (
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>{headerText}</span>
<OverflowText label={headerText} className='text-[var(--text-body)] text-sm' />
)}
<ChevronDown
className={cn(
@@ -226,7 +226,7 @@ export function AgentGroup({
{isWorking ? (
<ShimmerText className='min-w-0 truncate text-sm'>{headerText}</ShimmerText>
) : (
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>{headerText}</span>
<OverflowText label={headerText} className='text-[var(--text-body)] text-sm' />
)}
</div>
)}
@@ -1,7 +1,7 @@
'use client'
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn'
import { Button, OverflowText, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn'
import {
Download,
FileX,
@@ -782,7 +782,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
className='flex items-center gap-2 rounded-[6px] px-3 py-2 text-left transition-colors hover:bg-[var(--surface-4)]'
>
<WorkflowIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[13px] text-[var(--text-primary)]'>{w.name}</span>
<OverflowText label={w.name} className='text-[var(--text-primary)] text-small' />
</button>
))}
</div>
@@ -1,7 +1,7 @@
'use client'
import type { ElementType, ReactNode } from 'react'
import { cn } from '@sim/emcn'
import { cn, OverflowText } from '@sim/emcn'
import {
Connections,
Database,
@@ -54,13 +54,13 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
return (
<>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
<OverflowText label={item.name} />
</>
)
}
function DefaultDropdownItem({ item }: DropdownItemRenderProps) {
return <span className='truncate'>{item.name}</span>
return <OverflowText label={item.name} />
}
function FileDropdownItem({ item }: DropdownItemRenderProps) {
@@ -68,7 +68,7 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
return (
<>
<DocIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
<OverflowText label={item.name} />
</>
)
}
@@ -77,7 +77,7 @@ function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon
return (
<>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
<OverflowText label={item.name} />
</>
)
}
@@ -90,11 +90,11 @@ function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon
*/
function IntegrationDropdownItem({ item }: DropdownItemRenderProps) {
const Icon = item.iconComponent as StyleableIcon | undefined
if (!Icon) return <span className='truncate'>{item.name}</span>
if (!Icon) return <OverflowText label={item.name} />
return (
<>
<BrandIcon icon={Icon} className='size-[14px] flex-shrink-0' />
<span className='truncate'>{item.name}</span>
<OverflowText label={item.name} />
</>
)
}
@@ -115,7 +115,7 @@ function LogDropdownItem({ item }: DropdownItemRenderProps) {
return (
<>
<Library className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{workflowName}</span>
<OverflowText label={workflowName} />
{statusColor && (
<div
aria-hidden
@@ -1,7 +1,14 @@
'use client'
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import { Badge, ChipCombobox, ChipConfirmModal, chipContentLabelClass, cn } from '@sim/emcn'
import {
Badge,
ChipCombobox,
ChipConfirmModal,
chipContentLabelClass,
cn,
OverflowText,
} from '@sim/emcn'
import {
ChevronDown,
ChevronUp,
@@ -746,8 +753,13 @@ export function Document({
setEnabledFilter(values)
setSelectedChunks(new Set())
}}
overlayLabel={enabledDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{enabledDisplayLabel}</span>
<OverflowText
label={enabledDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
showAllOption
allOptionLabel='All'
@@ -18,6 +18,7 @@ import {
type ComboboxOption,
cn,
handleKeyboardActivation,
OverflowText,
Search,
} from '@sim/emcn'
import { ArrowLeft, Plus } from '@sim/emcn/icons'
@@ -508,8 +509,11 @@ function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
</div>
</div>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[var(--text-body)] text-sm'>{config.name}</span>
<span className='truncate text-[var(--text-muted)] text-caption'>{config.description}</span>
<OverflowText label={config.name} className='text-[var(--text-body)] text-sm' />
<OverflowText
label={config.description}
className='text-[var(--text-muted)] text-caption'
/>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</button>
@@ -60,6 +60,9 @@ vi.mock('@sim/emcn', () => ({
DropdownMenuContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => (
<span>{children ?? label}</span>
),
Tooltip: {
Root: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Trigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
@@ -11,6 +11,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
OverflowText,
Tooltip,
} from '@sim/emcn'
import {
@@ -362,7 +363,7 @@ function ConnectorCard({
<div className='flex min-w-0 flex-col gap-0.5'>
<div className='flex min-w-0 items-center gap-2'>
<span className='flex min-w-0 items-center gap-1.5 text-[var(--text-primary)] text-small'>
<span className='truncate'>{connectorDef?.name || connector.connectorType}</span>
<OverflowText label={connectorDef?.name || connector.connectorType} />
{syncInFlight && <Loader className='size-3 text-[var(--text-muted)]' animate />}
</span>
<Badge variant={statusConfig.variant} size='sm' dot className='flex-shrink-0'>
@@ -401,7 +401,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>Runs</span>
<span className='min-w-0 text-[var(--text-primary)] text-sm'>Runs</span>
{globalDetails && (
<span className='flex-shrink-0 text-[var(--text-secondary)] text-sm'>
{globalDetails.totalRuns}
@@ -426,7 +426,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>Errors</span>
<span className='min-w-0 text-[var(--text-primary)] text-sm'>Errors</span>
{globalDetails && (
<span className='flex-shrink-0 text-[var(--text-secondary)] text-sm'>
{globalDetails.totalErrors}
@@ -451,7 +451,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>Latency</span>
<span className='min-w-0 text-[var(--text-primary)] text-sm'>Latency</span>
{globalDetails && (
<span className='flex-shrink-0 text-[var(--text-secondary)] text-sm'>
{formatLatency(globalDetails.avgLatency)}
@@ -18,6 +18,7 @@ import {
type ComboboxOption,
cn,
Library,
OverflowText,
Popover,
PopoverAnchor,
PopoverContent,
@@ -1463,15 +1464,20 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
multiSelectValues={selectedStatuses}
onMultiSelectChange={handleStatusChange}
placeholder='All statuses'
overlayLabel={statusDisplayLabel}
overlayContent={
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
<span className='flex w-full min-w-0 items-center gap-1.5 text-[var(--text-primary)]'>
{selectedStatusColor && (
<div
className='flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
/>
)}
<span className='truncate'>{statusDisplayLabel}</span>
<OverflowText
label={statusDisplayLabel}
className='block flex-1'
tooltipEnabled={false}
/>
</span>
}
showAllOption
@@ -1488,12 +1494,17 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
multiSelectValues={workflowIds}
onMultiSelectChange={setWorkflowIds}
placeholder='All workflows'
overlayLabel={workflowDisplayLabel}
overlayContent={
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
<span className='flex w-full min-w-0 items-center gap-1.5 text-[var(--text-primary)]'>
{selectedWorkflow && (
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
)}
<span className='truncate'>{workflowDisplayLabel}</span>
<OverflowText
label={workflowDisplayLabel}
className='block flex-1'
tooltipEnabled={false}
/>
</span>
}
searchable
@@ -1512,8 +1523,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
multiSelectValues={folderIds}
onMultiSelectChange={setFolderIds}
placeholder='All folders'
overlayLabel={folderDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
<OverflowText
label={folderDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
searchable
searchPlaceholder='Search folders...'
@@ -1531,8 +1547,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
multiSelectValues={triggers}
onMultiSelectChange={setTriggers}
placeholder='All triggers'
overlayLabel={triggerDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
<OverflowText
label={triggerDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
searchable
searchPlaceholder='Search triggers...'
@@ -1550,8 +1571,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
value={timeRange}
onChange={handleTimeRangeChange}
placeholder='All time'
overlayLabel={timeDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
<OverflowText
label={timeDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
className='w-full'
maxHeight={320}
@@ -1627,7 +1653,7 @@ function SuggestionButton({
}}
>
<div className='flex w-full items-center justify-between gap-3'>
<div className='min-w-0 flex-1 truncate text-small'>{suggestion.label}</div>
<OverflowText label={suggestion.label} className='flex-1 text-small' />
{showCategory && suggestion.value !== suggestion.label && (
<div className='shrink-0 font-mono text-[var(--text-muted)] text-xs'>
{suggestion.category === 'workflow' || suggestion.category === 'folder'
@@ -65,14 +65,14 @@ function ActivityLogRow({
</span>
<span className='min-w-0 flex-1 text-[var(--text-primary)] text-small'>
{typeof entry.description === 'string' ? (
<FloatingOverflowText label={entry.description} className='block truncate' />
<FloatingOverflowText label={entry.description} className='block' />
) : (
entry.description
)}
</span>
<span className='flex w-[160px] flex-shrink-0 items-center justify-end gap-1.5 text-[var(--text-secondary)] text-small'>
{typeof entry.actor === 'string' ? (
<FloatingOverflowText label={entry.actor} className='block min-w-0 truncate' />
<FloatingOverflowText label={entry.actor} className='block' />
) : (
<span className='min-w-0 truncate'>{entry.actor}</span>
)}
@@ -11,6 +11,7 @@ import {
ChipModalField,
ChipSelect,
Label,
OverflowText,
Search,
Switch,
toast,
@@ -219,8 +220,8 @@ export function Admin() {
const renderUserRow = (u: AdminUser) => (
<div key={u.id} className='flex items-center gap-3 px-3 py-2 text-small'>
<span className='w-[170px] truncate text-[var(--text-primary)]'>{u.name || '—'}</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
<OverflowText label={u.name || '—'} className='w-[170px] text-[var(--text-primary)]' />
<OverflowText label={u.email} className='flex-1 text-[var(--text-secondary)]' />
<span className='w-[60px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
</span>
@@ -48,6 +48,9 @@ vi.mock('@sim/emcn', () => ({
Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
<label htmlFor={htmlFor}>{children}</label>
),
OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => (
<span>{children ?? label}</span>
),
Switch: ({
checked,
disabled,
@@ -8,6 +8,7 @@ import {
chipVariants,
cn,
Label,
OverflowText,
Switch,
Tooltip,
toast,
@@ -480,8 +481,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
</div>
</div>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-body)] text-sm'>{planTitle}</span>
<span className='truncate text-[var(--text-muted)] text-caption'>{priceText}</span>
<OverflowText label={planTitle} className='text-[var(--text-body)] text-sm' />
<OverflowText label={priceText} className='text-[var(--text-muted)] text-caption' />
</div>
</div>
{!subscription.isEnterprise &&
@@ -34,6 +34,9 @@ vi.mock('@sim/emcn', () => ({
</div>
) : null,
Key: () => <span />,
OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => (
<span>{children ?? label}</span>
),
Plus: () => <span />,
toast: mockToast,
}))
@@ -1,6 +1,13 @@
'use client'
import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader } from '@sim/emcn'
import {
Chip,
ChipModal,
ChipModalBody,
ChipModalFooter,
ChipModalHeader,
OverflowText,
} from '@sim/emcn'
import type {
BYOKManagerCapabilities,
BYOKManagerKey,
@@ -53,12 +60,14 @@ export function BYOKProviderKeysModal({
{keys.map((key) => (
<div key={key.id} className='flex items-center justify-between gap-2.5'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate text-[var(--text-body)] text-sm'>
{key.name ?? 'Unnamed key'}
</span>
<span className='truncate font-mono text-[var(--text-muted)] text-caption'>
{key.maskedKey}
</span>
<OverflowText
label={key.name ?? 'Unnamed key'}
className='text-[var(--text-body)] text-sm'
/>
<OverflowText
label={key.maskedKey}
className='font-mono text-[var(--text-muted)] text-caption'
/>
</div>
{(capabilities.update || capabilities.delete) && (
<div className='flex flex-shrink-0 items-center gap-2'>
@@ -1,11 +1,11 @@
'use client'
import type { ReactNode } from 'react'
import { OverflowText } from '@sim/emcn'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
const ROW_CLASSES = 'flex items-center gap-2.5 p-2'
const ROW_EMAIL_CLASSES = 'min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'
const ROW_STATUS_CLASSES = 'flex-shrink-0 text-[var(--text-muted)] text-caption'
interface MemberAvatarProps {
@@ -57,7 +57,7 @@ export function MemberRow({ name, email, image, status, roleControl, menu }: Mem
return (
<div className={ROW_CLASSES}>
<MemberAvatar name={name} image={image} />
<span className={ROW_EMAIL_CLASSES}>{email}</span>
<OverflowText label={email} className='flex-1 text-[var(--text-body)] text-sm' />
<span className={ROW_STATUS_CLASSES}>{status}</span>
{roleControl}
{menu}
@@ -1,5 +1,5 @@
import { type ReactNode, useId } from 'react'
import { cn } from '@sim/emcn'
import { cn, OverflowText } from '@sim/emcn'
import { ArrowRight } from '@sim/emcn/icons'
import Link from 'next/link'
import {
@@ -64,7 +64,7 @@ interface SettingsResourceRowProps {
*/
badge?: ReactNode
/**
* Makes the whole row activatable via a stretched overlay button. `trailing`
* Makes the whole row activatable via a control with a stretched hit area. `trailing`
* stacks above it, so interactive trailing controls (menus, chips) keep
* working never nest an interactive `trailing` inside a caller-supplied
* wrapper `<button>`, which is invalid HTML.
@@ -137,6 +137,7 @@ export function SettingsResourceRow({
}: SettingsResourceRowProps) {
const describedById = useId()
const isTile = iconVariant === 'tile'
const isActivatable = !disabled && Boolean(onClick || href)
const cluster = (
<>
{icon == null ? null : iconVariant === 'custom' ? (
@@ -156,13 +157,29 @@ export function SettingsResourceRow({
{icon}
</div>
)}
<div className='flex min-w-0 flex-col justify-center gap-[1px] text-left'>
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
{description != null && (
<span id={describedById} className='truncate text-[var(--text-muted)] text-caption'>
{description}
</span>
<div className='relative z-10 flex min-w-0 flex-col justify-center gap-[1px] text-left'>
{typeof title === 'string' ? (
<OverflowText
label={title}
className='text-[var(--text-body)] text-sm'
focusTarget={isActivatable ? 'nearest-interactive' : undefined}
/>
) : (
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
)}
{description != null &&
(typeof description === 'string' ? (
<span id={describedById} className='min-w-0'>
<OverflowText
label={description}
className='block text-[var(--text-muted)] text-caption'
/>
</span>
) : (
<span id={describedById} className='truncate text-[var(--text-muted)] text-caption'>
{description}
</span>
))}
</div>
</>
)
@@ -174,7 +191,7 @@ export function SettingsResourceRow({
// Decoration and the chevron stay click-through so the row's right edge never
// becomes a dead zone; only `trailing` takes pointer events back.
const end = hasEnd ? (
<div className='pointer-events-none relative flex flex-shrink-0 items-center gap-2'>
<div className='pointer-events-none relative z-20 flex flex-shrink-0 items-center gap-2'>
{badge}
{trailing != null && <div className='pointer-events-auto flex items-center'>{trailing}</div>}
{navigable && <ArrowRight className={RESOURCE_ROW_ARROW_CLASSES} />}
@@ -198,14 +215,13 @@ export function SettingsResourceRow({
)
}
// The ring renders on the stretched overlay, which is inset-0 over the row — so a
// keyboard focus outline traces the visible row even though the control is empty.
const overlayClass =
'absolute inset-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]'
const controlClass = cn(
clusterClass,
'min-w-0 flex-1 cursor-pointer focus-visible:outline-none',
'after:absolute after:inset-0 after:rounded-lg after:content-[""]',
'focus-visible:after:ring-2 focus-visible:after:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]'
)
// The hit area is a stretched overlay rather than a wrapper around the cluster:
// it lets the hover band span the full row (matching every hand-rolled settings
// list) while `trailing` — which may hold its own buttons — stacks above it.
return (
<div
className={cn(
@@ -218,18 +234,21 @@ export function SettingsResourceRow({
href={href}
aria-label={clickLabel}
aria-describedby={description != null ? describedById : undefined}
className={overlayClass}
/>
className={controlClass}
>
{cluster}
</Link>
) : (
<button
type='button'
onClick={onClick}
aria-label={clickLabel}
aria-describedby={description != null ? describedById : undefined}
className={overlayClass}
/>
className={controlClass}
>
{cluster}
</button>
)}
<div className={cn(clusterClass, 'pointer-events-none')}>{cluster}</div>
{end}
</div>
)
@@ -10,6 +10,7 @@ import {
ChipConfirmModal,
ChipInput,
cn,
OverflowText,
Search,
Skeleton,
} from '@sim/emcn'
@@ -182,18 +183,20 @@ export function TransferOwnershipDialog({
</Avatar>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<span className='truncate text-[var(--text-primary)] text-small'>
{m.name}
</span>
<OverflowText
label={m.name}
className='text-[var(--text-primary)] text-small'
/>
{m.role === 'admin' && (
<Badge variant='gray-secondary' size='sm'>
Admin
</Badge>
)}
</div>
<div className='truncate text-[var(--text-muted)] text-caption'>
{m.email}
</div>
<OverflowText
label={m.email}
className='block text-[var(--text-muted)] text-caption'
/>
</div>
</button>
</li>
@@ -63,7 +63,7 @@ export function SecretUsagePanel({ workspaceId, secretName, scope }: SecretUsage
*/
description: (
<span className='flex min-w-0 items-center gap-2'>
<FloatingOverflowText label={usedBy(entry)} className='block truncate' />
<FloatingOverflowText label={usedBy(entry)} className='block' />
{entry.useCount > 1 && (
<span className='flex-shrink-0 text-[var(--text-muted)]'>{entry.useCount} runs</span>
)}
@@ -9,6 +9,7 @@ import {
CollapsibleCard,
FieldDivider,
Label,
OverflowText,
Switch,
toast,
} from '@sim/emcn'
@@ -242,7 +243,7 @@ export function EnrichmentConfig({
>
<ArrowLeft className='size-[14px]' />
</Button>
<h2 className='truncate text-[var(--text-primary)] text-small'>{enrichment.name}</h2>
<OverflowText label={enrichment.name} className='text-[var(--text-primary)] text-small' />
</div>
<Button
variant='ghost'
@@ -1,6 +1,6 @@
'use client'
import { Badge, cn } from '@sim/emcn'
import { Badge, cn, OverflowText } from '@sim/emcn'
import type { ColumnDefinition, SelectOption } from '@/lib/table'
/** Reads the raw stored option ids from a cell value (single string or array). */
@@ -38,7 +38,7 @@ interface SelectPillProps {
export function SelectPill({ option, size = 'sm', className }: SelectPillProps) {
return (
<Badge variant='gray' size={size} className={cn('max-w-full', className)}>
<span className='truncate'>{option.name}</span>
<OverflowText label={option.name} />
</Badge>
)
}
@@ -8,6 +8,7 @@ import {
chipContentLabelClass,
chipVariants,
cn,
OverflowText,
POPOVER_ANIMATION_CLASSES,
Popover,
PopoverAnchor,
@@ -197,7 +198,7 @@ export const ViewsMenu = memo(function ViewsMenu({
<span className='flex size-[14px] shrink-0 items-center justify-center'>
<Plus className='size-3 text-[var(--text-icon)]' />
</span>
<span className='min-w-0 flex-1 truncate text-left'>New view</span>
<OverflowText label='New view' className='flex-1 text-left' />
</PopoverItem>
</>
)}
@@ -268,7 +269,7 @@ function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowPr
<span className='flex size-[14px] shrink-0 items-center justify-center'>
{isActive && <Check className='size-3 text-[var(--text-icon)]' />}
</span>
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
<OverflowText label={label} className='flex-1 text-left' />
{actionCount > 0 && (
<span
aria-hidden
@@ -13,6 +13,7 @@ import {
FieldDivider,
Label,
Loader,
OverflowText,
Switch,
Tooltip,
toast,
@@ -763,7 +764,9 @@ export function WorkflowSidebarBody({
<ArrowLeft className='size-[14px]' />
</Button>
)}
<h2 className='truncate text-[var(--text-primary)] text-small'>{title}</h2>
<h2 className='flex min-w-0'>
<OverflowText label={title} className='text-[var(--text-primary)] text-small' />
</h2>
</div>
<Button
variant='ghost'
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ComboboxOption } from '@sim/emcn'
import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn'
import { ChipCombobox, ChipConfirmModal, OverflowText, Plus, toast, Upload } from '@sim/emcn'
import { Columns3, FolderPlus, Pencil, Rows3, Table as TableIcon, Trash } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
@@ -692,8 +692,13 @@ export function Tables() {
multiSelect
multiSelectValues={rowCountFilter}
onMultiSelectChange={setRowCountFilter}
overlayLabel={rowCountDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{rowCountDisplayLabel}</span>
<OverflowText
label={rowCountDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
showAllOption
allOptionLabel='All'
@@ -708,8 +713,13 @@ export function Tables() {
multiSelect
multiSelectValues={ownerFilter}
onMultiSelectChange={setOwnerFilter}
overlayLabel={ownerDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span>
<OverflowText
label={ownerDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
searchable
searchPlaceholder='Search members...'
@@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn'
import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn, OverflowText } from '@sim/emcn'
import { useShallow } from 'zustand/react/shallow'
import {
type FlattenOutputsBlockInput,
@@ -269,8 +269,13 @@ export function OutputSelect({
multiSelectValues={normalizedSelectedValues}
onMultiSelectChange={onOutputSelect}
placeholder={selectedDisplayText}
overlayLabel={selectedDisplayText}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{selectedDisplayText}</span>
<OverflowText
label={selectedDisplayText}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
disabled={disabled || workflowOutputs.length === 0}
align={align}
@@ -5,6 +5,7 @@ import {
Button,
cn,
Input,
OverflowText,
Popover,
PopoverContent,
PopoverItem,
@@ -296,7 +297,7 @@ export function Versions({
<span className='shrink-0 text-[var(--text-tertiary)] tabular-nums'>
v{v.version}
</span>
{v.name && <span className='truncate'>{v.name}</span>}
{v.name && <OverflowText label={v.name} />}
{rowLabel && (
<span className='shrink-0 text-[var(--text-tertiary)]'>({rowLabel})</span>
)}
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import {
cn,
OverflowText,
Popover,
PopoverAnchor,
PopoverContent,
@@ -442,7 +443,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
}
}}
>
<span className='flex-1 truncate'>{currentNestedTag.display}</span>
<OverflowText label={currentNestedTag.display} className='flex-1' />
</PopoverItem>
)}
@@ -482,7 +483,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
}
}}
>
<span className='flex-1 truncate'>{child.display}</span>
<OverflowText label={child.display} className='flex-1' />
{childType && childType !== 'any' && (
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>
{childType}
@@ -518,7 +519,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
}
}}
>
<span className='flex-1 truncate'>{nestedChild.display}</span>
<OverflowText label={nestedChild.display} className='flex-1' />
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>{'>'}</span>
</PopoverItem>
)
@@ -673,7 +674,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
}
}}
>
<span className='flex-1 truncate'>{nestedTag.display}</span>
<OverflowText label={nestedTag.display} className='flex-1' />
{tagDescription && tagDescription !== 'any' && (
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>
{tagDescription}
@@ -741,9 +742,12 @@ const VariableTagItem: React.FC<{
}
}}
>
<span className='flex-1 truncate'>
{tag.startsWith(TAG_PREFIXES.VARIABLE) ? tag.substring(TAG_PREFIXES.VARIABLE.length) : tag}
</span>
<OverflowText
label={
tag.startsWith(TAG_PREFIXES.VARIABLE) ? tag.substring(TAG_PREFIXES.VARIABLE.length) : tag
}
className='flex-1'
/>
{variableInfo && (
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>
{variableInfo.type}
@@ -800,7 +804,7 @@ const BlockRootTagItem: React.FC<{
fallbackLabel={blockName.charAt(0).toUpperCase()}
size='sm'
/>
<span className='flex-1 truncate'>{blockName}</span>
<OverflowText label={blockName} className='flex-1' />
</PopoverItem>
)
}
@@ -19,6 +19,7 @@ import {
ExpandableContent,
handleKeyboardActivation,
Info,
OverflowText,
} from '@sim/emcn'
import { ChevronDown, Search } from '@sim/emcn/icons'
import clsx from 'clsx'
@@ -134,7 +135,7 @@ const ToolbarItem = memo(function ToolbarItem({
bgColor={item.bgColor}
data-toolbar-item-icon=''
/>
<span className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{item.name}</span>
<OverflowText label={item.name} className='flex-1 text-[var(--text-body)]' />
</div>
)
})
@@ -13,6 +13,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Loader,
OverflowText,
} from '@sim/emcn'
import { Folder, MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons'
import Link from 'next/link'
@@ -95,7 +96,7 @@ function CollapsedFlyoutRows({
<DropdownMenuItem key={entry.id} asChild active={currentItemId === entry.id}>
<Link href={entry.href}>
<Icon className='size-[14px]' />
<span className='truncate'>{entry.name}</span>
<OverflowText label={entry.name} />
{entry.pinned && <PinnedGlyph />}
</Link>
</DropdownMenuItem>
@@ -106,7 +107,7 @@ function CollapsedFlyoutRows({
return (
<DropdownMenuItem key={entry.id} disabled>
<Folder className='size-[14px]' />
<span className='truncate'>{entry.name}</span>
<OverflowText label={entry.name} />
{entry.pinned && <PinnedGlyph />}
</DropdownMenuItem>
)
@@ -116,7 +117,7 @@ function CollapsedFlyoutRows({
<DropdownMenuSub key={entry.id}>
<DropdownMenuSubTrigger>
<Folder className='size-[14px]' />
<span className='truncate'>{entry.name}</span>
<OverflowText label={entry.name} />
{entry.pinned && <PinnedGlyph />}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
@@ -463,7 +464,7 @@ export function CollapsedWorkflowFlyoutItem({
: undefined
}
>
<span className='min-w-0 flex-1 truncate'>{workflow.name}</span>
<OverflowText label={workflow.name} className='flex-1' />
</Link>
</DropdownMenuItem>
)
@@ -520,7 +521,7 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {
return (
<DropdownMenuItem key={folder.id} disabled>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
<OverflowText label={folder.name} />
</DropdownMenuItem>
)
}
@@ -529,7 +530,7 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
<OverflowText label={folder.name} />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{interleaveSiblings(folder.children, folderWorkflows).map((child) =>
@@ -2,6 +2,7 @@
import type { ComponentType } from 'react'
import { memo } from 'react'
import { OverflowText } from '@sim/emcn'
import { File, Workflow } from '@sim/emcn/icons'
import { Command } from 'cmdk'
import { HEX_COLOR_REGEX } from '@/lib/branding'
@@ -33,13 +34,14 @@ function ItemFolderPath({ folderPath }: ItemFolderPathProps) {
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<OverflowText
label={folderPath.slice(0, -1).join(' / ')}
className='[flex-shrink:9999]'
/>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
<OverflowText label={folderPath[folderPath.length - 1]} />
</span>
)
}
@@ -91,10 +93,13 @@ export const MemoizedCommandItem = memo(
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<BlockTile blockType={blockType} icon={Icon} bgColor={bgColor} />
<span className='truncate text-[var(--text-body)]'>
<OverflowText
label={`${labelPrefix ? `${labelPrefix} ` : ''}${label}`}
className='text-[var(--text-body)]'
>
{labelPrefix && <span className='text-[var(--text-subtle)]'>{labelPrefix} </span>}
{label}
</span>
</OverflowText>
{meta ? <ItemMeta meta={meta} /> : null}
</Command.Item>
)
@@ -127,7 +132,7 @@ export const MemoizedActionItem = memo(
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>{name}</span>
<OverflowText label={name} className='text-[var(--text-body)]' />
{meta ? <ItemMeta meta={meta} /> : shortcut ? <ShortcutHint shortcut={shortcut} /> : null}
</Command.Item>
)
@@ -161,7 +166,7 @@ export const MemoizedWorkflowItem = memo(
<Workflow className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<OverflowText label={name} />
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
{meta ? (
@@ -199,7 +204,7 @@ export const MemoizedFileItem = memo(
<File className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<OverflowText label={name} />
</span>
{meta ? (
<ItemMeta meta={meta} />
@@ -229,7 +234,7 @@ export const MemoizedTaskItem = memo(
} & ResultMetaProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='truncate text-[var(--text-body)]'>{name}</span>
<OverflowText label={name} className='text-[var(--text-body)]' />
{meta && <ItemMeta meta={meta} />}
</Command.Item>
)
@@ -278,7 +283,7 @@ export const MemoizedWorkspaceItem = memo(
</span>
)}
<span className='flex min-w-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<OverflowText label={name} />
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
{meta && <ItemMeta meta={meta} />}
@@ -312,7 +317,7 @@ export const MemoizedPageItem = memo(
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>{name}</span>
<OverflowText label={name} className='text-[var(--text-body)]' />
{meta ? <ItemMeta meta={meta} /> : shortcut ? <ShortcutHint shortcut={shortcut} /> : null}
</Command.Item>
)
@@ -344,7 +349,7 @@ export const MemoizedIconItem = memo(
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<OverflowText label={name} />
</span>
{meta ? (
<ItemMeta meta={meta} />
@@ -7,6 +7,7 @@ import {
chipIconSlotClass,
chipVariants,
cn,
OverflowText,
} from '@sim/emcn'
import { ChevronLeft } from '@sim/emcn/icons'
import { type QueryClient, useQueryClient } from '@tanstack/react-query'
@@ -345,7 +346,7 @@ export function SettingsSidebar({
<span aria-hidden className={cn(chipIconSlotClass, 'text-[var(--text-icon)]')}>
<ChevronLeft className='size-[14px]' />
</span>
<span className='sidebar-collapse-hide truncate text-[var(--text-body)]'>Back</span>
<span className='sidebar-collapse-hide text-[var(--text-body)]'>Back</span>
</button>
</SidebarTooltip>
</div>
@@ -396,9 +397,11 @@ export function SettingsSidebar({
const content = (
<>
<Icon className={chipContentIconClass} />
<span className='sidebar-collapse-hide min-w-0 truncate text-[var(--text-body)]'>
{item.label}
</span>
<OverflowText
label={item.label}
className='sidebar-collapse-hide text-[var(--text-body)]'
tooltipEnabled={!showCollapsedTooltips}
/>
{isLocked && (
<span className='sidebar-collapse-hide ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
Max
@@ -1,7 +1,7 @@
'use client'
import { type ReactNode, useState } from 'react'
import { cn, disclosureChevronClass, Expandable, ExpandableContent } from '@sim/emcn'
import { cn, disclosureChevronClass, Expandable, ExpandableContent, OverflowText } from '@sim/emcn'
import { ChevronDown } from '@sim/emcn/icons'
/**
@@ -60,9 +60,10 @@ export function SidebarSection({
}
const label = (
<span className='sidebar-collapse-hide min-w-0 truncate text-[var(--text-muted)] text-caption'>
{title}
</span>
<OverflowText
label={title}
className='sidebar-collapse-hide text-[var(--text-muted)] text-caption'
/>
)
return (
@@ -1,7 +1,14 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { chipContentIconClass, chipVariants, cn, disclosureChevronClass, toast } from '@sim/emcn'
import {
chipContentIconClass,
chipVariants,
cn,
disclosureChevronClass,
OverflowText,
toast,
} from '@sim/emcn'
import { ChevronRight, Folder, FolderOpen, Lock, MoreHorizontal } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
@@ -535,13 +542,11 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
/>
) : (
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-1'>
<span
className='min-w-0 truncate text-[var(--text-body)]'
onDoubleClick={handleDoubleClick}
>
{folder.name}
</span>
<div
className='flex min-w-0 flex-1 items-center gap-1'
onDoubleClick={handleDoubleClick}
>
<OverflowText label={folder.name} className='flex-1 text-[var(--text-body)]' />
</div>
<div className='relative size-[18px] flex-shrink-0'>
{folder.locked && (
@@ -1,7 +1,7 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { chipVariants, cn } from '@sim/emcn'
import { chipVariants, cn, OverflowText } from '@sim/emcn'
import { Lock, MoreHorizontal } from '@sim/emcn/icons'
import clsx from 'clsx'
import Link from 'next/link'
@@ -441,11 +441,8 @@ export const WorkflowItem = memo(function WorkflowItem({
spellCheck='false'
/>
) : (
<div
className='min-w-0 truncate text-[var(--text-body)]'
onDoubleClick={handleDoubleClick}
>
{workflow.name}
<div className='min-w-0' onDoubleClick={handleDoubleClick}>
<OverflowText label={workflow.name} className='block text-[var(--text-body)]' />
</div>
)}
{!isEditing && <Avatars workflowId={workflow.id} />}
@@ -1,6 +1,14 @@
'use client'
import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, toast } from '@sim/emcn'
import {
Chip,
ChipModal,
ChipModalBody,
ChipModalFooter,
ChipModalHeader,
OverflowText,
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
@@ -100,10 +108,14 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa
invitations.map((inv) => (
<div key={inv.id} className='flex items-center gap-2 px-2'>
<div className='min-w-0 flex-1'>
<p className='truncate text-[var(--text-body)] text-sm'>{invitationLabel(inv)}</p>
<p className='truncate text-[var(--text-muted)] text-caption'>
{invitationSubLabel(inv)}
</p>
<OverflowText
label={invitationLabel(inv)}
className='block text-[var(--text-body)] text-sm'
/>
<OverflowText
label={invitationSubLabel(inv)}
className='block text-[var(--text-muted)] text-caption'
/>
</div>
<Chip
variant='primary'
@@ -14,6 +14,7 @@ import {
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
OverflowText,
Plus,
Send,
Skeleton,
@@ -760,9 +761,10 @@ function WorkspaceHeaderImpl({
{initial}
</div>
)}
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>
{workspace.name}
</span>
<OverflowText
label={workspace.name}
className='flex-1 text-[var(--text-body)] text-sm'
/>
{/* Pin and options share one fixed slot, as the chat rows do:
the trailing width never changes, so pinning cannot re-truncate
the name under the user's cursor. */}
@@ -14,6 +14,7 @@ import {
Home,
Library,
Loader,
OverflowText,
Skeleton,
Tooltip,
Upload,
@@ -275,7 +276,7 @@ const SidebarChatItem = memo(function SidebarChatItem({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{chat.name}</div>
<OverflowText label={chat.name} className='flex-1 text-[var(--text-body)]' />
{chat.id !== 'new' && (
<div className='relative flex size-[18px] flex-shrink-0 items-center justify-center'>
{showStatusDot && (
@@ -1,6 +1,6 @@
'use client'
import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn'
import { Avatar, AvatarFallback, Chip, ChipDropdown, cn, OverflowText } from '@sim/emcn'
import { getUserColor } from '@/lib/workspaces/colors'
import type { MemberRole } from './member-role-options'
import { RoleLockTooltip } from './role-lock'
@@ -63,12 +63,14 @@ export function MemberRow<TRole extends string = MemberRole>({
</AvatarFallback>
</Avatar>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-body)] text-sm'>
{member.userName || member.userEmail || member.userId}
</span>
<span className='truncate text-[var(--text-muted)] text-caption'>
{member.userEmail || member.userId}
</span>
<OverflowText
label={member.userName || member.userEmail || member.userId}
className='text-[var(--text-body)] text-sm'
/>
<OverflowText
label={member.userEmail || member.userId}
className='text-[var(--text-muted)] text-caption'
/>
</div>
</div>
<RoleLockTooltip reason={lockReason}>
@@ -1,7 +1,14 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChipConfirmModal, chipIconSlotClass, chipVariants, cn, Tooltip } from '@sim/emcn'
import {
ChipConfirmModal,
chipIconSlotClass,
chipVariants,
cn,
OverflowText,
Tooltip,
} from '@sim/emcn'
import { ChevronLeft } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import {
@@ -119,7 +126,7 @@ export function SettingsSidebar<Section extends SettingsSection>({
<span aria-hidden className={cn(chipIconSlotClass, 'text-[var(--text-icon)]')}>
<ChevronLeft className='size-[14px]' />
</span>
<span className='sidebar-collapse-hide truncate text-[var(--text-body)]'>Back</span>
<span className='sidebar-collapse-hide text-[var(--text-body)]'>Back</span>
</button>
</SidebarTooltip>
)}
@@ -175,9 +182,10 @@ export function SettingsSidebar<Section extends SettingsSection>({
}}
>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='sidebar-collapse-hide min-w-0 truncate text-[var(--text-body)]'>
{item.label}
</span>
<OverflowText
label={item.label}
className='sidebar-collapse-hide text-[var(--text-body)]'
/>
{item.locked && (
<span className='sidebar-collapse-hide ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[var(--text-icon)] text-micro uppercase tracking-wide'>
Plan
@@ -17,6 +17,7 @@ import {
ChipTag,
cn,
Info,
OverflowText,
Search,
Skeleton,
Switch,
@@ -305,10 +306,14 @@ function AddMembersModal({
<Checkbox checked={isSelected} />
<MemberAvatar name={name} image={member.user?.image ?? null} />
<div className='min-w-0 flex-1'>
<div className='truncate text-[var(--text-body)] text-sm'>{name}</div>
<div className='truncate text-[var(--text-muted)] text-caption'>
{email}
</div>
<OverflowText
label={name}
className='block text-[var(--text-body)] text-sm'
/>
<OverflowText
label={email}
className='block text-[var(--text-muted)] text-caption'
/>
</div>
</button>
)
@@ -428,7 +433,7 @@ function CheckboxGrid({
checked={isAllowed(item.id)}
onCheckedChange={() => onToggle(item.id)}
/>
<span className='truncate text-sm'>{item.label}</span>
<OverflowText label={item.label} className='text-sm' />
</label>
)
})}
@@ -524,7 +529,7 @@ function ProviderRow({
isProviderAllowed ? 'cursor-pointer' : 'cursor-default opacity-60'
)}
>
<span className='truncate text-sm'>{providerName}</span>
<OverflowText label={providerName} className='text-sm' />
{isProviderAllowed && deniedCount > 0 && (
<ChipTag variant='gray' className='flex-shrink-0'>
{deniedCount} blocked
@@ -611,7 +616,7 @@ function BlockToolRow({
!isBlockAllowed && 'opacity-60'
)}
>
<span className='truncate text-sm'>{block.name}</span>
<OverflowText label={block.name} className='text-sm' />
{/* An org running one custom block per environment has prod/uat/sandbox copies
sharing a name and differing only by an opaque type slug. The source workspace
is the only thing that tells them apart, so an allowlist decision made without
@@ -1651,7 +1656,7 @@ export function GroupDetail({
>
{BlockIcon && <BlockIcon className='!size-[9px] text-white' />}
</div>
<span className='truncate text-sm'>{block.name}</span>
<OverflowText label={block.name} className='text-sm' />
{block.sourceWorkspaceName && (
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
{block.sourceWorkspaceName}
@@ -10,6 +10,7 @@ import {
ChipSelect,
type ComboboxOption,
Download,
OverflowText,
Popover,
PopoverAnchor,
PopoverContent,
@@ -419,8 +420,13 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
value={timeRange}
onChange={handleTimeRangeChange}
placeholder='All time'
overlayLabel={timeDisplayLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
<OverflowText
label={timeDisplayLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
maxHeight={320}
align='start'
@@ -11,6 +11,7 @@ import {
ChipSwitch,
ChipTag,
Info,
OverflowText,
Search,
toast,
} from '@sim/emcn'
@@ -336,7 +337,7 @@ function EntityCheckboxGrid({
checked={selected.includes(entity.value)}
onCheckedChange={() => toggle(entity.value)}
/>
<span className='truncate text-sm'>{entity.label}</span>
<OverflowText label={entity.label} className='text-sm' />
</label>
)
})}
@@ -1,7 +1,7 @@
'use client'
import { useId, useMemo, useState } from 'react'
import { Checkbox, ChevronDown, cn, toast } from '@sim/emcn'
import { Checkbox, ChevronDown, cn, OverflowText, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { useUpdateForkExcludedWorkflows } from '@/ee/workspace-forking/hooks/workspace-fork'
@@ -205,12 +205,14 @@ function ExcludedFolderRow({
className='flex min-w-0 items-center gap-1.5 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 truncate'>
<OverflowText
label={`${folder.name} (${selectedCount > 0 ? `${selectedCount}/${total}` : total})`}
>
{folder.name}{' '}
<span className='text-[var(--text-muted)]'>
({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
</span>
</OverflowText>
<ChevronDown
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
@@ -288,7 +290,7 @@ function ExcludedWorkflowRow({
onCheckedChange={(value) => onToggle([workflow.id], value === true)}
disabled={disabled}
/>
<span className='min-w-0 truncate'>{workflow.name}</span>
<OverflowText label={workflow.name} />
</label>
)
}
@@ -1,7 +1,7 @@
'use client'
import { useId, useState } from 'react'
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
import { Checkbox, ChevronDown, cn, OverflowText } from '@sim/emcn'
export interface ForkFileTreeItem {
id: string
@@ -137,9 +137,10 @@ function ForkFileFolderRow({
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{folder.name} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<OverflowText
label={`${folder.name} (${selectedCount > 0 ? `${selectedCount}/${total}` : total})`}
className='flex-1'
/>
<ChevronDown
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
@@ -191,7 +192,7 @@ function ForkFileRow({ file, checked, onToggle, disabled }: ForkFileRowProps) {
onCheckedChange={(value) => onToggle(file.id, value === true)}
disabled={disabled}
/>
<span className='min-w-0 flex-1 truncate'>{file.label}</span>
<OverflowText label={file.label} className='flex-1' />
</label>
)
}
@@ -1,7 +1,7 @@
'use client'
import { useId, useMemo, useState } from 'react'
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
import { Checkbox, ChevronDown, cn, OverflowText } from '@sim/emcn'
import {
ForkFileTree,
type ForkFlatFile,
@@ -69,9 +69,10 @@ export function ResourceKindRow({
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<OverflowText
label={`${label} (${selectedCount > 0 ? `${selectedCount}/${total}` : total})`}
className='flex-1'
/>
<ChevronDown
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
@@ -105,7 +106,7 @@ export function ResourceKindRow({
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
disabled={disabled}
/>
<span className='truncate'>{item.label}</span>
<OverflowText label={item.label} />
</label>
)
})}
@@ -164,9 +165,10 @@ export function FileKindRow({
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<OverflowText
label={`${label} (${selectedCount > 0 ? `${selectedCount}/${total}` : total})`}
className='flex-1'
/>
<ChevronDown
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
@@ -12,6 +12,7 @@ import {
cn,
FieldDivider,
Label,
OverflowText,
Tooltip,
} from '@sim/emcn'
import { ArrowRight } from '@sim/emcn/icons'
@@ -482,7 +483,9 @@ function MappingEntry({ controller, group, entry }: MappingEntryProps) {
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<div className='flex items-center justify-between gap-4'>
<Label className='min-w-0 truncate'>{entry.sourceLabel}</Label>
<Label className='min-w-0'>
<OverflowText label={entry.sourceLabel} />
</Label>
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
<ChipCombobox
className='w-full'
@@ -613,7 +616,7 @@ function MappingKindRow({ controller, group, summary }: MappingKindRowProps) {
onClick={() => setOpen((value) => !value)}
className='flex w-full items-center gap-2 text-left text-[var(--text-body)] text-sm transition-colors hover:text-[var(--text-primary)]'
>
<span className='min-w-0 flex-1 truncate'>{group.label}</span>
<OverflowText label={group.label} className='flex-1' />
<Badge variant={badge.variant} size='sm' dot>
{badge.label}
</Badge>
@@ -733,14 +736,11 @@ function TriggerMappingRow({ controller, mapping }: TriggerMappingRowProps) {
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center justify-between gap-4'>
{/* One inner span, so the name and its "in <workflow>" suffix share a normal inline flow:
`Label` is inline-flex, and a flex container DISCARDS whitespace-only children, which
eats the separating space (and leaves `truncate` with no text run to clip). */}
<Label className='min-w-0'>
<span className='min-w-0 truncate'>
<OverflowText label={`${mapping.blockName} in ${mapping.workflowName}`}>
{mapping.blockName}{' '}
<span className='text-[var(--text-muted)]'>in {mapping.workflowName}</span>
</span>
</OverflowText>
</Label>
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
{decidable ? (
@@ -57,6 +57,19 @@ afterEach(() => {
})
describe('Combobox onOpenChange', () => {
it('uses the overlay label for the interactive overflow layer', () => {
render(
<Combobox
options={OPTIONS}
overlayContent={<span>2 selected</span>}
overlayLabel='2 selected'
/>
)
const overflowLabel = trigger().querySelector('[data-overflow-text]')
expect(overflowLabel?.textContent).toBe('2 selected')
})
it('reports the open a trigger click causes', () => {
const onOpenChange = vi.fn()
render(<Combobox options={OPTIONS} onOpenChange={onOpenChange} />)
@@ -19,6 +19,7 @@ import { Check, ChevronDown, Loader, Search } from '../../icons'
import { cn } from '../../lib/cn'
import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
import { Input } from '../input/input'
import { OverflowText } from '../overflow-text/overflow-text'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
const comboboxVariants = cva(
@@ -93,8 +94,10 @@ export interface ComboboxProps
disabled?: boolean
/** Enable free-text input mode (default: false) */
editable?: boolean
/** Custom overlay content for editable mode */
/** Visual content rendered over the selected value. */
overlayContent?: ReactNode
/** Plain-text value represented by a visual overlay in non-editable mode. */
overlayLabel?: string
/** Additional input props for editable mode */
inputProps?: Omit<
React.InputHTMLAttributes<HTMLInputElement>,
@@ -172,6 +175,7 @@ const Combobox = memo(
disabled,
editable = false,
overlayContent,
overlayLabel,
inputProps = {},
inputRef: externalInputRef,
filterOptions = editable,
@@ -677,9 +681,10 @@ const Combobox = memo(
) : (
<>
{SelectedIcon && <SelectedIcon className='mr-2 size-3 flex-shrink-0' />}
<span className='truncate text-[var(--text-primary)]'>
{selectedOption?.label}
</span>
<OverflowText
label={selectedOption?.label ?? ''}
className='text-[var(--text-primary)]'
/>
</>
)}
</div>
@@ -716,15 +721,18 @@ const Combobox = memo(
onClick={handleToggle}
onKeyDown={handleKeyDown}
>
<span
<OverflowText
label={
overlayLabel ??
multiSelectLabel ??
(selectedOption ? selectedOption.label : placeholder)
}
className={cn(
'flex-1 truncate',
'flex-1',
!selectedOption && !multiSelectLabel && 'text-[var(--text-muted)]',
overlayContent && 'text-transparent'
)}
>
{multiSelectLabel ?? (selectedOption ? selectedOption.label : placeholder)}
</span>
/>
<ChevronDown
className={cn(
'ml-2 size-4 flex-shrink-0 opacity-50 transition-transform',
@@ -899,9 +907,10 @@ const Combobox = memo(
: OptionIcon && (
<OptionIcon className='size-[14px] flex-shrink-0' />
)}
<span className='flex-1 truncate text-[var(--text-primary)]'>
{option.label}
</span>
<OverflowText
label={option.label}
className='flex-1 text-[var(--text-primary)]'
/>
{option.suffixElement}
{multiSelect && isSelected && (
<Check className='ml-2 size-[12px] flex-shrink-0 text-[var(--text-primary)]' />
@@ -935,9 +944,10 @@ const Combobox = memo(
: chipHoverSurfaceClass
)}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{allOptionLabel}
</span>
<OverflowText
label={allOptionLabel}
className='flex-1 text-[var(--text-primary)]'
/>
</div>
)}
{filteredOptions.map((option, index) => {
@@ -973,9 +983,10 @@ const Combobox = memo(
{option.iconElement
? option.iconElement
: OptionIcon && <OptionIcon className='size-[14px] flex-shrink-0' />}
<span className='flex-1 truncate text-[var(--text-primary)]'>
{option.label}
</span>
<OverflowText
label={option.label}
className='flex-1 text-[var(--text-primary)]'
/>
{option.suffixElement}
{multiSelect && isSelected && (
<Check className='ml-2 size-[12px] flex-shrink-0 text-[var(--text-primary)]' />
@@ -2,8 +2,8 @@
* @vitest-environment jsdom
*
* Menu rows are a fixed height, so a label that wraps overflows its row and paints over its
* neighbours. Rows are held to one line and their bare text is wrapped in a truncating box so an
* over-long label ellipsizes instead of being cut mid-word. These tests cover that wrapping:
* neighbours. Rows are held to one line and their bare text is wrapped in the shared overflow
* treatment so an over-long label fades instead of being cut mid-word. These tests cover that wrapping:
* that it happens, that adjacent text stays in ONE box (two boxes would be two flex items, and
* the row's `gap` would open between the words), and that it steps aside for `asChild`, where
* Radix's `Slot` requires exactly one element child.
@@ -51,13 +51,15 @@ afterEach(() => {
})
describe('menu row labels', () => {
it('wraps a bare text label in a truncating box', () => {
it('wraps a bare text label in the shared overflow treatment', () => {
openMenu(<DropdownMenuItem>Run empty or failed cells on 2 rows</DropdownMenuItem>)
const labels = row().querySelectorAll('span')
expect(labels).toHaveLength(1)
expect(labels[0].textContent).toBe('Run empty or failed cells on 2 rows')
expect(labels[0].className).toContain('truncate')
expect(labels[0].className).toContain('overflow-hidden')
expect(labels[0].className).toContain('text-clip')
expect(labels[0].className).not.toContain('truncate')
})
it('keeps the row on one line', () => {
@@ -84,7 +86,7 @@ describe('menu row labels', () => {
openMenu(<DropdownMenuCheckboxItem checked>Show archived workflows</DropdownMenuCheckboxItem>)
const labels = row('[role="menuitemcheckbox"]').querySelectorAll('span')
const label = Array.from(labels).find((node) => node.className.includes('truncate'))
const label = Array.from(labels).find((node) => node.className.includes('text-clip'))
expect(label?.textContent).toBe('Show archived workflows')
})
@@ -108,5 +110,6 @@ describe('menu row labels', () => {
)
expect(row().querySelectorAll('span')).toHaveLength(1)
expect(row().className).toContain('[&>span:not([data-overflow-text])]:truncate')
})
})
@@ -26,6 +26,7 @@ import { Check, ChevronRight, Circle, Search } from '../../icons'
import { cn } from '../../lib/cn'
import { chipContentGap, chipFieldSurfaceClass } from '../chip/chip-chrome'
import { InsideModalContext } from '../modal/modal'
import { OverflowText } from '../overflow-text/overflow-text'
const ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
@@ -76,30 +77,32 @@ const MENU_ROW_SELECTED_CLASS =
/**
* Rows are a fixed height, so a label that wraps overflows its row and paints
* over its neighbours instead of growing the row. Every row is therefore held
* to one line, and its label ellipsizes see {@link withEllipsizedLabel}.
* to one line, and its label uses the shared overflow treatment see
* {@link withOverflowLabel}.
*/
const MENU_ROW_SINGLE_LINE_CLASS = 'whitespace-nowrap [&>span]:min-w-0 [&>span]:truncate'
const MENU_ROW_SINGLE_LINE_CLASS =
'whitespace-nowrap [&>span]:min-w-0 [&>span:not([data-overflow-text])]:truncate'
/**
* Wraps a row's bare text children in a truncating box so a label wider than
* the menu ends in an ellipsis rather than being cut mid-word at the surface
* edge. Consumers that already wrap their label in a `<span>` are unaffected
* the row's `[&>span]` rule truncates those in place.
* the menu uses the platform overflow treatment rather than being cut mid-word
* at the surface edge. Consumer-provided direct `<span>` labels retain an
* ellipsis fallback; a canonical {@link OverflowText} owns its fade and tooltip.
*
* Adjacent text is coalesced into a single box: a row is a flex container, so
* wrapping `Insert row {n}` as two boxes would make them two flex items and
* open the row's `gap` between the words. `React.Children.toArray` keys the
* element children it returns, so the rebuilt array needs no keys of its own.
*/
function withEllipsizedLabel(children: React.ReactNode): React.ReactNode {
function withOverflowLabel(children: React.ReactNode): React.ReactNode {
const rebuilt: React.ReactNode[] = []
let text: React.ReactNode[] = []
let text: Array<string | number> = []
const flushText = () => {
if (text.length === 0) return
rebuilt.push(
<span key={`label-${rebuilt.length}`} className='min-w-0 truncate'>
<OverflowText key={`label-${rebuilt.length}`} label={text.join('')}>
{text}
</span>
</OverflowText>
)
text = []
}
@@ -212,7 +215,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
)}
{...props}
>
{withEllipsizedLabel(children)}
{withOverflowLabel(children)}
<ChevronRight className='ml-auto size-[14px] shrink-0' />
</DropdownMenuPrimitive.SubTrigger>
)
@@ -303,7 +306,7 @@ const DropdownMenuItem = React.forwardRef<
action?: React.ReactNode
}
>(({ className, inset, active, action, asChild, children, ...props }, ref) => {
const content = asChild ? children : withEllipsizedLabel(children)
const content = asChild ? children : withOverflowLabel(children)
const stateClasses = active ? MENU_ROW_SELECTED_CLASS : MENU_ROW_HIGHLIGHT_CLASS
if (action) {
return (
@@ -389,7 +392,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<Check className='size-[14px]' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{withEllipsizedLabel(children)}
{withOverflowLabel(children)}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
@@ -411,7 +414,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<Circle className='size-[6px] fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{withEllipsizedLabel(children)}
{withOverflowLabel(children)}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+5
View File
@@ -163,6 +163,11 @@ export {
useModalDismissDisabled,
useNativeSurfaceOcclusionReady,
} from './modal/modal'
export {
OverflowText,
type OverflowTextProps,
overflowTextFadeClass,
} from './overflow-text/overflow-text'
export {
Popover,
PopoverAnchor,
@@ -0,0 +1,273 @@
/**
* @vitest-environment jsdom
*/
import { act, StrictMode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OverflowText } from './overflow-text'
let host: HTMLDivElement
let root: Root
let resizeObserverCallback: ResizeObserverCallback
let resizeObserverCount: number
let originalFontsDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
resizeObserverCount = 0
originalFontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
const fontEvents = new EventTarget()
Object.defineProperty(fontEvents, 'ready', {
configurable: true,
value: new Promise(() => {}),
})
Object.defineProperty(document, 'fonts', {
configurable: true,
value: fontEvents,
})
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeObserverCount += 1
resizeObserverCallback = callback
}
observe() {}
unobserve() {}
disconnect() {}
}
)
host = document.createElement('div')
document.body.appendChild(host)
root = createRoot(host)
})
afterEach(() => {
act(() => root.unmount())
host.remove()
if (originalFontsDescriptor) {
Object.defineProperty(document, 'fonts', originalFontsDescriptor)
} else {
Reflect.deleteProperty(document, 'fonts')
}
vi.unstubAllGlobals()
})
function setWidths(element: HTMLElement, clientWidth: number, scrollWidth: number) {
Object.defineProperties(element, {
clientWidth: { configurable: true, value: clientWidth },
scrollWidth: { configurable: true, value: scrollWidth },
})
act(() =>
resizeObserverCallback(
[
{
target: element,
contentRect: element.getBoundingClientRect(),
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
},
],
{} as ResizeObserver
)
)
}
describe('OverflowText', () => {
it('fades and reveals the full value only when clipped', () => {
act(() =>
root.render(<OverflowText label='A long workflow name' className='truncate text-sm' />)
)
const label = host.querySelector<HTMLElement>('span')
if (!label) throw new Error('Overflow label did not render')
setWidths(label, 80, 180)
expect(label.classList.contains('overflow-hidden')).toBe(true)
expect(label.classList.contains('block')).toBe(true)
expect(label.classList.contains('text-clip')).toBe(true)
expect(label.classList.contains('whitespace-nowrap')).toBe(true)
expect(label.classList.contains('truncate')).toBe(false)
expect(label.classList.contains('text-sm')).toBe(true)
expect(label.className).toContain('-webkit-mask-image:linear-gradient')
expect(label.className).toContain('mask-image:linear-gradient')
act(() => {
label.dispatchEvent(
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
)
})
expect(document.querySelector('[data-native-surface-overlay]')?.textContent).toBe(
'A long workflow name'
)
setWidths(label, 200, 180)
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
})
it('leaves a fitting label unmasked and does not open a tooltip', () => {
act(() => root.render(<OverflowText label='Short' />))
const label = host.querySelector<HTMLElement>('span')
if (!label) throw new Error('Overflow label did not render')
setWidths(label, 100, 60)
expect(label.className).not.toContain('mask-image:linear-gradient')
act(() => {
label.dispatchEvent(
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
)
})
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
})
it('can disable the tooltip for a visual mirror layer', () => {
act(() => root.render(<OverflowText label='A long workflow name' tooltipEnabled={false} />))
const label = host.querySelector<HTMLElement>('span')
if (!label) throw new Error('Overflow label did not render')
setWidths(label, 80, 180)
expect(label.className).toContain('mask-image:linear-gradient')
act(() => {
label.dispatchEvent(
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
)
})
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
})
it('opens from an external composite control keyboard focus', () => {
act(() =>
root.render(
<button type='button'>
<OverflowText label='A long workflow name' focusTarget='nearest-interactive' />
</button>
)
)
const button = host.querySelector('button')
const label = host.querySelector<HTMLElement>('[data-overflow-text]')
if (!button || !label) throw new Error('Composite label did not render')
setWidths(label, 80, 180)
vi.spyOn(button, 'matches').mockReturnValue(true)
act(() => button.focus())
expect(document.querySelector('[data-native-surface-overlay]')?.textContent).toBe(
'A long workflow name'
)
})
it('keeps decorated visible content out of the plain tooltip label', () => {
act(() =>
root.render(
<OverflowText label='Workflow production'>
Workflow <mark>production</mark>
</OverflowText>
)
)
const label = host.querySelector<HTMLElement>('span')
if (!label) throw new Error('Overflow label did not render')
setWidths(label, 80, 180)
act(() => {
label.dispatchEvent(
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
)
})
const tooltip = document.querySelector('[data-native-surface-overlay]')
expect(tooltip?.textContent).toBe('Workflow production')
expect(tooltip?.querySelector('mark')).toBeNull()
})
it('shares one resize observer across overflow labels', () => {
act(() =>
root.render(
<>
<OverflowText label='First workflow' />
<OverflowText label='Second workflow' />
</>
)
)
expect(resizeObserverCount).toBe(1)
})
it('remeasures when an existing label changes', () => {
act(() => root.render(<OverflowText label='Short' />))
const label = host.querySelector<HTMLElement>('span')
if (!label) throw new Error('Overflow label did not render')
setWidths(label, 100, 60)
expect(label.className).not.toContain('mask-image:linear-gradient')
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 180 })
act(() => root.render(<OverflowText label='A newly long workflow name' />))
expect(label.className).toContain('mask-image:linear-gradient')
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 60 })
act(() => root.render(<OverflowText label='Short again' />))
expect(label.className).not.toContain('mask-image:linear-gradient')
})
it('remeasures when decorated content changes without changing the label', () => {
act(() =>
root.render(
<OverflowText label='Workflow production'>
Workflow <mark>production</mark>
</OverflowText>
)
)
const label = host.querySelector<HTMLElement>('span')
if (!label) throw new Error('Overflow label did not render')
setWidths(label, 100, 60)
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 180 })
act(() =>
root.render(
<OverflowText label='Workflow production'>
Workflow <strong>production</strong>
</OverflowText>
)
)
expect(label.className).toContain('mask-image:linear-gradient')
})
it('remeasures when loaded fonts change text width', () => {
act(() => root.render(<OverflowText label='Workflow name' />))
const label = host.querySelector<HTMLElement>('span')
if (!label) throw new Error('Overflow label did not render')
setWidths(label, 100, 60)
expect(label.className).not.toContain('mask-image:linear-gradient')
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 180 })
act(() => document.fonts.dispatchEvent(new Event('loadingdone')))
expect(label.className).toContain('mask-image:linear-gradient')
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 60 })
act(() => document.fonts.dispatchEvent(new Event('loadingerror')))
expect(label.className).not.toContain('mask-image:linear-gradient')
})
it('keeps observing mounted labels through Strict Mode effect replay', () => {
act(() =>
root.render(
<StrictMode>
<OverflowText label='Workflow name' />
</StrictMode>
)
)
const label = host.querySelector<HTMLElement>('span')
if (!label) throw new Error('Overflow label did not render')
setWidths(label, 100, 60)
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 180 })
act(() => document.fonts.dispatchEvent(new Event('loadingdone')))
expect(label.className).toContain('mask-image:linear-gradient')
})
})
@@ -0,0 +1,87 @@
'use client'
import type { ReactNode } from 'react'
import { memo, useCallback } from 'react'
import { cn } from '../../lib/cn'
import {
FloatingTooltip,
isTextClipped,
useFloatingTooltip,
useIsOverflowing,
} from '../tooltip/tooltip'
/** Shared 18px trailing fade for measured special cases such as breadcrumb groups. */
export const overflowTextFadeClass =
'[-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_18px),transparent)] [mask-image:linear-gradient(to_right,black_calc(100%_-_18px),transparent)]'
export interface OverflowTextProps {
/** Full text shown in the tooltip and used as the default visible content. */
label: string
/** Decorated rendering of `label`; the tooltip always keeps the plain label. */
children?: ReactNode
/** Layout and typography only; truncation and fade chrome are owned here. */
className?: string
/** Forces the tooltip when the visible label was shortened before rendering. */
showWhen?: boolean
/** Whether the full-value tooltip may open. Disable for visual mirror layers. */
tooltipEnabled?: boolean
/** Lets the nearest interactive ancestor own keyboard focus for this label. */
focusTarget?: 'nearest-interactive'
}
/**
* A single-line, read-only label that fades only when clipped and exposes its
* complete value in the platform floating tooltip.
*
* Use this for human-readable names and titles in constrained chrome. Keep
* editable values, code, logs, paths, dense grids, and multiline copy on their
* purpose-built overflow behavior.
*/
export const OverflowText = memo(function OverflowText({
label,
children,
className,
showWhen,
tooltipEnabled = true,
focusTarget,
}: OverflowTextProps) {
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>(children ?? label)
const tooltipEligible = tooltipEnabled && label.length > 0 && (Boolean(showWhen) || isOverflowing)
const getFocusTarget = useCallback(() => {
if (focusTarget !== 'nearest-interactive') return null
return (
node.current?.closest<HTMLElement>(
'a[href], button, [role="button"], [tabindex]:not([tabindex="-1"])'
) ?? null
)
}, [focusTarget, node])
const { state, handlers } = useFloatingTooltip(
() => {
const element = node.current
if (!tooltipEnabled || !element || label.length === 0) return false
return Boolean(showWhen) || isTextClipped(element)
},
{
getFocusTarget: focusTarget === 'nearest-interactive' ? getFocusTarget : undefined,
revalidateKey: tooltipEligible,
}
)
return (
<>
<span
ref={textRef}
data-overflow-text=''
className={cn(
className,
'block min-w-0 overflow-hidden text-clip whitespace-nowrap',
isOverflowing && overflowTextFadeClass
)}
{...handlers}
>
{children ?? label}
</span>
<FloatingTooltip label={label} state={state} />
</>
)
})
@@ -17,6 +17,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { Plus, X } from '../../icons'
import { cn } from '../../lib/cn'
import { Button } from '../button/button'
import { overflowTextFadeClass } from '../overflow-text/overflow-text'
import { Tooltip } from '../tooltip/tooltip'
const DRAG_EDGE_ZONE = 40
@@ -441,7 +442,14 @@ const Tab = forwardRef<HTMLDivElement, TabProps>(function Tab(
>
{tab.icon}
{!tab.pinned && (
<span ref={titleRef} className='min-w-0 flex-1 select-none truncate text-left'>
<span
ref={titleRef}
className={cn(
'min-w-0 flex-1 select-none truncate text-left',
titleTruncated && overflowTextFadeClass,
titleTruncated && 'hover:[-webkit-mask-image:none] hover:[mask-image:none]'
)}
>
{tab.title}
</span>
)}
+137 -50
View File
@@ -83,11 +83,10 @@ const HIDDEN_STATE: FloatingTooltipState = {
}
/**
* Drives a pointer-reactive floating tooltip. `canShow` is queried on every
* gesture with the event target, letting the caller gate the tooltip on its own
* overflow measurement. Returns the current {@link FloatingTooltipState} to feed
* a {@link FloatingTooltip} and a stable set of {@link FloatingTooltipHandlers}
* to spread onto the trigger element.
* Drives a pointer-reactive floating tooltip. `canShow` is checked on each
* gesture and while visible, allowing callers to dismiss the tooltip when its
* eligibility changes. Returns the current state and stable pointer/focus
* handlers; `getFocusTarget` may supply a separate keyboard-focus trigger.
*/
export interface UseFloatingTooltipOptions {
/**
@@ -95,6 +94,10 @@ export interface UseFloatingTooltipOptions {
* pointer is too close to the top of the viewport.
*/
preferAbove?: boolean
/** Resolves an external control whose keyboard focus should reveal this tooltip. */
getFocusTarget?: () => HTMLElement | null
/** Semantic value whose changes should revalidate a visible tooltip. */
revalidateKey?: unknown
}
export function useFloatingTooltip(
@@ -126,41 +129,49 @@ export function useFloatingTooltip(
setState((current) => (current.visible ? HIDDEN_STATE : current))
}, [reset])
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
const apply = (clientX: number, clientY: number, motion: TooltipMotion) => {
const next = { ...getTooltipPosition(clientX, clientY, preferAboveRef.current), ...motion }
setState((current) =>
current.visible &&
current.x === next.x &&
current.y === next.y &&
current.alignX === next.alignX &&
current.alignY === next.alignY &&
current.skew === next.skew &&
current.scaleX === next.scaleX &&
current.scaleY === next.scaleY
? current
: { visible: true, ...next }
)
}
const apply = React.useCallback((clientX: number, clientY: number, motion: TooltipMotion) => {
const next = { ...getTooltipPosition(clientX, clientY, preferAboveRef.current), ...motion }
setState((current) =>
current.visible &&
current.x === next.x &&
current.y === next.y &&
current.alignX === next.alignX &&
current.alignY === next.alignY &&
current.skew === next.skew &&
current.scaleX === next.scaleX &&
current.scaleY === next.scaleY
? current
: { visible: true, ...next }
)
}, [])
/** Reveals the tooltip at the pointer, seeding velocity tracking from it. */
const showFromPointer = (clientX: number, clientY: number) => {
const showFromPointer = React.useCallback(
(clientX: number, clientY: number) => {
reset()
lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() }
apply(clientX, clientY, NEUTRAL_MOTION)
}
},
[apply, reset]
)
/**
* Reveals the tooltip anchored to an element's box rather than the pointer.
* Velocity tracking stays cleared: seeding it from the box would make the next
* `pointermove` read the box-to-cursor delta as velocity and spike the flourish
* when the pointer already happens to be over the trigger.
*/
const showFromElement = (clientX: number, clientY: number) => {
/**
* Anchors the tooltip to an element without seeding pointer velocity; using the
* box position would make the next pointer move produce a false motion spike.
*/
const showFromElement = React.useCallback(
(target: HTMLElement) => {
reset()
apply(clientX, clientY, NEUTRAL_MOTION)
}
const rect = target.getBoundingClientRect()
apply(
rect.left + rect.width / 2,
preferAboveRef.current ? rect.top : rect.bottom,
NEUTRAL_MOTION
)
},
[apply, reset]
)
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
return {
onPointerEnter: (event) => {
if (!canShowRef.current(event.currentTarget)) return
@@ -200,14 +211,32 @@ export function useFloatingTooltip(
if (!canShowRef.current(target)) return
if (!isFocusVisible(target)) return
triggerRef.current = target
const rect = target.getBoundingClientRect()
/* Anchor on the edge the bubble grows away from, so a `preferAbove`
tooltip measures from the trigger's top rather than its bottom. */
showFromElement(rect.left + rect.width / 2, preferAboveRef.current ? rect.top : rect.bottom)
showFromElement(target)
},
onBlur: hide,
}
}, [hide, reset])
}, [apply, hide, showFromElement, showFromPointer])
React.useEffect(() => {
const target = options.getFocusTarget?.()
if (!target) return undefined
const show = () => {
if (!canShowRef.current(target) || !isFocusVisible(target)) return
triggerRef.current = target
showFromElement(target)
}
target.addEventListener('focus', show)
target.addEventListener('blur', hide)
return () => {
target.removeEventListener('focus', show)
target.removeEventListener('blur', hide)
}
}, [hide, options.getFocusTarget, showFromElement])
React.useEffect(() => {
const trigger = triggerRef.current
if (state.visible && (!trigger || !canShowRef.current(trigger))) hide()
}, [hide, options.revalidateKey, state.visible])
/**
* A keyboard- or script-driven UI change can hide the trigger with no pointer or focus event
@@ -227,23 +256,77 @@ export function useFloatingTooltip(
return { state, handlers }
}
const overflowMeasureByElement = new WeakMap<Element, () => void>()
const observedOverflowElements = new Set<Element>()
let sharedOverflowObserver: ResizeObserver | null = null
let sharedOverflowFontSet: FontFaceSet | null = null
function measureObservedOverflow() {
for (const element of observedOverflowElements) overflowMeasureByElement.get(element)?.()
}
function observeOverflowFontChanges() {
if (typeof document === 'undefined' || !document.fonts || sharedOverflowFontSet) return
const fontSet = document.fonts
sharedOverflowFontSet = fontSet
fontSet.addEventListener('loadingdone', measureObservedOverflow)
fontSet.addEventListener('loadingerror', measureObservedOverflow)
void fontSet.ready.then(() => {
if (sharedOverflowFontSet === fontSet) measureObservedOverflow()
})
}
function unobserveOverflowFontChanges() {
sharedOverflowFontSet?.removeEventListener('loadingdone', measureObservedOverflow)
sharedOverflowFontSet?.removeEventListener('loadingerror', measureObservedOverflow)
sharedOverflowFontSet = null
}
function observeOverflow(element: Element, measure: () => void): boolean {
overflowMeasureByElement.set(element, measure)
observedOverflowElements.add(element)
observeOverflowFontChanges()
if (typeof ResizeObserver === 'undefined') return false
sharedOverflowObserver ??= new ResizeObserver((entries) => {
for (const entry of entries) overflowMeasureByElement.get(entry.target)?.()
})
sharedOverflowObserver.observe(element)
return true
}
function unobserveOverflow(element: Element | null) {
if (!element) return
sharedOverflowObserver?.unobserve(element)
overflowMeasureByElement.delete(element)
observedOverflowElements.delete(element)
if (observedOverflowElements.size === 0) {
sharedOverflowObserver?.disconnect()
sharedOverflowObserver = null
unobserveOverflowFontChanges()
}
}
/**
* Tracks whether an element's text is horizontally clipped, re-measuring via a
* `ResizeObserver` and window resizes.
* Tracks whether an element's text is horizontally clipped, re-measuring when
* `measurementKey` or loaded fonts change and via a shared `ResizeObserver` (or
* window resizes when the API is unavailable).
*
* Returns a callback `ref` to attach to the element the observer follows the
* element across mount, unmount, and reassignment, so it is safe to use on
* conditionally rendered children. `node` is a stable ref for reading the
* current element (e.g. for live measurements in event handlers).
*
* @param measurementKey - Value whose changes may alter the element's rendered width.
*/
export function useIsOverflowing<T extends HTMLElement = HTMLElement>(): {
export function useIsOverflowing<T extends HTMLElement = HTMLElement>(
measurementKey?: unknown
): {
ref: (node: T | null) => void
node: React.RefObject<T | null>
isOverflowing: boolean
} {
const [isOverflowing, setIsOverflowing] = React.useState(false)
const nodeRef = React.useRef<T | null>(null)
const observerRef = React.useRef<ResizeObserver | null>(null)
const measure = React.useCallback(() => {
const element = nodeRef.current
@@ -252,27 +335,31 @@ export function useIsOverflowing<T extends HTMLElement = HTMLElement>(): {
const ref = React.useCallback(
(node: T | null) => {
observerRef.current?.disconnect()
observerRef.current = null
unobserveOverflow(nodeRef.current)
nodeRef.current = node
if (!node) return
measure()
const observer = new ResizeObserver(measure)
observer.observe(node)
observerRef.current = observer
observeOverflow(node, measure)
},
[measure]
)
React.useEffect(() => {
window.addEventListener('resize', measure)
const element = nodeRef.current
if (!element) return undefined
const usesResizeObserver = observeOverflow(element, measure)
if (!usesResizeObserver) window.addEventListener('resize', measure)
return () => {
window.removeEventListener('resize', measure)
observerRef.current?.disconnect()
if (!usesResizeObserver) window.removeEventListener('resize', measure)
unobserveOverflow(element)
}
}, [measure])
React.useLayoutEffect(() => {
measure()
}, [measure, measurementKey])
return { ref, node: nodeRef, isOverflowing }
}
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { FloatingTooltip, isTextClipped, useFloatingTooltip } from '@sim/emcn'
import { OverflowText } from '@sim/emcn'
import type { CodePreview } from '../types'
import { CodeHoverCard } from './code-hover-card'
@@ -41,14 +41,9 @@ export function OverflowSpan({ value, className, codePreview, children }: Overfl
/** Plain clipped text keeps the platform tooltip behavior unchanged. */
function TextOverflowSpan({ value, className, children }: Omit<OverflowSpanProps, 'codePreview'>) {
const { state, handlers } = useFloatingTooltip(isTextClipped)
return (
<>
<span className={className} {...handlers}>
{children ?? value}
</span>
<FloatingTooltip label={value} state={state} />
</>
<OverflowText label={value} className={className}>
{children}
</OverflowText>
)
}
@@ -112,14 +112,13 @@ export function SubBlockRowView({
className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize'
/>
{displayValue !== undefined && (
<span
<OverflowSpan
value={displayValue}
className={cn(
'flex-1 truncate text-right text-[var(--text-primary)] text-sm',
isMonospace && 'font-mono'
)}
>
{displayValue}
</span>
/>
)}
</div>
)