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) => ( +