Skip to content

Commit 44869df

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
refactor(ui): centralize bulk-action buttons in EMCN (#7982)
* refactor(ui): share resource bulk-action button styles * refactor(emcn): own bulk-action button appearance centrally * test(emcn): consolidate bulk-action button coverage --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 7a8f39a commit 44869df

6 files changed

Lines changed: 230 additions & 61 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx‎

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
import type { ComponentType } from 'react'
44
import {
5-
Button,
6-
chipFilledFillTokens,
5+
BulkActionButton,
76
cn,
87
DropdownMenu,
98
DropdownMenuContent,
@@ -16,12 +15,6 @@ import { Download } from '@sim/emcn/icons'
1615
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
1716
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders'
1817

19-
/** Shared chrome for every action button, so the bar reads as one control strip. */
20-
const ACTION_BUTTON_CLASS = cn(
21-
chipFilledFillTokens,
22-
'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]'
23-
)
24-
2518
interface ActionButtonProps {
2619
icon: ComponentType<{ className?: string }>
2720
label: string
@@ -33,15 +26,9 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp
3326
return (
3427
<Tooltip.Root>
3528
<Tooltip.Trigger asChild>
36-
<Button
37-
aria-label={label}
38-
variant='ghost'
39-
onClick={onClick}
40-
disabled={disabled}
41-
className={ACTION_BUTTON_CLASS}
42-
>
29+
<BulkActionButton aria-label={label} onClick={onClick} disabled={disabled}>
4330
<Icon className='size-[12px]' />
44-
</Button>
31+
</BulkActionButton>
4532
</Tooltip.Trigger>
4633
<Tooltip.Content side='top'>{label}</Tooltip.Content>
4734
</Tooltip.Root>
@@ -129,14 +116,9 @@ export function ResourceActionBar({
129116
<Tooltip.Root>
130117
<Tooltip.Trigger asChild>
131118
<DropdownMenuTrigger asChild>
132-
<Button
133-
aria-label='Move'
134-
variant='ghost'
135-
disabled={actionsDisabled}
136-
className={ACTION_BUTTON_CLASS}
137-
>
119+
<BulkActionButton aria-label='Move' disabled={actionsDisabled}>
138120
<Folder className='size-[12px]' />
139-
</Button>
121+
</BulkActionButton>
140122
</DropdownMenuTrigger>
141123
</Tooltip.Trigger>
142124
<Tooltip.Content side='top'>Move</Tooltip.Content>

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx‎

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
1-
import { Button, chipFilledFillTokens, cn, Tooltip } from '@sim/emcn'
1+
import { BulkActionButton, cn, Tooltip } from '@sim/emcn'
22
import { Ban, Circle, Trash } from '@sim/emcn/icons'
33
import { domAnimation, LazyMotion, m } from 'framer-motion'
44
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
55

6-
/** One source of truth for the button chrome, so the three actions read as one control strip. */
7-
const ACTION_BUTTON_CLASS = cn(
8-
chipFilledFillTokens,
9-
'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]'
10-
)
11-
126
interface ActionBarProps {
137
selectedCount: number
148
onEnable?: () => void
@@ -92,15 +86,9 @@ export function ActionBar({
9286
{showEnableButton && (
9387
<Tooltip.Root>
9488
<Tooltip.Trigger asChild>
95-
<Button
96-
variant='ghost'
97-
aria-label='Enable'
98-
onClick={onEnable}
99-
disabled={isLoading}
100-
className={ACTION_BUTTON_CLASS}
101-
>
89+
<BulkActionButton aria-label='Enable' onClick={onEnable} disabled={isLoading}>
10290
<Circle className='size-[12px]' />
103-
</Button>
91+
</BulkActionButton>
10492
</Tooltip.Trigger>
10593
<Tooltip.Content side='top'>Enable</Tooltip.Content>
10694
</Tooltip.Root>
@@ -109,15 +97,9 @@ export function ActionBar({
10997
{showDisableButton && (
11098
<Tooltip.Root>
11199
<Tooltip.Trigger asChild>
112-
<Button
113-
variant='ghost'
114-
aria-label='Disable'
115-
onClick={onDisable}
116-
disabled={isLoading}
117-
className={ACTION_BUTTON_CLASS}
118-
>
100+
<BulkActionButton aria-label='Disable' onClick={onDisable} disabled={isLoading}>
119101
<Ban className='size-[12px]' />
120-
</Button>
102+
</BulkActionButton>
121103
</Tooltip.Trigger>
122104
<Tooltip.Content side='top'>Disable</Tooltip.Content>
123105
</Tooltip.Root>
@@ -126,15 +108,9 @@ export function ActionBar({
126108
{onDelete && canEdit && (
127109
<Tooltip.Root>
128110
<Tooltip.Trigger asChild>
129-
<Button
130-
variant='ghost'
131-
aria-label='Delete'
132-
onClick={onDelete}
133-
disabled={isLoading}
134-
className={ACTION_BUTTON_CLASS}
135-
>
111+
<BulkActionButton aria-label='Delete' onClick={onDelete} disabled={isLoading}>
136112
<Trash className='size-[12px]' />
137-
</Button>
113+
</BulkActionButton>
138114
</Tooltip.Trigger>
139115
<Tooltip.Content side='top'>Delete</Tooltip.Content>
140116
</Tooltip.Root>

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import type React from 'react'
4-
import { Button, cn, Tooltip } from '@sim/emcn'
4+
import { BulkActionButton, cn, Tooltip } from '@sim/emcn'
55
import { Eye, PlayOutline, RefreshCw, Square } from '@sim/emcn/icons'
66
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
77

@@ -146,22 +146,20 @@ interface ActionIconButtonProps {
146146
}
147147

148148
/**
149-
* Tooltip-wrapped icon button sharing the action bar's brand-hover chrome,
150-
* so the chrome string lives in one place.
149+
* Supplies the table action's tooltip around the shared EMCN bulk-action button.
151150
*/
152151
function ActionIconButton({ label, onClick, disabled, children }: ActionIconButtonProps) {
153152
return (
154153
<Tooltip.Root>
155154
<Tooltip.Trigger asChild>
156-
<Button
157-
variant='ghost'
155+
<BulkActionButton
158156
onClick={onClick}
159157
disabled={disabled}
160-
className='size-[28px] rounded-lg bg-[var(--surface-5)] p-0 hover-hover:bg-[var(--brand-secondary)] hover-hover:text-[var(--text-inverse)]!'
158+
surface='uniform'
161159
aria-label={label}
162160
>
163161
{children}
164-
</Button>
162+
</BulkActionButton>
165163
</Tooltip.Trigger>
166164
<Tooltip.Content side='top'>{label}</Tooltip.Content>
167165
</Tooltip.Root>
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/** @vitest-environment jsdom */
2+
import { act, createRef, type ReactNode } from 'react'
3+
import { BulkActionButton, Button, DropdownMenu, DropdownMenuTrigger, Tooltip } from '@sim/emcn'
4+
import { createRoot, type Root } from 'react-dom/client'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
7+
let root: Root | null = null
8+
let container: HTMLDivElement | null = null
9+
10+
function mount(children: ReactNode) {
11+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
12+
container = document.createElement('div')
13+
document.body.appendChild(container)
14+
root = createRoot(container)
15+
act(() => root?.render(children))
16+
return container
17+
}
18+
19+
function button() {
20+
const element = container?.querySelector('button')
21+
if (!element) throw new Error('Button did not render')
22+
return element
23+
}
24+
25+
afterEach(() => {
26+
if (root) act(() => root?.unmount())
27+
container?.remove()
28+
root = null
29+
container = null
30+
vi.useRealTimers()
31+
})
32+
33+
/** Exact class inputs used by the action bars before migrating into EMCN. */
34+
const PREVIOUS_GEOMETRY =
35+
'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]'
36+
37+
describe('BulkActionButton', () => {
38+
for (const surface of [undefined, 'adaptive', 'uniform'] as const) {
39+
it(`preserves the previous button markup for ${surface ?? 'default'}`, () => {
40+
const previousFill =
41+
surface === 'uniform'
42+
? 'bg-[var(--surface-5)]'
43+
: 'bg-[var(--surface-5)] dark:bg-[var(--surface-4)]'
44+
const view = mount(
45+
<>
46+
<Button
47+
variant='ghost'
48+
aria-label='Delete'
49+
className={`${previousFill} ${PREVIOUS_GEOMETRY}`}
50+
>
51+
<svg className='size-[12px]' aria-hidden='true' />
52+
</Button>
53+
<BulkActionButton aria-label='Delete' surface={surface}>
54+
<svg className='size-[12px]' aria-hidden='true' />
55+
</BulkActionButton>
56+
</>
57+
)
58+
const [previous, current] = view.querySelectorAll('button')
59+
/** Class order changes when composing recipes; the resolved utility set must not. */
60+
previous.className = previous.className.split(/\s+/).sort().join(' ')
61+
current.className = current.className.split(/\s+/).sort().join(' ')
62+
expect(current.outerHTML).toBe(previous.outerHTML)
63+
})
64+
}
65+
66+
it('forwards the native ref, attributes and original events', () => {
67+
const ref = createRef<HTMLButtonElement>()
68+
const onClick = vi.fn()
69+
const onKeyDown = vi.fn()
70+
mount(
71+
<BulkActionButton
72+
ref={ref}
73+
aria-label='Download'
74+
data-action='download'
75+
onClick={onClick}
76+
onKeyDown={onKeyDown}
77+
/>
78+
)
79+
expect(ref.current).toBe(button())
80+
expect(button().dataset.action).toBe('download')
81+
act(() => button().focus())
82+
expect(document.activeElement).toBe(button())
83+
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
84+
act(() => button().dispatchEvent(keyEvent))
85+
expect(onKeyDown).toHaveBeenCalledTimes(1)
86+
expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent)
87+
act(() => button().click())
88+
expect(onClick).toHaveBeenCalledTimes(1)
89+
})
90+
91+
it('does not invoke disabled actions', () => {
92+
const onClick = vi.fn()
93+
mount(<BulkActionButton aria-label='Delete' onClick={onClick} disabled />)
94+
act(() => button().click())
95+
expect(button().disabled).toBe(true)
96+
expect(onClick).not.toHaveBeenCalled()
97+
})
98+
99+
for (const type of [undefined, 'button'] as const) {
100+
it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => {
101+
const onSubmit = vi.fn((event) => event.preventDefault())
102+
mount(
103+
<form onSubmit={onSubmit}>
104+
<BulkActionButton aria-label='Run' type={type} />
105+
</form>
106+
)
107+
act(() => button().click())
108+
expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1)
109+
})
110+
}
111+
112+
it('composes with the tooltip and menu triggers used by Move', () => {
113+
const onOpenChange = vi.fn()
114+
const onKeyDown = vi.fn()
115+
const ref = createRef<HTMLButtonElement>()
116+
mount(
117+
<DropdownMenu open={false} onOpenChange={onOpenChange}>
118+
<Tooltip.Root>
119+
<Tooltip.Trigger asChild>
120+
<DropdownMenuTrigger asChild>
121+
<BulkActionButton ref={ref} aria-label='Move' onKeyDown={onKeyDown} />
122+
</DropdownMenuTrigger>
123+
</Tooltip.Trigger>
124+
<Tooltip.Content>Move</Tooltip.Content>
125+
</Tooltip.Root>
126+
</DropdownMenu>
127+
)
128+
expect(container?.querySelectorAll('button')).toHaveLength(1)
129+
expect(ref.current).toBe(button())
130+
expect(button().getAttribute('aria-haspopup')).toBe('menu')
131+
act(() =>
132+
button().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
133+
)
134+
expect(onKeyDown).toHaveBeenCalledTimes(1)
135+
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true)
136+
})
137+
138+
it('retains the tooltip and accessible name on a direct action', () => {
139+
vi.useFakeTimers()
140+
mount(
141+
<Tooltip.Root>
142+
<Tooltip.Trigger asChild>
143+
<BulkActionButton aria-label='Download' />
144+
</Tooltip.Trigger>
145+
<Tooltip.Content>Download selected files</Tooltip.Content>
146+
</Tooltip.Root>
147+
)
148+
act(() =>
149+
button().dispatchEvent(
150+
new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
151+
)
152+
)
153+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Download selected files')
154+
expect(button().getAttribute('aria-label')).toBe('Download')
155+
})
156+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { forwardRef } from 'react'
2+
import { cva, type VariantProps } from 'class-variance-authority'
3+
import { cn } from '../../lib/cn'
4+
import { Button, type ButtonProps } from '../button/button'
5+
import { chipFilledFillTokens, chipRadiusClass } from '../chip/chip-chrome'
6+
7+
/** The shared 28px geometry and brand-hover treatment of selection action bars. */
8+
export const bulkActionButtonVariants = cva(
9+
`${chipRadiusClass} size-[28px] p-0 hover-hover:bg-[var(--brand-secondary)] hover-hover:text-[var(--text-inverse)]!`,
10+
{
11+
variants: {
12+
surface: {
13+
adaptive: chipFilledFillTokens,
14+
uniform: 'bg-[var(--surface-5)]',
15+
},
16+
},
17+
defaultVariants: { surface: 'adaptive' },
18+
}
19+
)
20+
21+
export interface BulkActionButtonProps extends Omit<ButtonProps, 'variant' | 'size'> {
22+
/** Accessible name for the icon action; tooltip content is supplied separately. */
23+
'aria-label': string
24+
/**
25+
* `adaptive` follows the filled chip surface: surface-5 in light mode and surface-4 in dark.
26+
* `uniform` retains surface-5 in both themes, as used by table-cell action bars.
27+
* @default 'adaptive'
28+
*/
29+
surface?: NonNullable<VariantProps<typeof bulkActionButtonVariants>['surface']>
30+
}
31+
32+
/**
33+
* Icon action for a selection's bulk-action bar. Owns its geometry and visual states;
34+
* callers provide icon content, labels, disabled state and command behavior.
35+
* Forwards the native button ref and props for tooltip/menu `asChild` composition.
36+
* Native form behavior is inherited from Button; pass `type` when it must be explicit.
37+
*
38+
* @example <BulkActionButton aria-label='Delete' onClick={onDelete}><Trash className='size-[12px]' /></BulkActionButton>
39+
*/
40+
export const BulkActionButton = forwardRef<HTMLButtonElement, BulkActionButtonProps>(
41+
({ surface, className, ...props }, ref) => (
42+
<Button
43+
{...props}
44+
ref={ref}
45+
variant='ghost'
46+
size='md'
47+
className={cn(bulkActionButtonVariants({ surface }), className)}
48+
/>
49+
)
50+
)
51+
52+
BulkActionButton.displayName = 'BulkActionButton'

‎packages/emcn/src/components/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
export { Avatar, AvatarFallback, AvatarImage } from './avatar/avatar'
22
export { Badge, type BadgeProps } from './badge/badge'
33
export { Banner } from './banner/banner'
4+
export {
5+
BulkActionButton,
6+
type BulkActionButtonProps,
7+
bulkActionButtonVariants,
8+
} from './bulk-action-button/bulk-action-button'
49
export { Button, buttonVariants } from './button/button'
510
export {
611
CalendarDayCell,

0 commit comments

Comments
 (0)