diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index a1ab8b5b93a..2d5babc15df 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -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 diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 1670b0bd513..188fc2b1810 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -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. diff --git a/.cursor/rules/sim-styling.mdc b/.cursor/rules/sim-styling.mdc index 7479c5676e0..d79cc9fd04e 100644 --- a/.cursor/rules/sim-styling.mdc +++ b/.cursor/rules/sim-styling.mdc @@ -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. diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 9f44f508e2e..8247cfe24d7 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -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({ )}
- {name} + {provenance ? ( {provenance} ) : null} diff --git a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx index 0cc2baa0d99..57f5346360c 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx @@ -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 (
- {title} + {showStatusDot && (
)} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index b1fb3d08812..25286e6e8c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -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)]' > - {w.name} + ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 029c33f90f7..527c6d4cf3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -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 ( <> - {item.name} + ) } function DefaultDropdownItem({ item }: DropdownItemRenderProps) { - return {item.name} + return } function FileDropdownItem({ item }: DropdownItemRenderProps) { @@ -68,7 +68,7 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) { return ( <> - {item.name} + ) } @@ -77,7 +77,7 @@ function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon return ( <> - {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 {item.name} + if (!Icon) return return ( <> - {item.name} + ) } @@ -115,7 +115,7 @@ function LogDropdownItem({ item }: DropdownItemRenderProps) { return ( <> - {workflowName} + {statusColor && (
{enabledDisplayLabel} + } showAllOption allOptionLabel='All' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 7d6566de35d..faa56a106b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -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) {
- {config.name} - {config.description} + +
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx index bbca18b7547..2121d040e41 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx @@ -60,6 +60,9 @@ vi.mock('@sim/emcn', () => ({ DropdownMenuContent: ({ children }: { children?: ReactNode }) =>
{children}
, DropdownMenuItem: ({ children }: { children?: ReactNode }) =>
{children}
, DropdownMenuTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => ( + {children ?? label} + ), Tooltip: { Root: ({ children }: { children?: ReactNode }) =>
{children}
, Trigger: ({ children }: { children?: ReactNode }) =>
{children}
, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index e5efda502a3..443a7cc4b20 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -11,6 +11,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + OverflowText, Tooltip, } from '@sim/emcn' import { @@ -362,7 +363,7 @@ function ConnectorCard({
- {connectorDef?.name || connector.connectorType} + {syncInFlight && } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index 62b0934018f..83a53d724c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -401,7 +401,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
- Runs + Runs {globalDetails && ( {globalDetails.totalRuns} @@ -426,7 +426,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
- Errors + Errors {globalDetails && ( {globalDetails.totalErrors} @@ -451,7 +451,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
- Latency + Latency {globalDetails && ( {formatLatency(globalDetails.avgLatency)} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 6f78db43a6d..8be63a40c5c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -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={ - + {selectedStatusColor && (
)} - {statusDisplayLabel} + } showAllOption @@ -1488,12 +1494,17 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr multiSelectValues={workflowIds} onMultiSelectChange={setWorkflowIds} placeholder='All workflows' + overlayLabel={workflowDisplayLabel} overlayContent={ - + {selectedWorkflow && ( )} - {workflowDisplayLabel} + } searchable @@ -1512,8 +1523,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr multiSelectValues={folderIds} onMultiSelectChange={setFolderIds} placeholder='All folders' + overlayLabel={folderDisplayLabel} overlayContent={ - {folderDisplayLabel} + } searchable searchPlaceholder='Search folders...' @@ -1531,8 +1547,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr multiSelectValues={triggers} onMultiSelectChange={setTriggers} placeholder='All triggers' + overlayLabel={triggerDisplayLabel} overlayContent={ - {triggerDisplayLabel} + } searchable searchPlaceholder='Search triggers...' @@ -1550,8 +1571,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr value={timeRange} onChange={handleTimeRangeChange} placeholder='All time' + overlayLabel={timeDisplayLabel} overlayContent={ - {timeDisplayLabel} + } className='w-full' maxHeight={320} @@ -1627,7 +1653,7 @@ function SuggestionButton({ }} >
-
{suggestion.label}
+ {showCategory && suggestion.value !== suggestion.label && (
{suggestion.category === 'workflow' || suggestion.category === 'folder' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx index cf39c45cd98..860cbb3c53f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx @@ -65,14 +65,14 @@ function ActivityLogRow({ {typeof entry.description === 'string' ? ( - + ) : ( entry.description )} {typeof entry.actor === 'string' ? ( - + ) : ( {entry.actor} )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 98652eff6ab..4cbe2f03ed2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -11,6 +11,7 @@ import { ChipModalField, ChipSelect, Label, + OverflowText, Search, Switch, toast, @@ -219,8 +220,8 @@ export function Admin() { const renderUserRow = (u: AdminUser) => (
- {u.name || '—'} - {u.email} + + {u.role || 'user'} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index 4b75e1440d0..e6a32a8d2f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -48,6 +48,9 @@ vi.mock('@sim/emcn', () => ({ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => ( ), + OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => ( + {children ?? label} + ), Switch: ({ checked, disabled, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index b0fc26e75d9..a930f107b05 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -8,6 +8,7 @@ import { chipVariants, cn, Label, + OverflowText, Switch, Tooltip, toast, @@ -480,8 +481,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
- {planTitle} - {priceText} + +
{!subscription.isEnterprise && diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx index b334e010c14..88c7238866b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -34,6 +34,9 @@ vi.mock('@sim/emcn', () => ({
) : null, Key: () => , + OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => ( + {children ?? label} + ), Plus: () => , toast: mockToast, })) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx index b3fc1d0e577..e29cee913d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx @@ -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) => (
- - {key.name ?? 'Unnamed key'} - - - {key.maskedKey} - + +
{(capabilities.update || capabilities.delete) && (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx index 8966401f6b5..718e3342866 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx @@ -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 (
- {email} + {status} {roleControl} {menu} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index f05e8b3c73e..3753caed70f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -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 `
)} -
- {title} - {description != null && ( - - {description} - +
+ {typeof title === 'string' ? ( + + ) : ( + {title} )} + {description != null && + (typeof description === 'string' ? ( + + + + ) : ( + + {description} + + ))}
) @@ -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 ? ( -
+
{badge} {trailing != null &&
{trailing}
} {navigable && } @@ -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 (
+ className={controlClass} + > + {cluster} + ) : ( )} -
{cluster}
{end}
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx index 2c2d91637aa..5f2b20d33c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx @@ -10,6 +10,7 @@ import { ChipConfirmModal, ChipInput, cn, + OverflowText, Search, Skeleton, } from '@sim/emcn' @@ -182,18 +183,20 @@ export function TransferOwnershipDialog({
- - {m.name} - + {m.role === 'admin' && ( Admin )}
-
- {m.email} -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx index 1e59583d091..03fd1f8bf56 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx @@ -63,7 +63,7 @@ export function SecretUsagePanel({ workspaceId, secretName, scope }: SecretUsage */ description: ( - + {entry.useCount > 1 && ( {entry.useCount} runs )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx index 42b51b0db3a..b142da251b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx @@ -9,6 +9,7 @@ import { CollapsibleCard, FieldDivider, Label, + OverflowText, Switch, toast, } from '@sim/emcn' @@ -242,7 +243,7 @@ export function EnrichmentConfig({ > -

{enrichment.name}

+
)} -

{title}

+

+ +

) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index ae2ce8f6fa0..e543ac8e94c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -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({ - {entry.name} + {entry.pinned && } @@ -106,7 +107,7 @@ function CollapsedFlyoutRows({ return ( - {entry.name} + {entry.pinned && } ) @@ -116,7 +117,7 @@ function CollapsedFlyoutRows({ - {entry.name} + {entry.pinned && } @@ -463,7 +464,7 @@ export function CollapsedWorkflowFlyoutItem({ : undefined } > - {workflow.name} + ) @@ -520,7 +521,7 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) { return ( - {folder.name} + ) } @@ -529,7 +530,7 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) { - {folder.name} + {interleaveSiblings(folder.children, folderWorkflows).map((child) => diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index c67b2089917..4b19b943f38 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -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) { {folderPath.length > 1 && ( <> - - {folderPath.slice(0, -1).join(' / ')} - + / )} - {folderPath[folderPath.length - 1]} + ) } @@ -91,10 +93,13 @@ export const MemoizedCommandItem = memo( return ( - + {labelPrefix && {labelPrefix} } {label} - + {meta ? : null} ) @@ -127,7 +132,7 @@ export const MemoizedActionItem = memo( return ( - {name} + {meta ? : shortcut ? : null} ) @@ -161,7 +166,7 @@ export const MemoizedWorkflowItem = memo(
- {name} + {isCurrent && (current)} {meta ? ( @@ -199,7 +204,7 @@ export const MemoizedFileItem = memo(
- {name} + {meta ? ( @@ -229,7 +234,7 @@ export const MemoizedTaskItem = memo( } & ResultMetaProps) { return ( - {name} + {meta && } ) @@ -278,7 +283,7 @@ export const MemoizedWorkspaceItem = memo(
)} - {name} + {isCurrent && (current)} {meta && } @@ -312,7 +317,7 @@ export const MemoizedPageItem = memo( return ( - {name} + {meta ? : shortcut ? : null} ) @@ -344,7 +349,7 @@ export const MemoizedIconItem = memo( - {name} + {meta ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 5bebaba2fb6..60c26715a5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -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({ - Back + Back
@@ -396,9 +397,11 @@ export function SettingsSidebar({ const content = ( <> - - {item.label} - + {isLocked && ( Max diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx index 41fa904a83e..3f32ddbbaae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx @@ -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 = ( - - {title} - + ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 4877ba28400..78b0987f7f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -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 /> ) : (
-
- - {folder.name} - +
+
{folder.locked && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index 009052d2d44..8992c70e3fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -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' /> ) : ( -
- {workflow.name} +
+
)} {!isEditing && } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx index 541db1b29e5..2a63f4f848d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx @@ -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) => (
-

{invitationLabel(inv)}

-

- {invitationSubLabel(inv)} -

+ +
)} - - {workspace.name} - + {/* 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. */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 4d06bf87c44..d8c707f0e81 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -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} > -
{chat.name}
+ {chat.id !== 'new' && (
{showStatusDot && ( diff --git a/apps/sim/components/permissions/member-row.tsx b/apps/sim/components/permissions/member-row.tsx index 8e184e5633a..50308f7b05b 100644 --- a/apps/sim/components/permissions/member-row.tsx +++ b/apps/sim/components/permissions/member-row.tsx @@ -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({
- - {member.userName || member.userEmail || member.userId} - - - {member.userEmail || member.userId} - + +
diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index b64bf54a1b2..b51c8399594 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -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
({ - Back + Back )} @@ -175,9 +182,10 @@ export function SettingsSidebar
({ }} > - - {item.label} - + {item.locked && ( Plan diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 8e599fa4de6..93352103f8d 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -17,6 +17,7 @@ import { ChipTag, cn, Info, + OverflowText, Search, Skeleton, Switch, @@ -305,10 +306,14 @@ function AddMembersModal({
-
{name}
-
- {email} -
+ +
) @@ -428,7 +433,7 @@ function CheckboxGrid({ checked={isAllowed(item.id)} onCheckedChange={() => onToggle(item.id)} /> - {item.label} + ) })} @@ -524,7 +529,7 @@ function ProviderRow({ isProviderAllowed ? 'cursor-pointer' : 'cursor-default opacity-60' )} > - {providerName} + {isProviderAllowed && deniedCount > 0 && ( {deniedCount} blocked @@ -611,7 +616,7 @@ function BlockToolRow({ !isBlockAllowed && 'opacity-60' )} > - {block.name} + {/* 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 && }
- {block.name} + {block.sourceWorkspaceName && ( {block.sourceWorkspaceName} diff --git a/apps/sim/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx index a7942f2a4bf..62f41b4c16d 100644 --- a/apps/sim/ee/audit-logs/components/audit-logs.tsx +++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx @@ -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={ - {timeDisplayLabel} + } maxHeight={320} align='start' diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 10b69a4daad..15637672d8c 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -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)} /> - {entity.label} + ) })} diff --git a/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx b/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx index 8ced29f7330..29f5d073344 100644 --- a/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx @@ -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)} > - + 0 ? `${selectedCount}/${total}` : total})`} + > {folder.name}{' '} ({selectedCount > 0 ? `${selectedCount}/${total}` : total}) - + onToggle([workflow.id], value === true)} disabled={disabled} /> - {workflow.name} + ) } diff --git a/apps/sim/ee/workspace-forking/components/fork-file-tree/fork-file-tree.tsx b/apps/sim/ee/workspace-forking/components/fork-file-tree/fork-file-tree.tsx index 695edefa6c9..2cc7f60bda6 100644 --- a/apps/sim/ee/workspace-forking/components/fork-file-tree/fork-file-tree.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-file-tree/fork-file-tree.tsx @@ -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)} > - - {folder.name} ({selectedCount > 0 ? `${selectedCount}/${total}` : total}) - + 0 ? `${selectedCount}/${total}` : total})`} + className='flex-1' + /> onToggle(file.id, value === true)} disabled={disabled} /> - {file.label} + ) } diff --git a/apps/sim/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker.tsx b/apps/sim/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker.tsx index 3a6ddd0f7cc..890cb0c78ff 100644 --- a/apps/sim/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker.tsx @@ -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)} > - - {label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total}) - + 0 ? `${selectedCount}/${total}` : total})`} + className='flex-1' + /> onToggleItem(item.id, checked === true)} disabled={disabled} /> - {item.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)} > - - {label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total}) - + 0 ? `${selectedCount}/${total}` : total})`} + className='flex-1' + />
- +
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)]' > - {group.label} + {badge.label} @@ -733,14 +736,11 @@ function TriggerMappingRow({ controller, mapping }: TriggerMappingRowProps) { return (
- {/* One inner span, so the name and its "in " 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). */}
{decidable ? ( diff --git a/packages/emcn/src/components/combobox/combobox.dom.test.tsx b/packages/emcn/src/components/combobox/combobox.dom.test.tsx index c67131c91ab..ac9d87b9114 100644 --- a/packages/emcn/src/components/combobox/combobox.dom.test.tsx +++ b/packages/emcn/src/components/combobox/combobox.dom.test.tsx @@ -57,6 +57,19 @@ afterEach(() => { }) describe('Combobox onOpenChange', () => { + it('uses the overlay label for the interactive overflow layer', () => { + render( + 2 selected} + 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() diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index 68e33d12136..a2298f187b9 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -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, @@ -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 && } - - {selectedOption?.label} - + )}
@@ -716,15 +721,18 @@ const Combobox = memo( onClick={handleToggle} onKeyDown={handleKeyDown} > - - {multiSelectLabel ?? (selectedOption ? selectedOption.label : placeholder)} - + /> )} - - {option.label} - + {option.suffixElement} {multiSelect && isSelected && ( @@ -935,9 +944,10 @@ const Combobox = memo( : chipHoverSurfaceClass )} > - - {allOptionLabel} - +
)} {filteredOptions.map((option, index) => { @@ -973,9 +983,10 @@ const Combobox = memo( {option.iconElement ? option.iconElement : OptionIcon && } - - {option.label} - + {option.suffixElement} {multiSelect && isSelected && ( diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx index 1d4b9cf85eb..f683ddf5141 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx @@ -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(Run empty or failed cells on 2 rows) 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(Show archived workflows) 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') }) }) diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index 9dbdfeda815..0198bda51c5 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -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 `` 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 `` 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 = [] const flushText = () => { if (text.length === 0) return rebuilt.push( - + {text} - + ) text = [] } @@ -212,7 +215,7 @@ const DropdownMenuSubTrigger = React.forwardRef< )} {...props} > - {withEllipsizedLabel(children)} + {withOverflowLabel(children)} ) @@ -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< - {withEllipsizedLabel(children)} + {withOverflowLabel(children)} )) DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName @@ -411,7 +414,7 @@ const DropdownMenuRadioItem = React.forwardRef< - {withEllipsizedLabel(children)} + {withOverflowLabel(children)} )) DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 7acf22d12ce..f0830e272ad 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -163,6 +163,11 @@ export { useModalDismissDisabled, useNativeSurfaceOcclusionReady, } from './modal/modal' +export { + OverflowText, + type OverflowTextProps, + overflowTextFadeClass, +} from './overflow-text/overflow-text' export { Popover, PopoverAnchor, diff --git a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx new file mode 100644 index 00000000000..d057c1019ab --- /dev/null +++ b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx @@ -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() + ) + const label = host.querySelector('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()) + const label = host.querySelector('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()) + const label = host.querySelector('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( + + ) + ) + const button = host.querySelector('button') + const label = host.querySelector('[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( + + Workflow production + + ) + ) + const label = host.querySelector('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( + <> + + + + ) + ) + + expect(resizeObserverCount).toBe(1) + }) + + it('remeasures when an existing label changes', () => { + act(() => root.render()) + const label = host.querySelector('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()) + expect(label.className).toContain('mask-image:linear-gradient') + + Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 60 }) + act(() => root.render()) + expect(label.className).not.toContain('mask-image:linear-gradient') + }) + + it('remeasures when decorated content changes without changing the label', () => { + act(() => + root.render( + + Workflow production + + ) + ) + const label = host.querySelector('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( + + Workflow production + + ) + ) + + expect(label.className).toContain('mask-image:linear-gradient') + }) + + it('remeasures when loaded fonts change text width', () => { + act(() => root.render()) + const label = host.querySelector('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( + + + + ) + ) + const label = host.querySelector('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') + }) +}) diff --git a/packages/emcn/src/components/overflow-text/overflow-text.tsx b/packages/emcn/src/components/overflow-text/overflow-text.tsx new file mode 100644 index 00000000000..f845d49dc23 --- /dev/null +++ b/packages/emcn/src/components/overflow-text/overflow-text.tsx @@ -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(children ?? label) + const tooltipEligible = tooltipEnabled && label.length > 0 && (Boolean(showWhen) || isOverflowing) + const getFocusTarget = useCallback(() => { + if (focusTarget !== 'nearest-interactive') return null + return ( + node.current?.closest( + '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 ( + <> + + {children ?? label} + + + + ) +}) diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index 2c727bdfa20..8ede2a176f6 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -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(function Tab( > {tab.icon} {!tab.pinned && ( - + {tab.title} )} diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 601ec570d67..321f912caeb 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -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(() => { - 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(() => { 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 void>() +const observedOverflowElements = new Set() +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(): { +export function useIsOverflowing( + measurementKey?: unknown +): { ref: (node: T | null) => void node: React.RefObject isOverflowing: boolean } { const [isOverflowing, setIsOverflowing] = React.useState(false) const nodeRef = React.useRef(null) - const observerRef = React.useRef(null) const measure = React.useCallback(() => { const element = nodeRef.current @@ -252,27 +335,31 @@ export function useIsOverflowing(): { 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 } } diff --git a/packages/workflow-renderer/src/lib/overflow-span.tsx b/packages/workflow-renderer/src/lib/overflow-span.tsx index 9ddfdc11d0e..608b10b3434 100644 --- a/packages/workflow-renderer/src/lib/overflow-span.tsx +++ b/packages/workflow-renderer/src/lib/overflow-span.tsx @@ -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) { - const { state, handlers } = useFloatingTooltip(isTextClipped) - return ( - <> - - {children ?? value} - - - + + {children} + ) } diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx index 2f7eec00e64..112fd8eccae 100644 --- a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx @@ -112,14 +112,13 @@ export function SubBlockRowView({ className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize' /> {displayValue !== undefined && ( - - {displayValue} - + /> )}
)