From a0c5ed5c609e02675585b07a64768942786c4140 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 14:07:38 -0700 Subject: [PATCH 01/11] improvement(ui): standardize overflow text --- .claude/rules/emcn-components.md | 1 + .claude/rules/sim-styling.md | 6 + .cursor/rules/sim-styling.mdc | 6 + apps/sim/app/f/[token]/public-file-view.tsx | 4 +- .../conversation-list-item.tsx | 4 +- .../components/floating-overflow-text.tsx | 50 +------ .../resource-header/resource-header.tsx | 7 +- .../resource-options/resource-options.tsx | 4 +- .../workspace/[workspaceId]/files/files.tsx | 5 +- .../components/agent-group/agent-group.tsx | 6 +- .../resource-content/resource-content.tsx | 4 +- .../resource-registry/resource-registry.tsx | 16 +- .../knowledge/[id]/[documentId]/document.tsx | 11 +- .../add-connector-modal.tsx | 8 +- .../connectors-section/connectors-section.tsx | 3 +- .../logs/components/dashboard/dashboard.tsx | 6 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 13 +- .../components/activity-log/activity-log.tsx | 4 +- .../settings/components/admin/admin.tsx | 5 +- .../settings/components/billing/billing.tsx | 5 +- .../byok/byok-provider-keys-modal.tsx | 23 ++- .../components/member-list/member-list.tsx | 4 +- .../settings-resource-row.tsx | 24 ++- .../transfer-ownership-dialog.tsx | 15 +- .../secret-usage-panel/secret-usage-panel.tsx | 2 +- .../enrichments-sidebar/enrichment-config.tsx | 3 +- .../components/select-field/select-pill.tsx | 4 +- .../components/views-menu/views-menu.tsx | 5 +- .../workflow-sidebar/workflow-sidebar.tsx | 3 +- .../workspace/[workspaceId]/tables/tables.tsx | 6 +- .../output-select/output-select.tsx | 4 +- .../general/components/versions.tsx | 3 +- .../components/tag-dropdown/tag-dropdown.tsx | 20 ++- .../panel/components/toolbar/toolbar.tsx | 3 +- .../collapsed-sidebar-menu.tsx | 13 +- .../command-items/command-items.tsx | 31 ++-- .../settings-sidebar/settings-sidebar.tsx | 10 +- .../sidebar-section/sidebar-section.tsx | 9 +- .../components/folder-item/folder-item.tsx | 21 ++- .../workflow-item/workflow-item.tsx | 9 +- .../view-invitations-modal.tsx | 22 ++- .../workspace-header/workspace-header.tsx | 8 +- .../w/components/sidebar/sidebar.tsx | 3 +- .../sim/components/permissions/member-row.tsx | 16 +- .../components/settings/settings-sidebar.tsx | 18 ++- .../components/group-detail.tsx | 21 ++- .../ee/audit-logs/components/audit-logs.tsx | 3 +- .../components/data-retention-settings.tsx | 3 +- .../fork-excluded-workflows.tsx | 10 +- .../fork-file-tree/fork-file-tree.tsx | 11 +- .../fork-resource-picker.tsx | 18 ++- .../components/fork-sync/fork-sync-view.tsx | 14 +- .../emcn/src/components/combobox/combobox.tsx | 40 ++--- .../dropdown-menu/dropdown-menu.test.tsx | 12 +- .../dropdown-menu/dropdown-menu.tsx | 29 ++-- packages/emcn/src/components/index.ts | 5 + .../overflow-text/overflow-text.test.tsx | 140 ++++++++++++++++++ .../overflow-text/overflow-text.tsx | 65 ++++++++ .../src/components/tab-strip/tab-strip.tsx | 10 +- .../emcn/src/components/tooltip/tooltip.tsx | 40 ++++- .../src/lib/overflow-span.tsx | 13 +- .../src/workflow-block/sub-block-row-view.tsx | 7 +- 62 files changed, 600 insertions(+), 288 deletions(-) create mode 100644 packages/emcn/src/components/overflow-text/overflow-text.test.tsx create mode 100644 packages/emcn/src/components/overflow-text/overflow-text.tsx diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index a1ab8b5b93a..9bdec05dd13 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. ## Modal keyboard defaults diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 1670b0bd513..0d6cd6afaa2 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -50,6 +50,12 @@ 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. + +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..32cab5e3b01 100644 --- a/.cursor/rules/sim-styling.mdc +++ b/.cursor/rules/sim-styling.mdc @@ -44,6 +44,12 @@ 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. + +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.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..9cc055fabb9 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, @@ -1471,7 +1472,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }} /> )} - {statusDisplayLabel} + } showAllOption @@ -1493,7 +1494,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr {selectedWorkflow && ( )} - {workflowDisplayLabel} + } searchable @@ -1513,7 +1514,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr onMultiSelectChange={setFolderIds} placeholder='All folders' overlayContent={ - {folderDisplayLabel} + } searchable searchPlaceholder='Search folders...' @@ -1532,7 +1533,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr onMultiSelectChange={setTriggers} placeholder='All triggers' overlayContent={ - {triggerDisplayLabel} + } searchable searchPlaceholder='Search triggers...' @@ -1551,7 +1552,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr onChange={handleTimeRangeChange} placeholder='All time' overlayContent={ - {timeDisplayLabel} + } className='w-full' maxHeight={320} @@ -1627,7 +1628,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.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/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..25a3f2376a0 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 { @@ -157,12 +157,24 @@ export function SettingsResourceRow({
)}
- {title} - {description != null && ( - - {description} - + {typeof title === 'string' ? ( + + ) : ( + {title} )} + {description != null && + (typeof description === 'string' ? ( + + + + ) : ( + + {description} + + ))}
) 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..d39fb93641c 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,10 @@ 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..5da1da2403e 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, @@ -420,7 +421,7 @@ export function AuditLogs({ organizationId }: AuditLogsProps) { onChange={handleTimeRangeChange} placeholder='All time' 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.tsx b/packages/emcn/src/components/combobox/combobox.tsx index 68e33d12136..adbd29e1419 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( @@ -677,9 +678,10 @@ const Combobox = memo( ) : ( <> {SelectedIcon && } - - {selectedOption?.label} - + )}
@@ -716,15 +718,16 @@ const Combobox = memo( onClick={handleToggle} onKeyDown={handleKeyDown} > - - {multiSelectLabel ?? (selectedOption ? selectedOption.label : placeholder)} - + /> )} - - {option.label} - + {option.suffixElement} {multiSelect && isSelected && ( @@ -935,9 +939,10 @@ const Combobox = memo( : chipHoverSurfaceClass )} > - - {allOptionLabel} - +
)} {filteredOptions.map((option, index) => { @@ -973,9 +978,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..4d515bd1c16 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') }) diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index 9dbdfeda815..372a2c1938b 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]:overflow-hidden [&>span]:text-clip' /** * 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. Consumers that already wrap their label in a `` + * are unaffected — the row's `[&>span]` rule truncates those in place. * * 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..9509787b1f6 --- /dev/null +++ b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx @@ -0,0 +1,140 @@ +/** + * @vitest-environment jsdom + */ +import { act } 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 + +beforeEach(() => { + resizeObserverCount = 0 + ;(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() + 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('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' + ) + }) + + 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('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) + }) +}) 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..65f68af729b --- /dev/null +++ b/packages/emcn/src/components/overflow-text/overflow-text.tsx @@ -0,0 +1,65 @@ +'use client' + +import type { ReactNode } from 'react' +import { memo } 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 +} + +/** + * 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, +}: OverflowTextProps) { + const { ref: textRef, node, isOverflowing } = useIsOverflowing() + const { state, handlers } = useFloatingTooltip(() => { + const element = node.current + if (!element || label.length === 0) return false + return Boolean(showWhen) || isTextClipped(element) + }) + + 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..1c2a8f851c3 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -227,9 +227,35 @@ export function useFloatingTooltip( return { state, handlers } } +const overflowMeasureByElement = new WeakMap void>() +const observedOverflowElements = new Set() +let sharedOverflowObserver: ResizeObserver | null = null + +function observeOverflow(element: Element, measure: () => void): boolean { + if (typeof ResizeObserver === 'undefined') return false + sharedOverflowObserver ??= new ResizeObserver((entries) => { + for (const entry of entries) overflowMeasureByElement.get(entry.target)?.() + }) + overflowMeasureByElement.set(element, measure) + observedOverflowElements.add(element) + 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 + } +} + /** * Tracks whether an element's text is horizontally clipped, re-measuring via a - * `ResizeObserver` and window resizes. + * 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 @@ -243,7 +269,7 @@ export function useIsOverflowing(): { } { const [isOverflowing, setIsOverflowing] = React.useState(false) const nodeRef = React.useRef(null) - const observerRef = React.useRef(null) + const usesResizeObserverRef = React.useRef(false) const measure = React.useCallback(() => { const element = nodeRef.current @@ -252,24 +278,22 @@ 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 + usesResizeObserverRef.current = observeOverflow(node, measure) }, [measure] ) React.useEffect(() => { + if (usesResizeObserverRef.current) return () => unobserveOverflow(nodeRef.current) window.addEventListener('resize', measure) return () => { window.removeEventListener('resize', measure) - observerRef.current?.disconnect() + unobserveOverflow(nodeRef.current) } }, [measure]) 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} - + /> )}
) From 43070a48476ad5da29e9ee9f1cc1bc13b33bf747 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 14:14:41 -0700 Subject: [PATCH 02/11] fix(ui): remeasure overflow text changes --- .../overflow-text/overflow-text.test.tsx | 17 +++++++++++++++++ .../components/overflow-text/overflow-text.tsx | 2 +- .../emcn/src/components/tooltip/tooltip.tsx | 15 ++++++++++++--- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx index 9509787b1f6..7be33ea8551 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx @@ -137,4 +137,21 @@ describe('OverflowText', () => { 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') + }) }) diff --git a/packages/emcn/src/components/overflow-text/overflow-text.tsx b/packages/emcn/src/components/overflow-text/overflow-text.tsx index 65f68af729b..9ec3cae2b48 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.tsx @@ -39,7 +39,7 @@ export const OverflowText = memo(function OverflowText({ className, showWhen, }: OverflowTextProps) { - const { ref: textRef, node, isOverflowing } = useIsOverflowing() + const { ref: textRef, node, isOverflowing } = useIsOverflowing(label) const { state, handlers } = useFloatingTooltip(() => { const element = node.current if (!element || label.length === 0) return false diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 1c2a8f851c3..36cd62ab6ce 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -254,15 +254,20 @@ function unobserveOverflow(element: Element | null) { } /** - * Tracks whether an element's text is horizontally clipped, re-measuring via a - * shared `ResizeObserver` (or window resizes when the API is unavailable). + * Tracks whether an element's text is horizontally clipped, re-measuring when + * `measurementKey` changes 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 @@ -297,6 +302,10 @@ export function useIsOverflowing(): { } }, [measure]) + React.useLayoutEffect(() => { + measure() + }, [measure, measurementKey]) + return { ref, node: nodeRef, isOverflowing } } From dd39a60316292c32cf94e0ade20e21dc32a3ac6e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 14:21:55 -0700 Subject: [PATCH 03/11] fix(ui): refresh overflow after font loads --- .../overflow-text/overflow-text.test.tsx | 33 +++++++++++++++++++ .../emcn/src/components/tooltip/tooltip.tsx | 32 +++++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx index 7be33ea8551..f93f79c6381 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx @@ -10,9 +10,20 @@ 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: Promise.resolve(fontEvents), + }) + Object.defineProperty(document, 'fonts', { + configurable: true, + value: fontEvents, + }) ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true vi.stubGlobal( 'ResizeObserver', @@ -34,6 +45,11 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()) host.remove() + if (originalFontsDescriptor) { + Object.defineProperty(document, 'fonts', originalFontsDescriptor) + } else { + Reflect.deleteProperty(document, 'fonts') + } vi.unstubAllGlobals() }) @@ -154,4 +170,21 @@ describe('OverflowText', () => { act(() => root.render()) expect(label.className).not.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') + }) }) diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 36cd62ab6ce..2619dd78277 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -230,14 +230,37 @@ export function useFloatingTooltip( 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)?.() }) - overflowMeasureByElement.set(element, measure) - observedOverflowElements.add(element) sharedOverflowObserver.observe(element) return true } @@ -250,13 +273,14 @@ function unobserveOverflow(element: Element | null) { if (observedOverflowElements.size === 0) { sharedOverflowObserver?.disconnect() sharedOverflowObserver = null + unobserveOverflowFontChanges() } } /** * Tracks whether an element's text is horizontally clipped, re-measuring when - * `measurementKey` changes and via a shared `ResizeObserver` (or window resizes - * when the API is unavailable). + * `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 From 1f7918fd74a59f5694a93064915bcf2c9b63d735 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 14:27:43 -0700 Subject: [PATCH 04/11] fix(ui): stabilize overflow font test --- .../emcn/src/components/overflow-text/overflow-text.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx index f93f79c6381..b11804bfc73 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx @@ -18,7 +18,7 @@ beforeEach(() => { const fontEvents = new EventTarget() Object.defineProperty(fontEvents, 'ready', { configurable: true, - value: Promise.resolve(fontEvents), + value: new Promise(() => {}), }) Object.defineProperty(document, 'fonts', { configurable: true, From f8f7ce4ec98efe907f1bd0f759d6d0cee8e2ebfc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 14:39:58 -0700 Subject: [PATCH 05/11] fix(ui): preserve overflow text interactions --- .claude/rules/emcn-components.md | 2 +- .claude/rules/sim-styling.md | 2 + .cursor/rules/sim-styling.mdc | 2 + .../workspace/[workspaceId]/files/files.tsx | 14 ++++++- .../knowledge/[id]/[documentId]/document.tsx | 7 +++- .../app/workspace/[workspaceId]/logs/logs.tsx | 39 +++++++++++++++---- .../settings-resource-row.tsx | 32 ++++++++------- .../workflow-sidebar/workflow-sidebar.tsx | 4 +- .../workspace/[workspaceId]/tables/tables.tsx | 14 ++++++- .../output-select/output-select.tsx | 7 +++- .../settings-sidebar/settings-sidebar.tsx | 1 + .../ee/audit-logs/components/audit-logs.tsx | 7 +++- .../components/combobox/combobox.dom.test.tsx | 13 +++++++ .../emcn/src/components/combobox/combobox.tsx | 9 ++++- .../dropdown-menu/dropdown-menu.test.tsx | 1 + .../dropdown-menu/dropdown-menu.tsx | 6 +-- .../overflow-text/overflow-text.test.tsx | 16 ++++++++ .../overflow-text/overflow-text.tsx | 6 ++- 18 files changed, 145 insertions(+), 37 deletions(-) diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 9bdec05dd13..2d5babc15df 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -32,7 +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. +- **`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 0d6cd6afaa2..188fc2b1810 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -54,6 +54,8 @@ Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), n 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 diff --git a/.cursor/rules/sim-styling.mdc b/.cursor/rules/sim-styling.mdc index 32cab5e3b01..d79cc9fd04e 100644 --- a/.cursor/rules/sim-styling.mdc +++ b/.cursor/rules/sim-styling.mdc @@ -48,6 +48,8 @@ Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), n 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 diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index e8fe51d2f6a..f81334b9512 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1969,8 +1969,13 @@ export function Files() { multiSelect multiSelectValues={typeFilter} onMultiSelectChange={setTypeFilter} + overlayLabel={typeDisplayLabel} overlayContent={ - + } showAllOption allOptionLabel='All' @@ -1988,8 +1993,13 @@ export function Files() { multiSelect multiSelectValues={sizeFilter} onMultiSelectChange={setSizeFilter} + overlayLabel={sizeDisplayLabel} overlayContent={ - + } showAllOption allOptionLabel='All' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 1265fff5410..e253143adf0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -753,8 +753,13 @@ export function Document({ setEnabledFilter(values) setSelectedChunks(new Set()) }} + overlayLabel={enabledDisplayLabel} overlayContent={ - + } showAllOption allOptionLabel='All' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 9cc055fabb9..8be63a40c5c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -1464,15 +1464,20 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr multiSelectValues={selectedStatuses} onMultiSelectChange={handleStatusChange} placeholder='All statuses' + overlayLabel={statusDisplayLabel} overlayContent={ - + {selectedStatusColor && (
)} - + } showAllOption @@ -1489,12 +1494,17 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr multiSelectValues={workflowIds} onMultiSelectChange={setWorkflowIds} placeholder='All workflows' + overlayLabel={workflowDisplayLabel} overlayContent={ - + {selectedWorkflow && ( )} - + } searchable @@ -1513,8 +1523,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr multiSelectValues={folderIds} onMultiSelectChange={setFolderIds} placeholder='All folders' + overlayLabel={folderDisplayLabel} overlayContent={ - + } searchable searchPlaceholder='Search folders...' @@ -1532,8 +1547,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr multiSelectValues={triggers} onMultiSelectChange={setTriggers} placeholder='All triggers' + overlayLabel={triggerDisplayLabel} overlayContent={ - + } searchable searchPlaceholder='Search triggers...' @@ -1551,8 +1571,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr value={timeRange} onChange={handleTimeRangeChange} placeholder='All time' + overlayLabel={timeDisplayLabel} overlayContent={ - + } className='w-full' maxHeight={320} 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 25a3f2376a0..c37a96ebd82 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 @@ -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 `
)} -
+
{typeof title === 'string' ? ( ) : ( @@ -186,7 +186,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 && } @@ -210,14 +210,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]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index db1f887e077..762f158a783 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -764,7 +764,9 @@ export function WorkflowSidebarBody({ )} - +

+ +

) : null, Key: () => , + OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => ( + {children ?? label} + ), Plus: () => , toast: mockToast, })) From 3f25fdf013c69618a2c055b453c5973e60b29848 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 14:58:08 -0700 Subject: [PATCH 07/11] fix(ui): keep overflow tooltips in sync --- .../settings-resource-row.tsx | 12 +- .../overflow-text/overflow-text.test.tsx | 32 +++++- .../overflow-text/overflow-text.tsx | 17 ++- .../emcn/src/components/tooltip/tooltip.tsx | 105 +++++++++++------- 4 files changed, 120 insertions(+), 46 deletions(-) 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 c37a96ebd82..3c42d934392 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,4 +1,4 @@ -import { type ReactNode, useId } from 'react' +import { type ReactNode, useId, useState } from 'react' import { cn, OverflowText } from '@sim/emcn' import { ArrowRight } from '@sim/emcn/icons' import Link from 'next/link' @@ -135,8 +135,10 @@ export function SettingsResourceRow({ flush = false, disabled = false, }: SettingsResourceRowProps) { + const [control, setControl] = useState(null) const describedById = useId() const isTile = iconVariant === 'tile' + const isActivatable = !disabled && Boolean(onClick || href) const cluster = ( <> {icon == null ? null : iconVariant === 'custom' ? ( @@ -158,7 +160,11 @@ export function SettingsResourceRow({ )}
{typeof title === 'string' ? ( - + ) : ( {title} )} @@ -226,6 +232,7 @@ export function SettingsResourceRow({ > {href ? ( ) : ( + + + ) + } + + 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( diff --git a/packages/emcn/src/components/overflow-text/overflow-text.tsx b/packages/emcn/src/components/overflow-text/overflow-text.tsx index c102523e81a..78cf9438488 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.tsx @@ -25,6 +25,8 @@ export interface OverflowTextProps { showWhen?: boolean /** Whether the full-value tooltip may open. Disable for visual mirror layers. */ tooltipEnabled?: boolean + /** External composite control whose keyboard focus should reveal the tooltip. */ + focusTarget?: HTMLElement | null } /** @@ -41,13 +43,18 @@ export const OverflowText = memo(function OverflowText({ className, showWhen, tooltipEnabled = true, + focusTarget, }: OverflowTextProps) { const { ref: textRef, node, isOverflowing } = useIsOverflowing(label) - const { state, handlers } = useFloatingTooltip(() => { - const element = node.current - if (!tooltipEnabled || !element || label.length === 0) return false - return Boolean(showWhen) || isTextClipped(element) - }) + const tooltipEligible = tooltipEnabled && label.length > 0 && (Boolean(showWhen) || isOverflowing) + const { state, handlers } = useFloatingTooltip( + () => { + const element = node.current + if (!tooltipEnabled || !element || label.length === 0) return false + return Boolean(showWhen) || isTextClipped(element) + }, + { focusTarget, revalidateKey: tooltipEligible } + ) return ( <> diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 2619dd78277..bfe3130c978 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; `focusTarget` 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 + /** External control whose keyboard focus should reveal this tooltip. */ + focusTarget?: 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.focusTarget + 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.focusTarget, 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 — From 894a8dc5bc943a2921c46935fedb55bf0c26b81f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 15:06:36 -0700 Subject: [PATCH 08/11] fix(ui): preserve overflow server boundary --- .../settings-resource-row.tsx | 7 ++----- .../overflow-text/overflow-text.test.tsx | 20 +++++++------------ .../overflow-text/overflow-text.tsx | 19 ++++++++++++++---- .../emcn/src/components/tooltip/tooltip.tsx | 10 +++++----- 4 files changed, 29 insertions(+), 27 deletions(-) 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 3c42d934392..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,4 +1,4 @@ -import { type ReactNode, useId, useState } from 'react' +import { type ReactNode, useId } from 'react' import { cn, OverflowText } from '@sim/emcn' import { ArrowRight } from '@sim/emcn/icons' import Link from 'next/link' @@ -135,7 +135,6 @@ export function SettingsResourceRow({ flush = false, disabled = false, }: SettingsResourceRowProps) { - const [control, setControl] = useState(null) const describedById = useId() const isTile = iconVariant === 'tile' const isActivatable = !disabled && Boolean(onClick || href) @@ -163,7 +162,7 @@ export function SettingsResourceRow({ ) : ( {title} @@ -232,7 +231,6 @@ export function SettingsResourceRow({ > {href ? ( ) : ( - - + act(() => + root.render( + ) - } - - 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') diff --git a/packages/emcn/src/components/overflow-text/overflow-text.tsx b/packages/emcn/src/components/overflow-text/overflow-text.tsx index 78cf9438488..c299c77831c 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.tsx @@ -1,7 +1,7 @@ 'use client' import type { ReactNode } from 'react' -import { memo } from 'react' +import { memo, useCallback } from 'react' import { cn } from '../../lib/cn' import { FloatingTooltip, @@ -25,8 +25,8 @@ export interface OverflowTextProps { showWhen?: boolean /** Whether the full-value tooltip may open. Disable for visual mirror layers. */ tooltipEnabled?: boolean - /** External composite control whose keyboard focus should reveal the tooltip. */ - focusTarget?: HTMLElement | null + /** Lets the nearest interactive ancestor own keyboard focus for this label. */ + focusTarget?: 'nearest-interactive' } /** @@ -47,13 +47,24 @@ export const OverflowText = memo(function OverflowText({ }: OverflowTextProps) { const { ref: textRef, node, isOverflowing } = useIsOverflowing(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) }, - { focusTarget, revalidateKey: tooltipEligible } + { + getFocusTarget: focusTarget === 'nearest-interactive' ? getFocusTarget : undefined, + revalidateKey: tooltipEligible, + } ) return ( diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index bfe3130c978..612e7ef5e72 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -86,7 +86,7 @@ const HIDDEN_STATE: FloatingTooltipState = { * 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; `focusTarget` may supply a separate keyboard-focus trigger. + * handlers; `getFocusTarget` may supply a separate keyboard-focus trigger. */ export interface UseFloatingTooltipOptions { /** @@ -94,8 +94,8 @@ export interface UseFloatingTooltipOptions { * pointer is too close to the top of the viewport. */ preferAbove?: boolean - /** External control whose keyboard focus should reveal this tooltip. */ - focusTarget?: HTMLElement | null + /** 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 } @@ -218,7 +218,7 @@ export function useFloatingTooltip( }, [apply, hide, showFromElement, showFromPointer]) React.useEffect(() => { - const target = options.focusTarget + const target = options.getFocusTarget?.() if (!target) return undefined const show = () => { if (!canShowRef.current(target) || !isFocusVisible(target)) return @@ -231,7 +231,7 @@ export function useFloatingTooltip( target.removeEventListener('focus', show) target.removeEventListener('blur', hide) } - }, [hide, options.focusTarget, showFromElement]) + }, [hide, options.getFocusTarget, showFromElement]) React.useEffect(() => { const trigger = triggerRef.current From b2d13f406365ab77a5d3b6fd60db420cedd4107b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 15:16:02 -0700 Subject: [PATCH 09/11] test(ui): cover strict mode overflow replay --- .../overflow-text/overflow-text.test.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx index 5467c25c77a..a16801105a7 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act } from 'react' +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' @@ -227,4 +227,22 @@ describe('OverflowText', () => { 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') + }) }) From 592ee66b8841e2a0df076b6a9d6e681b9b75244f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 15:26:37 -0700 Subject: [PATCH 10/11] fix(ui): restore overflow observers on effect replay --- packages/emcn/src/components/tooltip/tooltip.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 612e7ef5e72..321f912caeb 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -327,7 +327,6 @@ export function useIsOverflowing( } { const [isOverflowing, setIsOverflowing] = React.useState(false) const nodeRef = React.useRef(null) - const usesResizeObserverRef = React.useRef(false) const measure = React.useCallback(() => { const element = nodeRef.current @@ -341,17 +340,19 @@ export function useIsOverflowing( if (!node) return measure() - usesResizeObserverRef.current = observeOverflow(node, measure) + observeOverflow(node, measure) }, [measure] ) React.useEffect(() => { - if (usesResizeObserverRef.current) return () => unobserveOverflow(nodeRef.current) - 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) - unobserveOverflow(nodeRef.current) + if (!usesResizeObserver) window.removeEventListener('resize', measure) + unobserveOverflow(element) } }, [measure]) From 901236fb503e8ce8313bca17693d423a7c3160c3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 15:33:48 -0700 Subject: [PATCH 11/11] fix(ui): harden overflow measurement defaults --- .../overflow-text/overflow-text.test.tsx | 25 +++++++++++++++++++ .../overflow-text/overflow-text.tsx | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx index a16801105a7..d057c1019ab 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx @@ -85,6 +85,7 @@ describe('OverflowText', () => { 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) @@ -211,6 +212,30 @@ describe('OverflowText', () => { 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') diff --git a/packages/emcn/src/components/overflow-text/overflow-text.tsx b/packages/emcn/src/components/overflow-text/overflow-text.tsx index c299c77831c..f845d49dc23 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.tsx @@ -45,7 +45,7 @@ export const OverflowText = memo(function OverflowText({ tooltipEnabled = true, focusTarget, }: OverflowTextProps) { - const { ref: textRef, node, isOverflowing } = useIsOverflowing(label) + 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 @@ -74,7 +74,7 @@ export const OverflowText = memo(function OverflowText({ data-overflow-text='' className={cn( className, - 'min-w-0 overflow-hidden text-clip whitespace-nowrap', + 'block min-w-0 overflow-hidden text-clip whitespace-nowrap', isOverflowing && overflowTextFadeClass )} {...handlers}