feat: simplify bulk action bar
This commit is contained in:
@@ -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} />)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user