feat: simplify bulk action bar

This commit is contained in:
lucaronin
2026-04-16 00:38:43 +02:00
parent 310e159894
commit 82192f23be
8 changed files with 289 additions and 83 deletions

View File

@@ -5,15 +5,20 @@ import { BulkActionBar } from './BulkActionBar'
describe('BulkActionBar', () => {
const defaultProps = {
count: 3,
onOrganize: vi.fn(),
onArchive: vi.fn(),
onDelete: vi.fn(),
onClear: vi.fn(),
}
it('shows Archive and Delete buttons in normal view', () => {
it('shows icon-only organize, archive, and delete buttons in normal view', () => {
render(<BulkActionBar {...defaultProps} />)
expect(screen.getByTestId('bulk-organize-btn')).toBeInTheDocument()
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
expect(screen.queryByText('Organize')).not.toBeInTheDocument()
expect(screen.queryByText('Archive')).not.toBeInTheDocument()
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
})
it('shows selected count', () => {
@@ -21,13 +26,64 @@ describe('BulkActionBar', () => {
expect(screen.getByText('5 selected')).toBeInTheDocument()
})
it('shows Unarchive and Delete buttons in archived view', () => {
it('shows organize, unarchive, and delete buttons in archived view', () => {
render(<BulkActionBar {...defaultProps} isArchivedView={true} />)
expect(screen.getByTestId('bulk-organize-btn')).toBeInTheDocument()
expect(screen.getByTestId('bulk-unarchive-btn')).toBeInTheDocument()
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
})
it('exposes accessible names and a destructive variant for icon-only actions', () => {
render(<BulkActionBar {...defaultProps} />)
expect(screen.getByRole('button', { name: 'Organize selected notes' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Archive selected notes' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Permanently delete selected notes' })).toHaveAttribute('data-variant', 'destructive')
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument()
})
it('keeps the icon-only controls focusable and activatable', () => {
const onOrganize = vi.fn()
const onArchive = vi.fn()
const onDelete = vi.fn()
const onClear = vi.fn()
render(
<BulkActionBar
{...defaultProps}
onOrganize={onOrganize}
onArchive={onArchive}
onDelete={onDelete}
onClear={onClear}
/>,
)
const organizeButton = screen.getByRole('button', { name: 'Organize selected notes' })
const archiveButton = screen.getByRole('button', { name: 'Archive selected notes' })
const deleteButton = screen.getByRole('button', { name: 'Permanently delete selected notes' })
const clearButton = screen.getByRole('button', { name: 'Clear selection' })
organizeButton.focus()
expect(organizeButton).toHaveFocus()
fireEvent.click(organizeButton)
expect(onOrganize).toHaveBeenCalledTimes(1)
archiveButton.focus()
expect(archiveButton).toHaveFocus()
fireEvent.click(archiveButton)
expect(onArchive).toHaveBeenCalledTimes(1)
deleteButton.focus()
expect(deleteButton).toHaveFocus()
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledTimes(1)
clearButton.focus()
expect(clearButton).toHaveFocus()
fireEvent.click(clearButton)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('calls onUnarchive when Unarchive button clicked in archived view', () => {
const onUnarchive = vi.fn()
render(<BulkActionBar {...defaultProps} isArchivedView={true} onUnarchive={onUnarchive} />)

View File

@@ -1,45 +1,74 @@
import { memo } from 'react'
import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
import { Archive, ArrowCounterClockwise, CheckCircle, Trash, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
interface BulkActionBarProps {
count: number
isArchivedView?: boolean
onOrganize?: () => void
onArchive: () => void
onDelete: () => void
onUnarchive?: () => void
onClear: () => void
}
const actionBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 } as const
const destructiveBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 } as const
interface BulkActionButtonProps {
ariaLabel: string
children: React.ReactNode
destructive?: boolean
onClick?: () => void
testId: string
}
function BulkActionButton({ ariaLabel, children, destructive = false, onClick, testId }: BulkActionButtonProps) {
return (
<Button
type="button"
size="icon-sm"
variant={destructive ? 'destructive' : 'ghost'}
className={
destructive
? 'h-8 w-8 rounded-lg bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/30'
: 'h-8 w-8 rounded-lg bg-white/10 text-background hover:bg-white/20 focus-visible:ring-white/35 disabled:bg-white/5 disabled:text-white/35'
}
onClick={onClick}
disabled={!onClick}
aria-label={ariaLabel}
title={ariaLabel}
data-testid={testId}
>
{children}
</Button>
)
}
function renderPrimaryActions(
isArchivedView: boolean,
onOrganize: (() => void) | undefined,
onArchive: () => void,
onDelete: () => void,
onUnarchive: (() => void) | undefined,
) {
const archiveLabel = isArchivedView ? 'Unarchive selected notes' : 'Archive selected notes'
const archiveIcon = isArchivedView ? <ArrowCounterClockwise size={16} /> : <Archive size={16} />
const archiveHandler = isArchivedView ? onUnarchive : onArchive
function renderArchivedActions(onUnarchive: (() => void) | undefined, onDelete: () => void) {
return (
<>
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onUnarchive} title="Unarchive selected notes" data-testid="bulk-unarchive-btn">
<ArrowCounterClockwise size={14} /> Unarchive
</button>
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
<Trash size={14} /> Delete
</button>
<BulkActionButton ariaLabel="Organize selected notes" onClick={onOrganize} testId="bulk-organize-btn">
<CheckCircle size={16} weight="fill" />
</BulkActionButton>
<BulkActionButton ariaLabel={archiveLabel} onClick={archiveHandler} testId={isArchivedView ? 'bulk-unarchive-btn' : 'bulk-archive-btn'}>
{archiveIcon}
</BulkActionButton>
<BulkActionButton ariaLabel="Permanently delete selected notes" destructive onClick={onDelete} testId="bulk-delete-btn">
<Trash size={16} />
</BulkActionButton>
</>
)
}
function renderDefaultActions(onArchive: () => void, onDelete: () => void) {
return (
<>
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
<Archive size={14} /> Archive
</button>
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
<Trash size={14} /> Delete
</button>
</>
)
}
function BulkActionBarInner({ count, isArchivedView, onArchive, onDelete, onUnarchive, onClear }: BulkActionBarProps) {
function BulkActionBarInner({ count, isArchivedView, onOrganize, onArchive, onDelete, onUnarchive, onClear }: BulkActionBarProps) {
return (
<div
className="flex shrink-0 items-center justify-between"
@@ -54,18 +83,20 @@ function BulkActionBarInner({ count, isArchivedView, onArchive, onDelete, onUnar
<span style={{ fontSize: 13, fontWeight: 500 }}>
{count} selected
</span>
<div className="flex items-center gap-1">
{isArchivedView ? renderArchivedActions(onUnarchive, onDelete)
: renderDefaultActions(onArchive, onDelete)}
<button
className="flex items-center border-none bg-transparent cursor-pointer"
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
<div className="flex items-center gap-1.5">
{renderPrimaryActions(Boolean(isArchivedView), onOrganize, onArchive, onDelete, onUnarchive)}
<Button
type="button"
size="icon-sm"
variant="ghost"
className="h-8 w-8 rounded-lg text-white/55 hover:bg-white/10 hover:text-background focus-visible:ring-white/30"
onClick={onClear}
aria-label="Clear selection"
title="Clear selection"
data-testid="bulk-clear-btn"
>
<X size={14} />
</button>
<X size={16} />
</Button>
</div>
</div>
)

View File

@@ -150,6 +150,7 @@ describe('NoteList multi-select', () => {
})
it.each([
{ label: 'organizes via button', prop: 'onBulkOrganize', trigger: () => fireEvent.click(screen.getByTestId('bulk-organize-btn')) },
{ label: 'archives via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) },
{ label: 'deletes via button', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.click(screen.getByTestId('bulk-delete-btn')) },
{ label: 'archives via Cmd+E', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) },

View File

@@ -1,10 +1,20 @@
import { memo } from 'react'
import { memo, useCallback } from 'react'
import { NoteListLayout } from './note-list/NoteListLayout'
import { useNoteListModel, type NoteListProps } from './note-list/useNoteListModel'
function NoteListInner(props: NoteListProps) {
type NoteListInnerProps = NoteListProps & {
onBulkOrganize?: (paths: string[]) => void
}
function NoteListInner({ onBulkOrganize, ...props }: NoteListInnerProps) {
const model = useNoteListModel(props)
return <NoteListLayout {...model} />
const handleBulkOrganize = useCallback(() => {
const paths = [...model.multiSelect.selectedPaths]
model.multiSelect.clear()
onBulkOrganize?.(paths)
}, [model.multiSelect, onBulkOrganize])
return <NoteListLayout {...model} handleBulkOrganize={onBulkOrganize ? handleBulkOrganize : undefined} />
}
export const NoteList = memo(NoteListInner)

View File

@@ -4,7 +4,32 @@ import { NoteListHeader } from './NoteListHeader'
import { EntityView, ListView } from './NoteListViews'
import type { useNoteListModel } from './useNoteListModel'
type NoteListLayoutProps = ReturnType<typeof useNoteListModel>
type NoteListLayoutProps = ReturnType<typeof useNoteListModel> & {
handleBulkOrganize?: () => void
}
function MultiSelectBar({
multiSelect,
isArchivedView,
handleBulkOrganize,
handleBulkArchive,
handleBulkDeletePermanently,
handleBulkUnarchive,
}: Pick<NoteListLayoutProps, 'multiSelect' | 'isArchivedView' | 'handleBulkOrganize' | 'handleBulkArchive' | 'handleBulkDeletePermanently' | 'handleBulkUnarchive'>) {
if (!multiSelect.isMultiSelecting) return null
return (
<BulkActionBar
count={multiSelect.selectedPaths.size}
isArchivedView={isArchivedView}
onOrganize={handleBulkOrganize}
onArchive={handleBulkArchive}
onDelete={handleBulkDeletePermanently}
onUnarchive={handleBulkUnarchive}
onClear={multiSelect.clear}
/>
)
}
export function NoteListLayout({
title,
@@ -43,6 +68,7 @@ export function NoteListLayout({
filterCounts,
onNoteListFilterChange,
multiSelect,
handleBulkOrganize,
handleBulkArchive,
handleBulkDeletePermanently,
handleBulkUnarchive,
@@ -115,16 +141,14 @@ export function NoteListLayout({
/>
)}
</div>
{multiSelect.isMultiSelecting && (
<BulkActionBar
count={multiSelect.selectedPaths.size}
isArchivedView={isArchivedView}
onArchive={handleBulkArchive}
onDelete={handleBulkDeletePermanently}
onUnarchive={handleBulkUnarchive}
onClear={multiSelect.clear}
/>
)}
<MultiSelectBar
multiSelect={multiSelect}
isArchivedView={isArchivedView}
handleBulkOrganize={handleBulkOrganize}
handleBulkArchive={handleBulkArchive}
handleBulkDeletePermanently={handleBulkDeletePermanently}
handleBulkUnarchive={handleBulkUnarchive}
/>
{contextMenuNode}
{dialogNode}
</div>