diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx
index 4fd9f163f69..7ded34f10d0 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx
@@ -2,8 +2,7 @@
import type { ComponentType } from 'react'
import {
- Button,
- chipFilledFillTokens,
+ BulkActionButton,
cn,
DropdownMenu,
DropdownMenuContent,
@@ -16,12 +15,6 @@ import { Download } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders'
-/** Shared chrome for every action button, so the bar reads as one control strip. */
-const ACTION_BUTTON_CLASS = cn(
- chipFilledFillTokens,
- 'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]'
-)
-
interface ActionButtonProps {
icon: ComponentType<{ className?: string }>
label: string
@@ -33,15 +26,9 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp
return (
-
+
{label}
@@ -129,14 +116,9 @@ export function ResourceActionBar({
-
+
Move
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
index 6d118e9a5e8..01dbfaead48 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
@@ -1,14 +1,8 @@
-import { Button, chipFilledFillTokens, cn, Tooltip } from '@sim/emcn'
+import { BulkActionButton, cn, Tooltip } from '@sim/emcn'
import { Ban, Circle, Trash } from '@sim/emcn/icons'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-/** One source of truth for the button chrome, so the three actions read as one control strip. */
-const ACTION_BUTTON_CLASS = cn(
- chipFilledFillTokens,
- 'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]'
-)
-
interface ActionBarProps {
selectedCount: number
onEnable?: () => void
@@ -92,15 +86,9 @@ export function ActionBar({
{showEnableButton && (
-
+
Enable
@@ -109,15 +97,9 @@ export function ActionBar({
{showDisableButton && (
-
+
Disable
@@ -126,15 +108,9 @@ export function ActionBar({
{onDelete && canEdit && (
-
+
Delete
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx
index 71eac786c43..b04257ae755 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx
@@ -1,7 +1,7 @@
'use client'
import type React from 'react'
-import { Button, cn, Tooltip } from '@sim/emcn'
+import { BulkActionButton, cn, Tooltip } from '@sim/emcn'
import { Eye, PlayOutline, RefreshCw, Square } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
@@ -146,22 +146,20 @@ interface ActionIconButtonProps {
}
/**
- * Tooltip-wrapped icon button sharing the action bar's brand-hover chrome,
- * so the chrome string lives in one place.
+ * Supplies the table action's tooltip around the shared EMCN bulk-action button.
*/
function ActionIconButton({ label, onClick, disabled, children }: ActionIconButtonProps) {
return (
-
+
{label}
diff --git a/packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx b/packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx
new file mode 100644
index 00000000000..763106567be
--- /dev/null
+++ b/packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx
@@ -0,0 +1,156 @@
+/** @vitest-environment jsdom */
+import { act, createRef, type ReactNode } from 'react'
+import { BulkActionButton, Button, DropdownMenu, DropdownMenuTrigger, Tooltip } from '@sim/emcn'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+function mount(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))
+ return container
+}
+
+function button() {
+ const element = container?.querySelector('button')
+ if (!element) throw new Error('Button did not render')
+ return element
+}
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+ vi.useRealTimers()
+})
+
+/** Exact class inputs used by the action bars before migrating into EMCN. */
+const PREVIOUS_GEOMETRY =
+ 'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]'
+
+describe('BulkActionButton', () => {
+ for (const surface of [undefined, 'adaptive', 'uniform'] as const) {
+ it(`preserves the previous button markup for ${surface ?? 'default'}`, () => {
+ const previousFill =
+ surface === 'uniform'
+ ? 'bg-[var(--surface-5)]'
+ : 'bg-[var(--surface-5)] dark:bg-[var(--surface-4)]'
+ const view = mount(
+ <>
+
+
+
+
+ >
+ )
+ const [previous, current] = view.querySelectorAll('button')
+ /** Class order changes when composing recipes; the resolved utility set must not. */
+ previous.className = previous.className.split(/\s+/).sort().join(' ')
+ current.className = current.className.split(/\s+/).sort().join(' ')
+ expect(current.outerHTML).toBe(previous.outerHTML)
+ })
+ }
+
+ it('forwards the native ref, attributes and original events', () => {
+ const ref = createRef()
+ const onClick = vi.fn()
+ const onKeyDown = vi.fn()
+ mount(
+
+ )
+ expect(ref.current).toBe(button())
+ expect(button().dataset.action).toBe('download')
+ act(() => button().focus())
+ expect(document.activeElement).toBe(button())
+ const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
+ act(() => button().dispatchEvent(keyEvent))
+ expect(onKeyDown).toHaveBeenCalledTimes(1)
+ expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent)
+ act(() => button().click())
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not invoke disabled actions', () => {
+ const onClick = vi.fn()
+ mount()
+ act(() => button().click())
+ expect(button().disabled).toBe(true)
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ for (const type of [undefined, 'button'] as const) {
+ it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => {
+ const onSubmit = vi.fn((event) => event.preventDefault())
+ mount(
+
+ )
+ act(() => button().click())
+ expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1)
+ })
+ }
+
+ it('composes with the tooltip and menu triggers used by Move', () => {
+ const onOpenChange = vi.fn()
+ const onKeyDown = vi.fn()
+ const ref = createRef()
+ mount(
+
+
+
+
+
+
+
+ Move
+
+
+ )
+ expect(container?.querySelectorAll('button')).toHaveLength(1)
+ expect(ref.current).toBe(button())
+ expect(button().getAttribute('aria-haspopup')).toBe('menu')
+ act(() =>
+ button().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
+ )
+ expect(onKeyDown).toHaveBeenCalledTimes(1)
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true)
+ })
+
+ it('retains the tooltip and accessible name on a direct action', () => {
+ vi.useFakeTimers()
+ mount(
+
+
+
+
+ Download selected files
+
+ )
+ act(() =>
+ button().dispatchEvent(
+ new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
+ )
+ )
+ expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Download selected files')
+ expect(button().getAttribute('aria-label')).toBe('Download')
+ })
+})
diff --git a/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx b/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx
new file mode 100644
index 00000000000..b24e402e274
--- /dev/null
+++ b/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx
@@ -0,0 +1,52 @@
+import { forwardRef } from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '../../lib/cn'
+import { Button, type ButtonProps } from '../button/button'
+import { chipFilledFillTokens, chipRadiusClass } from '../chip/chip-chrome'
+
+/** The shared 28px geometry and brand-hover treatment of selection action bars. */
+export const bulkActionButtonVariants = cva(
+ `${chipRadiusClass} size-[28px] p-0 hover-hover:bg-[var(--brand-secondary)] hover-hover:text-[var(--text-inverse)]!`,
+ {
+ variants: {
+ surface: {
+ adaptive: chipFilledFillTokens,
+ uniform: 'bg-[var(--surface-5)]',
+ },
+ },
+ defaultVariants: { surface: 'adaptive' },
+ }
+)
+
+export interface BulkActionButtonProps extends Omit {
+ /** Accessible name for the icon action; tooltip content is supplied separately. */
+ 'aria-label': string
+ /**
+ * `adaptive` follows the filled chip surface: surface-5 in light mode and surface-4 in dark.
+ * `uniform` retains surface-5 in both themes, as used by table-cell action bars.
+ * @default 'adaptive'
+ */
+ surface?: NonNullable['surface']>
+}
+
+/**
+ * Icon action for a selection's bulk-action bar. Owns its geometry and visual states;
+ * callers provide icon content, labels, disabled state and command behavior.
+ * Forwards the native button ref and props for tooltip/menu `asChild` composition.
+ * Native form behavior is inherited from Button; pass `type` when it must be explicit.
+ *
+ * @example
+ */
+export const BulkActionButton = forwardRef(
+ ({ surface, className, ...props }, ref) => (
+
+ )
+)
+
+BulkActionButton.displayName = 'BulkActionButton'
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index 9b195c0abf8..987281636cc 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -1,6 +1,11 @@
export { Avatar, AvatarFallback, AvatarImage } from './avatar/avatar'
export { Badge, type BadgeProps } from './badge/badge'
export { Banner } from './banner/banner'
+export {
+ BulkActionButton,
+ type BulkActionButtonProps,
+ bulkActionButtonVariants,
+} from './bulk-action-button/bulk-action-button'
export { Button, buttonVariants } from './button/button'
export {
CalendarDayCell,