Skip to content

Commit 4f5ad20

Browse files
authored
fix(tables): keep row context menu labels on one line (#6418)
* fix(tables): keep row context menu labels on one line * improvement(emcn): ellipsize menu row labels instead of clipping them
1 parent 6c6d8a5 commit 4f5ad20

3 files changed

Lines changed: 179 additions & 10 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ import {
1919
} from '@sim/emcn/icons'
2020
import type { ContextMenuState } from '../../types'
2121

22+
/**
23+
* Wider than the menu's 220px default. The row-scoped workflow labels name both
24+
* the action and the selected row count ("Run empty or failed cells on 2 rows"),
25+
* which does not fit the default width.
26+
*/
27+
const CONTENT_WIDTH_CLASS = 'max-w-[320px]'
28+
2229
interface ContextMenuProps {
2330
contextMenu: ContextMenuState
2431
onClose: () => void
@@ -150,6 +157,7 @@ export function ContextMenu({
150157
align='start'
151158
side='bottom'
152159
sideOffset={4}
160+
className={CONTENT_WIDTH_CLASS}
153161
onCloseAutoFocus={(e) => e.preventDefault()}
154162
>
155163
{onAddToChat && (
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Menu rows are a fixed height, so a label that wraps overflows its row and paints over its
5+
* neighbours. Rows are held to one line and their bare text is wrapped in a truncating box so an
6+
* over-long label ellipsizes instead of being cut mid-word. These tests cover that wrapping:
7+
* that it happens, that adjacent text stays in ONE box (two boxes would be two flex items, and
8+
* the row's `gap` would open between the words), and that it steps aside for `asChild`, where
9+
* Radix's `Slot` requires exactly one element child.
10+
*/
11+
import { act, type ReactNode } from 'react'
12+
import { createRoot, type Root } from 'react-dom/client'
13+
import { afterEach, describe, expect, it } from 'vitest'
14+
import {
15+
DropdownMenu,
16+
DropdownMenuCheckboxItem,
17+
DropdownMenuContent,
18+
DropdownMenuItem,
19+
DropdownMenuTrigger,
20+
} from './dropdown-menu'
21+
22+
let root: Root | null = null
23+
let container: HTMLDivElement | null = null
24+
25+
function openMenu(children: ReactNode) {
26+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
27+
container = document.createElement('div')
28+
document.body.appendChild(container)
29+
root = createRoot(container)
30+
act(() =>
31+
root?.render(
32+
<DropdownMenu open modal={false}>
33+
<DropdownMenuTrigger />
34+
<DropdownMenuContent>{children}</DropdownMenuContent>
35+
</DropdownMenu>
36+
)
37+
)
38+
}
39+
40+
function row(selector = '[role="menuitem"]'): HTMLElement {
41+
const node = document.querySelector(selector)
42+
if (!node) throw new Error(`No ${selector} rendered`)
43+
return node as HTMLElement
44+
}
45+
46+
afterEach(() => {
47+
if (root) act(() => root?.unmount())
48+
container?.remove()
49+
root = null
50+
container = null
51+
})
52+
53+
describe('menu row labels', () => {
54+
it('wraps a bare text label in a truncating box', () => {
55+
openMenu(<DropdownMenuItem>Run empty or failed cells on 2 rows</DropdownMenuItem>)
56+
57+
const labels = row().querySelectorAll('span')
58+
expect(labels).toHaveLength(1)
59+
expect(labels[0].textContent).toBe('Run empty or failed cells on 2 rows')
60+
expect(labels[0].className).toContain('truncate')
61+
})
62+
63+
it('keeps the row on one line', () => {
64+
openMenu(<DropdownMenuItem>Delete 2 rows</DropdownMenuItem>)
65+
66+
expect(row().className).toContain('whitespace-nowrap')
67+
})
68+
69+
it('coalesces adjacent text into a single box', () => {
70+
const count = 2
71+
openMenu(
72+
<DropdownMenuItem>
73+
<svg aria-hidden />
74+
Delete {count} rows
75+
</DropdownMenuItem>
76+
)
77+
78+
const labels = row().querySelectorAll('span')
79+
expect(labels).toHaveLength(1)
80+
expect(labels[0].textContent).toBe('Delete 2 rows')
81+
})
82+
83+
it('wraps a checkbox row label, leaving the check indicator its own box', () => {
84+
openMenu(<DropdownMenuCheckboxItem checked>Show archived workflows</DropdownMenuCheckboxItem>)
85+
86+
const labels = row('[role="menuitemcheckbox"]').querySelectorAll('span')
87+
const label = Array.from(labels).find((node) => node.className.includes('truncate'))
88+
expect(label?.textContent).toBe('Show archived workflows')
89+
})
90+
91+
it('leaves an asChild row alone so Slot still sees one element child', () => {
92+
openMenu(
93+
<DropdownMenuItem asChild>
94+
<a href='/workflows'>Open workflow</a>
95+
</DropdownMenuItem>
96+
)
97+
98+
const link = row('a')
99+
expect(link.textContent).toBe('Open workflow')
100+
expect(link.querySelector('span')).toBeNull()
101+
})
102+
103+
it('leaves a label the consumer already wrapped as a single box', () => {
104+
openMenu(
105+
<DropdownMenuItem>
106+
<span>Add 2 rows to Chat</span>
107+
</DropdownMenuItem>
108+
)
109+
110+
expect(row().querySelectorAll('span')).toHaveLength(1)
111+
})
112+
})

packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,48 @@ const ANIMATION_CLASSES =
4040
const MENU_ROW_HEIGHT_CLASS = 'h-[28px]'
4141
const MENU_ROW_RADIUS_CLASS = 'rounded-lg'
4242

43+
/**
44+
* Rows are a fixed height, so a label that wraps overflows its row and paints
45+
* over its neighbours instead of growing the row. Every row is therefore held
46+
* to one line, and its label ellipsizes — see {@link withEllipsizedLabel}.
47+
*/
48+
const MENU_ROW_SINGLE_LINE_CLASS = 'whitespace-nowrap [&>span]:min-w-0 [&>span]:truncate'
49+
50+
/**
51+
* Wraps a row's bare text children in a truncating box so a label wider than
52+
* the menu ends in an ellipsis rather than being cut mid-word at the surface
53+
* edge. Consumers that already wrap their label in a `<span>` are unaffected —
54+
* the row's `[&>span]` rule truncates those in place.
55+
*
56+
* Adjacent text is coalesced into a single box: a row is a flex container, so
57+
* wrapping `Insert row {n}` as two boxes would make them two flex items and
58+
* open the row's `gap` between the words. `React.Children.toArray` keys the
59+
* element children it returns, so the rebuilt array needs no keys of its own.
60+
*/
61+
function withEllipsizedLabel(children: React.ReactNode): React.ReactNode {
62+
const rebuilt: React.ReactNode[] = []
63+
let text: React.ReactNode[] = []
64+
const flushText = () => {
65+
if (text.length === 0) return
66+
rebuilt.push(
67+
<span key={`label-${rebuilt.length}`} className='min-w-0 truncate'>
68+
{text}
69+
</span>
70+
)
71+
text = []
72+
}
73+
for (const child of React.Children.toArray(children)) {
74+
if (typeof child === 'string' || typeof child === 'number') {
75+
text.push(child)
76+
continue
77+
}
78+
flushText()
79+
rebuilt.push(child)
80+
}
81+
flushText()
82+
return rebuilt
83+
}
84+
4385
/**
4486
* Surface corner, shared by the root menu and submenus — they previously
4587
* disagreed, at 12px and 8px.
@@ -110,13 +152,13 @@ const DropdownMenuSubTrigger = React.forwardRef<
110152
<DropdownMenuPrimitive.SubTrigger
111153
ref={ref}
112154
className={cn(
113-
`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)] [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`,
155+
`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)]`,
114156
inset && 'pl-7',
115157
className
116158
)}
117159
{...props}
118160
>
119-
{children}
161+
{withEllipsizedLabel(children)}
120162
<ChevronRight className='ml-auto size-[14px] shrink-0' />
121163
</DropdownMenuPrimitive.SubTrigger>
122164
)
@@ -172,7 +214,7 @@ const DropdownMenuContent = React.forwardRef<
172214
))
173215
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
174216

175-
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)]`
217+
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)]`
176218

177219
const DropdownMenuItem = React.forwardRef<
178220
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
@@ -185,7 +227,8 @@ const DropdownMenuItem = React.forwardRef<
185227
*/
186228
action?: React.ReactNode
187229
}
188-
>(({ className, inset, action, ...props }, ref) => {
230+
>(({ className, inset, action, asChild, children, ...props }, ref) => {
231+
const content = asChild ? children : withEllipsizedLabel(children)
189232
if (action) {
190233
return (
191234
<div className='group/dropdownitem relative'>
@@ -197,8 +240,11 @@ const DropdownMenuItem = React.forwardRef<
197240
inset && 'pl-7',
198241
className
199242
)}
243+
asChild={asChild}
200244
{...props}
201-
/>
245+
>
246+
{content}
247+
</DropdownMenuPrimitive.Item>
202248
<div className='-translate-y-1/2 absolute top-1/2 right-1 flex items-center opacity-0 transition-opacity group-focus-within/dropdownitem:opacity-100 group-hover/dropdownitem:opacity-100'>
203249
{action}
204250
</div>
@@ -209,8 +255,11 @@ const DropdownMenuItem = React.forwardRef<
209255
<DropdownMenuPrimitive.Item
210256
ref={ref}
211257
className={cn(DROPDOWN_MENU_ITEM_BASE_CLASSES, inset && 'pl-7', className)}
258+
asChild={asChild}
212259
{...props}
213-
/>
260+
>
261+
{content}
262+
</DropdownMenuPrimitive.Item>
214263
)
215264
})
216265
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
@@ -252,7 +301,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
252301
<DropdownMenuPrimitive.CheckboxItem
253302
ref={ref}
254303
className={cn(
255-
`relative flex ${MENU_ROW_HEIGHT_CLASS} cursor-default select-none items-center ${MENU_ROW_RADIUS_CLASS} pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,
304+
`relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center ${MENU_ROW_RADIUS_CLASS} whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,
256305
className
257306
)}
258307
checked={checked}
@@ -263,7 +312,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
263312
<Check className='size-[14px]' />
264313
</DropdownMenuPrimitive.ItemIndicator>
265314
</span>
266-
{children}
315+
{withEllipsizedLabel(children)}
267316
</DropdownMenuPrimitive.CheckboxItem>
268317
))
269318
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
@@ -275,7 +324,7 @@ const DropdownMenuRadioItem = React.forwardRef<
275324
<DropdownMenuPrimitive.RadioItem
276325
ref={ref}
277326
className={cn(
278-
`relative flex ${MENU_ROW_HEIGHT_CLASS} cursor-default select-none items-center ${MENU_ROW_RADIUS_CLASS} pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,
327+
`relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center ${MENU_ROW_RADIUS_CLASS} whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,
279328
className
280329
)}
281330
{...props}
@@ -285,7 +334,7 @@ const DropdownMenuRadioItem = React.forwardRef<
285334
<Circle className='size-[6px] fill-current' />
286335
</DropdownMenuPrimitive.ItemIndicator>
287336
</span>
288-
{children}
337+
{withEllipsizedLabel(children)}
289338
</DropdownMenuPrimitive.RadioItem>
290339
))
291340
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName

0 commit comments

Comments
 (0)