From bce8234278bda2587a0068275d953bab4d28a6f8 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 18 Sep 2026 13:15:39 -0700 Subject: [PATCH 1/3] refactor(ui): share resource bulk-action button styles --- .../components/action-bar/action-bar-styles.ts | 7 +++++++ .../resource/components/action-bar/action-bar.tsx | 12 +++--------- .../[id]/components/action-bar/action-bar.tsx | 15 +++++---------- .../table-action-bar/table-action-bar.tsx | 3 ++- 4 files changed, 17 insertions(+), 20 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles.ts new file mode 100644 index 00000000000..328dd790ab8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles.ts @@ -0,0 +1,7 @@ +import { chipFilledFillTokens, chipRadiusClass } from '@sim/emcn' + +/** Shared geometry and hover treatment for resource bulk-action buttons. */ +export const RESOURCE_ACTION_BUTTON_BASE = `${chipRadiusClass} size-[28px] p-0 hover-hover:bg-[var(--brand-secondary)] hover-hover:text-[var(--text-inverse)]!` + +/** Resource and knowledge actions follow the chip fill in both themes. */ +export const RESOURCE_ACTION_BUTTON_FILLED = `${chipFilledFillTokens} ${RESOURCE_ACTION_BUTTON_BASE}` 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..c03722e40f9 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 @@ -3,7 +3,6 @@ import type { ComponentType } from 'react' import { Button, - chipFilledFillTokens, cn, DropdownMenu, DropdownMenuContent, @@ -15,12 +14,7 @@ import { 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)]' -) +import { RESOURCE_ACTION_BUTTON_FILLED } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles' interface ActionButtonProps { icon: ComponentType<{ className?: string }> @@ -38,7 +32,7 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp variant='ghost' onClick={onClick} disabled={disabled} - className={ACTION_BUTTON_CLASS} + className={RESOURCE_ACTION_BUTTON_FILLED} > @@ -133,7 +127,7 @@ export function ResourceActionBar({ aria-label='Move' variant='ghost' disabled={actionsDisabled} - className={ACTION_BUTTON_CLASS} + className={RESOURCE_ACTION_BUTTON_FILLED} > 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..c5f35afda73 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,9 @@ -import { Button, chipFilledFillTokens, cn, Tooltip } from '@sim/emcn' +import { Button, cn, Tooltip } from '@sim/emcn' import { Ban, Circle, Trash } from '@sim/emcn/icons' import { domAnimation, LazyMotion, m } from 'framer-motion' +import { RESOURCE_ACTION_BUTTON_FILLED } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles' 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 @@ -97,7 +92,7 @@ export function ActionBar({ aria-label='Enable' onClick={onEnable} disabled={isLoading} - className={ACTION_BUTTON_CLASS} + className={RESOURCE_ACTION_BUTTON_FILLED} > @@ -114,7 +109,7 @@ export function ActionBar({ aria-label='Disable' onClick={onDisable} disabled={isLoading} - className={ACTION_BUTTON_CLASS} + className={RESOURCE_ACTION_BUTTON_FILLED} > @@ -131,7 +126,7 @@ export function ActionBar({ aria-label='Delete' onClick={onDelete} disabled={isLoading} - className={ACTION_BUTTON_CLASS} + className={RESOURCE_ACTION_BUTTON_FILLED} > 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..b556f2322eb 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 @@ -4,6 +4,7 @@ import type React from 'react' import { Button, cn, Tooltip } from '@sim/emcn' import { Eye, PlayOutline, RefreshCw, Square } from '@sim/emcn/icons' import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' +import { RESOURCE_ACTION_BUTTON_BASE } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles' interface TableActionBarProps { /** Number of (row × group) cells the run/stop buttons would target. Drives @@ -157,7 +158,7 @@ function ActionIconButton({ label, onClick, disabled, children }: ActionIconButt variant='ghost' onClick={onClick} disabled={disabled} - className='size-[28px] rounded-lg bg-[var(--surface-5)] p-0 hover-hover:bg-[var(--brand-secondary)] hover-hover:text-[var(--text-inverse)]!' + className={cn(RESOURCE_ACTION_BUTTON_BASE, 'bg-[var(--surface-5)]')} aria-label={label} > {children} From 86af6a9e17d90056a4e33227d25d5a921b88ddbb Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 18 Sep 2026 13:44:03 -0700 Subject: [PATCH 2/3] refactor(emcn): own bulk-action button appearance centrally --- .../action-bar/action-bar-styles.ts | 7 - .../components/action-bar/action-bar.tsx | 22 +-- .../[id]/components/action-bar/action-bar.tsx | 33 +--- .../table-action-bar/table-action-bar.tsx | 13 +- .../bulk-action-button.test.tsx | 159 ++++++++++++++++++ .../bulk-action-button/bulk-action-button.tsx | 52 ++++++ packages/emcn/src/components/index.ts | 5 + 7 files changed, 233 insertions(+), 58 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles.ts create mode 100644 packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx create mode 100644 packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles.ts deleted file mode 100644 index 328dd790ab8..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { chipFilledFillTokens, chipRadiusClass } from '@sim/emcn' - -/** Shared geometry and hover treatment for resource bulk-action buttons. */ -export const RESOURCE_ACTION_BUTTON_BASE = `${chipRadiusClass} size-[28px] p-0 hover-hover:bg-[var(--brand-secondary)] hover-hover:text-[var(--text-inverse)]!` - -/** Resource and knowledge actions follow the chip fill in both themes. */ -export const RESOURCE_ACTION_BUTTON_FILLED = `${chipFilledFillTokens} ${RESOURCE_ACTION_BUTTON_BASE}` 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 c03722e40f9..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,7 +2,7 @@ import type { ComponentType } from 'react' import { - Button, + BulkActionButton, cn, DropdownMenu, DropdownMenuContent, @@ -14,7 +14,6 @@ import { import { Download } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' -import { RESOURCE_ACTION_BUTTON_FILLED } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles' interface ActionButtonProps { icon: ComponentType<{ className?: string }> @@ -27,15 +26,9 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp return ( - + {label} @@ -123,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 c5f35afda73..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,7 +1,6 @@ -import { Button, 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 { RESOURCE_ACTION_BUTTON_FILLED } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' interface ActionBarProps { @@ -87,15 +86,9 @@ export function ActionBar({ {showEnableButton && ( - + Enable @@ -104,15 +97,9 @@ export function ActionBar({ {showDisableButton && ( - + Disable @@ -121,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 b556f2322eb..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,10 +1,9 @@ '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' -import { RESOURCE_ACTION_BUTTON_BASE } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar-styles' interface TableActionBarProps { /** Number of (row × group) cells the run/stop buttons would target. Drives @@ -147,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..88cfaf6a9f8 --- /dev/null +++ b/packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx @@ -0,0 +1,159 @@ +/** @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) { + for (const disabled of [false, true]) { + it(`preserves the previous button markup for ${surface ?? 'default'} / disabled=${disabled}`, () => { + 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', 'submit'] 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) => ( + - - - - - ) - 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(`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', () => { @@ -99,7 +96,7 @@ describe('BulkActionButton', () => { expect(onClick).not.toHaveBeenCalled() }) - for (const type of [undefined, 'button', 'submit'] as const) { + 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(