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..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 @@ -150,6 +157,7 @@ export function ContextMenu({ align='start' side='bottom' sideOffset={4} + className={CONTENT_WIDTH_CLASS} onCloseAutoFocus={(e) => e.preventDefault()} > {onAddToChat && ( 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 c7af01b215b..a9af709e481 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -40,6 +40,48 @@ 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. 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. @@ -110,13 +152,13 @@ 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 )} {...props} > - {children} + {withEllipsizedLabel(children)} ) @@ -172,7 +214,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, @@ -185,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 (
@@ -197,8 +240,11 @@ const DropdownMenuItem = React.forwardRef< inset && 'pl-7', className )} + asChild={asChild} {...props} - /> + > + {content} +
{action}
@@ -209,8 +255,11 @@ const DropdownMenuItem = React.forwardRef< + > + {content} + ) }) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName @@ -252,7 +301,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< - {children} + {withEllipsizedLabel(children)} )) DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName @@ -275,7 +324,7 @@ const DropdownMenuRadioItem = React.forwardRef< - {children} + {withEllipsizedLabel(children)} )) DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName