From 27f328fb5dd7456076c62d25e1423b57d2bf3338 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 8 Aug 2026 11:42:18 -0700 Subject: [PATCH 1/2] fix(tables): keep row context menu labels on one line --- .../components/context-menu/context-menu.tsx | 15 ++++++++++----- .../components/dropdown-menu/dropdown-menu.tsx | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 8c4245f33c2..b49cc729065 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -146,17 +146,22 @@ export function ContextMenu({ aria-hidden /> + {/* Wider than the 220px menu default: the row-scoped workflow labels name + both the action and the selected row count ("Run empty or failed cells + on 2 rows"), which wrapped — and so overlapped the rows beneath — at + the default width. */} e.preventDefault()} > {onAddToChat && ( <> - {addToChatLabel} + {addToChatLabel} @@ -179,19 +184,19 @@ export function ContextMenu({ {hasWorkflowColumns && onRunWorkflows && ( - {runLabel} + {runLabel} )} {hasWorkflowColumns && onRefreshWorkflows && ( - {refreshLabel} + {refreshLabel} )} {hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && ( - {stopLabel} + {stopLabel} )} @@ -212,7 +217,7 @@ export function ContextMenu({ - {deleteLabel} + {deleteLabel} diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index c7af01b215b..133fe2a447c 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -40,6 +40,14 @@ const ANIMATION_CLASSES = const MENU_ROW_HEIGHT_CLASS = 'h-[28px]' const MENU_ROW_RADIUS_CLASS = 'rounded-lg' +/** + * Rows are a fixed height, so a label that wraps overflows its row and paints + * over its neighbours instead of growing the row. Keep every row on one line: + * a bare text label is clipped at the surface edge, and a label wrapped in a + * `` (the shape long or count-bearing labels should use) ellipsizes. + */ +const MENU_ROW_SINGLE_LINE_CLASS = 'whitespace-nowrap [&>span]:min-w-0 [&>span]:truncate' + /** * Surface corner, shared by the root menu and submenus — they previously * disagreed, at 12px and 8px. @@ -110,7 +118,7 @@ const DropdownMenuSubTrigger = React.forwardRef< span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`, + `flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center gap-2 ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[state=open]:bg-[var(--surface-active)] ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`, inset && 'pl-7', className )} @@ -172,7 +180,7 @@ const DropdownMenuContent = React.forwardRef< )) DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName -const DROPDOWN_MENU_ITEM_BASE_CLASSES = `relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-pointer select-none items-center gap-2 ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]` +const DROPDOWN_MENU_ITEM_BASE_CLASSES = `relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-pointer select-none items-center gap-2 ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]` const DropdownMenuItem = React.forwardRef< React.ElementRef, @@ -252,7 +260,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< Date: Sat, 8 Aug 2026 11:50:02 -0700 Subject: [PATCH 2/2] improvement(emcn): ellipsize menu row labels instead of clipping them --- .../components/context-menu/context-menu.tsx | 23 ++-- .../dropdown-menu/dropdown-menu.test.tsx | 112 ++++++++++++++++++ .../dropdown-menu/dropdown-menu.tsx | 63 ++++++++-- 3 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index b49cc729065..e4ca0b262a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -19,6 +19,13 @@ import { } from '@sim/emcn/icons' import type { ContextMenuState } from '../../types' +/** + * Wider than the menu's 220px default. The row-scoped workflow labels name both + * the action and the selected row count ("Run empty or failed cells on 2 rows"), + * which does not fit the default width. + */ +const CONTENT_WIDTH_CLASS = 'max-w-[320px]' + interface ContextMenuProps { contextMenu: ContextMenuState onClose: () => void @@ -146,22 +153,18 @@ export function ContextMenu({ aria-hidden /> - {/* Wider than the 220px menu default: the row-scoped workflow labels name - both the action and the selected row count ("Run empty or failed cells - on 2 rows"), which wrapped — and so overlapped the rows beneath — at - the default width. */} e.preventDefault()} > {onAddToChat && ( <> - {addToChatLabel} + {addToChatLabel} @@ -184,19 +187,19 @@ export function ContextMenu({ {hasWorkflowColumns && onRunWorkflows && ( - {runLabel} + {runLabel} )} {hasWorkflowColumns && onRefreshWorkflows && ( - {refreshLabel} + {refreshLabel} )} {hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && ( - {stopLabel} + {stopLabel} )} @@ -217,7 +220,7 @@ export function ContextMenu({ - {deleteLabel} + {deleteLabel} diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx new file mode 100644 index 00000000000..1d4b9cf85eb --- /dev/null +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx @@ -0,0 +1,112 @@ +/** + * @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: + * 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. + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './dropdown-menu' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function openMenu(children: ReactNode) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => + root?.render( + + + {children} + + ) + ) +} + +function row(selector = '[role="menuitem"]'): HTMLElement { + const node = document.querySelector(selector) + if (!node) throw new Error(`No ${selector} rendered`) + return node as HTMLElement +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('menu row labels', () => { + it('wraps a bare text label in a truncating box', () => { + 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') + }) + + it('keeps the row on one line', () => { + openMenu(Delete 2 rows) + + expect(row().className).toContain('whitespace-nowrap') + }) + + it('coalesces adjacent text into a single box', () => { + const count = 2 + openMenu( + + + Delete {count} rows + + ) + + const labels = row().querySelectorAll('span') + expect(labels).toHaveLength(1) + expect(labels[0].textContent).toBe('Delete 2 rows') + }) + + it('wraps a checkbox row label, leaving the check indicator its own box', () => { + openMenu(Show archived workflows) + + const labels = row('[role="menuitemcheckbox"]').querySelectorAll('span') + const label = Array.from(labels).find((node) => node.className.includes('truncate')) + expect(label?.textContent).toBe('Show archived workflows') + }) + + it('leaves an asChild row alone so Slot still sees one element child', () => { + openMenu( + + Open workflow + + ) + + const link = row('a') + expect(link.textContent).toBe('Open workflow') + expect(link.querySelector('span')).toBeNull() + }) + + it('leaves a label the consumer already wrapped as a single box', () => { + openMenu( + + Add 2 rows to Chat + + ) + + expect(row().querySelectorAll('span')).toHaveLength(1) + }) +}) diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index 133fe2a447c..a9af709e481 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -42,12 +42,46 @@ const MENU_ROW_RADIUS_CLASS = 'rounded-lg' /** * Rows are a fixed height, so a label that wraps overflows its row and paints - * over its neighbours instead of growing the row. Keep every row on one line: - * a bare text label is clipped at the surface edge, and a label wrapped in a - * `` (the shape long or count-bearing labels should use) ellipsizes. + * over its neighbours instead of growing the row. Every row is therefore held + * to one line, and its label ellipsizes — see {@link withEllipsizedLabel}. */ const MENU_ROW_SINGLE_LINE_CLASS = 'whitespace-nowrap [&>span]:min-w-0 [&>span]:truncate' +/** + * Wraps a row's bare text children in a truncating box so a label wider than + * the menu ends in an ellipsis rather than being cut mid-word at the surface + * edge. Consumers that already wrap their label in a `` are unaffected — + * the row's `[&>span]` rule truncates those in place. + * + * 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 { + const rebuilt: React.ReactNode[] = [] + let text: React.ReactNode[] = [] + const flushText = () => { + if (text.length === 0) return + rebuilt.push( + + {text} + + ) + text = [] + } + for (const child of React.Children.toArray(children)) { + if (typeof child === 'string' || typeof child === 'number') { + text.push(child) + continue + } + flushText() + rebuilt.push(child) + } + flushText() + return rebuilt +} + /** * Surface corner, shared by the root menu and submenus — they previously * disagreed, at 12px and 8px. @@ -124,7 +158,7 @@ const DropdownMenuSubTrigger = React.forwardRef< )} {...props} > - {children} + {withEllipsizedLabel(children)} ) @@ -193,7 +227,8 @@ const DropdownMenuItem = React.forwardRef< */ action?: React.ReactNode } ->(({ className, inset, action, ...props }, ref) => { +>(({ className, inset, action, asChild, children, ...props }, ref) => { + const content = asChild ? children : withEllipsizedLabel(children) if (action) { return (
@@ -205,8 +240,11 @@ const DropdownMenuItem = React.forwardRef< inset && 'pl-7', className )} + asChild={asChild} {...props} - /> + > + {content} +
{action}
@@ -217,8 +255,11 @@ const DropdownMenuItem = React.forwardRef< + > + {content} + ) }) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName @@ -260,7 +301,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< - {children} + {withEllipsizedLabel(children)} )) DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName @@ -283,7 +324,7 @@ const DropdownMenuRadioItem = React.forwardRef< - {children} + {withEllipsizedLabel(children)} )) DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName