Skip to content

Commit 86af6a9

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
refactor(emcn): own bulk-action button appearance centrally
1 parent bce8234 commit 86af6a9

7 files changed

Lines changed: 233 additions & 58 deletions

File tree

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

Lines changed: 0 additions & 7 deletions
This file was deleted.

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

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

33
import type { ComponentType } from 'react'
44
import {
5-
Button,
5+
BulkActionButton,
66
cn,
77
DropdownMenu,
88
DropdownMenuContent,
@@ -14,7 +14,6 @@ import {
1414
import { Download } from '@sim/emcn/icons'
1515
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
1616
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders'
17-
import { RESOURCE_ACTION_BUTTON_FILLED } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles'
1817

1918
interface ActionButtonProps {
2019
icon: ComponentType<{ className?: string }>
@@ -27,15 +26,9 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp
2726
return (
2827
<Tooltip.Root>
2928
<Tooltip.Trigger asChild>
30-
<Button
31-
aria-label={label}
32-
variant='ghost'
33-
onClick={onClick}
34-
disabled={disabled}
35-
className={RESOURCE_ACTION_BUTTON_FILLED}
36-
>
29+
<BulkActionButton aria-label={label} onClick={onClick} disabled={disabled}>
3730
<Icon className='size-[12px]' />
38-
</Button>
31+
</BulkActionButton>
3932
</Tooltip.Trigger>
4033
<Tooltip.Content side='top'>{label}</Tooltip.Content>
4134
</Tooltip.Root>
@@ -123,14 +116,9 @@ export function ResourceActionBar({
123116
<Tooltip.Root>
124117
<Tooltip.Trigger asChild>
125118
<DropdownMenuTrigger asChild>
126-
<Button
127-
aria-label='Move'
128-
variant='ghost'
129-
disabled={actionsDisabled}
130-
className={RESOURCE_ACTION_BUTTON_FILLED}
131-
>
119+
<BulkActionButton aria-label='Move' disabled={actionsDisabled}>
132120
<Folder className='size-[12px]' />
133-
</Button>
121+
</BulkActionButton>
134122
</DropdownMenuTrigger>
135123
</Tooltip.Trigger>
136124
<Tooltip.Content side='top'>Move</Tooltip.Content>

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

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { Button, 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'
4-
import { RESOURCE_ACTION_BUTTON_FILLED } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles'
54
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
65

76
interface ActionBarProps {
@@ -87,15 +86,9 @@ export function ActionBar({
8786
{showEnableButton && (
8887
<Tooltip.Root>
8988
<Tooltip.Trigger asChild>
90-
<Button
91-
variant='ghost'
92-
aria-label='Enable'
93-
onClick={onEnable}
94-
disabled={isLoading}
95-
className={RESOURCE_ACTION_BUTTON_FILLED}
96-
>
89+
<BulkActionButton aria-label='Enable' onClick={onEnable} disabled={isLoading}>
9790
<Circle className='size-[12px]' />
98-
</Button>
91+
</BulkActionButton>
9992
</Tooltip.Trigger>
10093
<Tooltip.Content side='top'>Enable</Tooltip.Content>
10194
</Tooltip.Root>
@@ -104,15 +97,9 @@ export function ActionBar({
10497
{showDisableButton && (
10598
<Tooltip.Root>
10699
<Tooltip.Trigger asChild>
107-
<Button
108-
variant='ghost'
109-
aria-label='Disable'
110-
onClick={onDisable}
111-
disabled={isLoading}
112-
className={RESOURCE_ACTION_BUTTON_FILLED}
113-
>
100+
<BulkActionButton aria-label='Disable' onClick={onDisable} disabled={isLoading}>
114101
<Ban className='size-[12px]' />
115-
</Button>
102+
</BulkActionButton>
116103
</Tooltip.Trigger>
117104
<Tooltip.Content side='top'>Disable</Tooltip.Content>
118105
</Tooltip.Root>
@@ -121,15 +108,9 @@ export function ActionBar({
121108
{onDelete && canEdit && (
122109
<Tooltip.Root>
123110
<Tooltip.Trigger asChild>
124-
<Button
125-
variant='ghost'
126-
aria-label='Delete'
127-
onClick={onDelete}
128-
disabled={isLoading}
129-
className={RESOURCE_ACTION_BUTTON_FILLED}
130-
>
111+
<BulkActionButton aria-label='Delete' onClick={onDelete} disabled={isLoading}>
131112
<Trash className='size-[12px]' />
132-
</Button>
113+
</BulkActionButton>
133114
</Tooltip.Trigger>
134115
<Tooltip.Content side='top'>Delete</Tooltip.Content>
135116
</Tooltip.Root>

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
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'
7-
import { RESOURCE_ACTION_BUTTON_BASE } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles'
87

98
interface TableActionBarProps {
109
/** Number of (row × group) cells the run/stop buttons would target. Drives
@@ -147,22 +146,20 @@ interface ActionIconButtonProps {
147146
}
148147

149148
/**
150-
* Tooltip-wrapped icon button sharing the action bar's brand-hover chrome,
151-
* so the chrome string lives in one place.
149+
* Supplies the table action's tooltip around the shared EMCN bulk-action button.
152150
*/
153151
function ActionIconButton({ label, onClick, disabled, children }: ActionIconButtonProps) {
154152
return (
155153
<Tooltip.Root>
156154
<Tooltip.Trigger asChild>
157-
<Button
158-
variant='ghost'
155+
<BulkActionButton
159156
onClick={onClick}
160157
disabled={disabled}
161-
className={cn(RESOURCE_ACTION_BUTTON_BASE, 'bg-[var(--surface-5)]')}
158+
surface='uniform'
162159
aria-label={label}
163160
>
164161
{children}
165-
</Button>
162+
</BulkActionButton>
166163
</Tooltip.Trigger>
167164
<Tooltip.Content side='top'>{label}</Tooltip.Content>
168165
</Tooltip.Root>
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
for (const disabled of [false, true]) {
40+
it(`preserves the previous button markup for ${surface ?? 'default'} / disabled=${disabled}`, () => {
41+
const previousFill =
42+
surface === 'uniform'
43+
? 'bg-[var(--surface-5)]'
44+
: 'bg-[var(--surface-5)] dark:bg-[var(--surface-4)]'
45+
const view = mount(
46+
<>
47+
<Button
48+
variant='ghost'
49+
aria-label='Delete'
50+
disabled={disabled}
51+
className={`${previousFill} ${PREVIOUS_GEOMETRY}`}
52+
>
53+
<svg className='size-[12px]' aria-hidden='true' />
54+
</Button>
55+
<BulkActionButton aria-label='Delete' surface={surface} disabled={disabled}>
56+
<svg className='size-[12px]' aria-hidden='true' />
57+
</BulkActionButton>
58+
</>
59+
)
60+
const [previous, current] = view.querySelectorAll('button')
61+
/** Class order changes when composing recipes; the resolved utility set must not. */
62+
previous.className = previous.className.split(/\s+/).sort().join(' ')
63+
current.className = current.className.split(/\s+/).sort().join(' ')
64+
expect(current.outerHTML).toBe(previous.outerHTML)
65+
})
66+
}
67+
}
68+
69+
it('forwards the native ref, attributes and original events', () => {
70+
const ref = createRef<HTMLButtonElement>()
71+
const onClick = vi.fn()
72+
const onKeyDown = vi.fn()
73+
mount(
74+
<BulkActionButton
75+
ref={ref}
76+
aria-label='Download'
77+
data-action='download'
78+
onClick={onClick}
79+
onKeyDown={onKeyDown}
80+
/>
81+
)
82+
expect(ref.current).toBe(button())
83+
expect(button().dataset.action).toBe('download')
84+
act(() => button().focus())
85+
expect(document.activeElement).toBe(button())
86+
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
87+
act(() => button().dispatchEvent(keyEvent))
88+
expect(onKeyDown).toHaveBeenCalledTimes(1)
89+
expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent)
90+
act(() => button().click())
91+
expect(onClick).toHaveBeenCalledTimes(1)
92+
})
93+
94+
it('does not invoke disabled actions', () => {
95+
const onClick = vi.fn()
96+
mount(<BulkActionButton aria-label='Delete' onClick={onClick} disabled />)
97+
act(() => button().click())
98+
expect(button().disabled).toBe(true)
99+
expect(onClick).not.toHaveBeenCalled()
100+
})
101+
102+
for (const type of [undefined, 'button', 'submit'] as const) {
103+
it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => {
104+
const onSubmit = vi.fn((event) => event.preventDefault())
105+
mount(
106+
<form onSubmit={onSubmit}>
107+
<BulkActionButton aria-label='Run' type={type} />
108+
</form>
109+
)
110+
act(() => button().click())
111+
expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1)
112+
})
113+
}
114+
115+
it('composes with the tooltip and menu triggers used by Move', () => {
116+
const onOpenChange = vi.fn()
117+
const onKeyDown = vi.fn()
118+
const ref = createRef<HTMLButtonElement>()
119+
mount(
120+
<DropdownMenu open={false} onOpenChange={onOpenChange}>
121+
<Tooltip.Root>
122+
<Tooltip.Trigger asChild>
123+
<DropdownMenuTrigger asChild>
124+
<BulkActionButton ref={ref} aria-label='Move' onKeyDown={onKeyDown} />
125+
</DropdownMenuTrigger>
126+
</Tooltip.Trigger>
127+
<Tooltip.Content>Move</Tooltip.Content>
128+
</Tooltip.Root>
129+
</DropdownMenu>
130+
)
131+
expect(container?.querySelectorAll('button')).toHaveLength(1)
132+
expect(ref.current).toBe(button())
133+
expect(button().getAttribute('aria-haspopup')).toBe('menu')
134+
act(() =>
135+
button().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
136+
)
137+
expect(onKeyDown).toHaveBeenCalledTimes(1)
138+
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true)
139+
})
140+
141+
it('retains the tooltip and accessible name on a direct action', () => {
142+
vi.useFakeTimers()
143+
mount(
144+
<Tooltip.Root>
145+
<Tooltip.Trigger asChild>
146+
<BulkActionButton aria-label='Download' />
147+
</Tooltip.Trigger>
148+
<Tooltip.Content>Download selected files</Tooltip.Content>
149+
</Tooltip.Root>
150+
)
151+
act(() =>
152+
button().dispatchEvent(
153+
new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
154+
)
155+
)
156+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Download selected files')
157+
expect(button().getAttribute('aria-label')).toBe('Download')
158+
})
159+
})
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)