Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import type { ComponentType } from 'react'
import {
Button,
chipFilledFillTokens,
BulkActionButton,
cn,
DropdownMenu,
DropdownMenuContent,
Expand All @@ -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
Expand All @@ -33,15 +26,9 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
aria-label={label}
variant='ghost'
onClick={onClick}
disabled={disabled}
className={ACTION_BUTTON_CLASS}
>
<BulkActionButton aria-label={label} onClick={onClick} disabled={disabled}>
<Icon className='size-[12px]' />
</Button>
</BulkActionButton>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{label}</Tooltip.Content>
</Tooltip.Root>
Expand Down Expand Up @@ -129,14 +116,9 @@ export function ResourceActionBar({
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label='Move'
variant='ghost'
disabled={actionsDisabled}
className={ACTION_BUTTON_CLASS}
>
<BulkActionButton aria-label='Move' disabled={actionsDisabled}>
<Folder className='size-[12px]' />
</Button>
</BulkActionButton>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Move</Tooltip.Content>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -92,15 +86,9 @@ export function ActionBar({
{showEnableButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
aria-label='Enable'
onClick={onEnable}
disabled={isLoading}
className={ACTION_BUTTON_CLASS}
>
<BulkActionButton aria-label='Enable' onClick={onEnable} disabled={isLoading}>
<Circle className='size-[12px]' />
</Button>
</BulkActionButton>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Enable</Tooltip.Content>
</Tooltip.Root>
Expand All @@ -109,15 +97,9 @@ export function ActionBar({
{showDisableButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
aria-label='Disable'
onClick={onDisable}
disabled={isLoading}
className={ACTION_BUTTON_CLASS}
>
<BulkActionButton aria-label='Disable' onClick={onDisable} disabled={isLoading}>
<Ban className='size-[12px]' />
</Button>
</BulkActionButton>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Disable</Tooltip.Content>
</Tooltip.Root>
Expand All @@ -126,15 +108,9 @@ export function ActionBar({
{onDelete && canEdit && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
aria-label='Delete'
onClick={onDelete}
disabled={isLoading}
className={ACTION_BUTTON_CLASS}
>
<BulkActionButton aria-label='Delete' onClick={onDelete} disabled={isLoading}>
<Trash className='size-[12px]' />
</Button>
</BulkActionButton>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Delete</Tooltip.Content>
</Tooltip.Root>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
<BulkActionButton
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)]!'
surface='uniform'
aria-label={label}
>
{children}
</Button>
</BulkActionButton>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{label}</Tooltip.Content>
</Tooltip.Root>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<>
<Button
variant='ghost'
aria-label='Delete'
className={`${previousFill} ${PREVIOUS_GEOMETRY}`}
>
<svg className='size-[12px]' aria-hidden='true' />
</Button>
<BulkActionButton aria-label='Delete' surface={surface}>
<svg className='size-[12px]' aria-hidden='true' />
</BulkActionButton>
</>
)
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<HTMLButtonElement>()
const onClick = vi.fn()
const onKeyDown = vi.fn()
mount(
<BulkActionButton
ref={ref}
aria-label='Download'
data-action='download'
onClick={onClick}
onKeyDown={onKeyDown}
/>
)
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(<BulkActionButton aria-label='Delete' onClick={onClick} disabled />)
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(
<form onSubmit={onSubmit}>
<BulkActionButton aria-label='Run' type={type} />
</form>
)
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<HTMLButtonElement>()
mount(
<DropdownMenu open={false} onOpenChange={onOpenChange}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<BulkActionButton ref={ref} aria-label='Move' onKeyDown={onKeyDown} />
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content>Move</Tooltip.Content>
</Tooltip.Root>
</DropdownMenu>
)
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(
<Tooltip.Root>
<Tooltip.Trigger asChild>
<BulkActionButton aria-label='Download' />
</Tooltip.Trigger>
<Tooltip.Content>Download selected files</Tooltip.Content>
</Tooltip.Root>
)
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')
})
})
Original file line number Diff line number Diff line change
@@ -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<ButtonProps, 'variant' | 'size'> {
/** 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<VariantProps<typeof bulkActionButtonVariants>['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 <BulkActionButton aria-label='Delete' onClick={onDelete}><Trash className='size-[12px]' /></BulkActionButton>
*/
export const BulkActionButton = forwardRef<HTMLButtonElement, BulkActionButtonProps>(
({ surface, className, ...props }, ref) => (
<Button
{...props}
ref={ref}
variant='ghost'
size='md'
className={cn(bulkActionButtonVariants({ surface }), className)}
/>
)
)

BulkActionButton.displayName = 'BulkActionButton'
5 changes: 5 additions & 0 deletions packages/emcn/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading