Skip to content

Commit bb0bc7d

Browse files
committed
feat: add data table bulk action toolbar (#196)
This commit introduces a new bulk action toolbar that appears when a user selects one or more rows in a data table, providing an intuitive way to perform actions on multiple items at once. The new `BulkActionsToolbar` is a generic and reusable component designed with accessibility in mind. It features full keyboard navigation (Arrow keys, Home, End, Escape) and provides ARIA live announcements for screen readers. Key features include: - A floating design that remains fixed at the bottom of the viewport. - Bulk actions for the Tasks table: update status, change priority, export, and delete. - Bulk actions for the Users table: invite, activate/deactivate, and delete. - A confirmation dialog for destructive actions, requiring the user to type "DELETE" to proceed.
1 parent c8aa7c3 commit bb0bc7d

9 files changed

Lines changed: 744 additions & 3 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { useState, useEffect, useRef } from 'react'
2+
import { Table } from '@tanstack/react-table'
3+
import { X } from 'lucide-react'
4+
import { cn } from '@/lib/utils'
5+
import { Badge } from '@/components/ui/badge'
6+
import { Button } from '@/components/ui/button'
7+
import { Separator } from '@/components/ui/separator'
8+
import {
9+
Tooltip,
10+
TooltipContent,
11+
TooltipTrigger,
12+
} from '@/components/ui/tooltip'
13+
14+
interface BulkActionsToolbarProps<TData> {
15+
table: Table<TData>
16+
entityName: string
17+
children: React.ReactNode
18+
}
19+
20+
/**
21+
* A modular toolbar for displaying bulk actions when table rows are selected.
22+
*
23+
* @template TData The type of data in the table.
24+
* @param {object} props The component props.
25+
* @param {Table<TData>} props.table The react-table instance.
26+
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
27+
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
28+
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
29+
*/
30+
export function BulkActionsToolbar<TData>({
31+
table,
32+
entityName,
33+
children,
34+
}: BulkActionsToolbarProps<TData>): React.ReactNode | null {
35+
const selectedRows = table.getFilteredSelectedRowModel().rows
36+
const selectedCount = selectedRows.length
37+
const toolbarRef = useRef<HTMLDivElement>(null)
38+
const [announcement, setAnnouncement] = useState('')
39+
40+
// Announce selection changes to screen readers
41+
useEffect(() => {
42+
if (selectedCount > 0) {
43+
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
44+
setAnnouncement(message)
45+
46+
// Clear announcement after a delay
47+
const timer = setTimeout(() => setAnnouncement(''), 3000)
48+
return () => clearTimeout(timer)
49+
}
50+
}, [selectedCount, entityName])
51+
52+
const handleClearSelection = () => {
53+
table.resetRowSelection()
54+
}
55+
56+
const handleKeyDown = (event: React.KeyboardEvent) => {
57+
const buttons = toolbarRef.current?.querySelectorAll('button')
58+
if (!buttons) return
59+
60+
const currentIndex = Array.from(buttons).findIndex(
61+
(button) => button === document.activeElement
62+
)
63+
64+
switch (event.key) {
65+
case 'ArrowRight': {
66+
event.preventDefault()
67+
const nextIndex = (currentIndex + 1) % buttons.length
68+
buttons[nextIndex]?.focus()
69+
break
70+
}
71+
case 'ArrowLeft': {
72+
event.preventDefault()
73+
const prevIndex =
74+
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
75+
buttons[prevIndex]?.focus()
76+
break
77+
}
78+
case 'Home':
79+
event.preventDefault()
80+
buttons[0]?.focus()
81+
break
82+
case 'End':
83+
event.preventDefault()
84+
buttons[buttons.length - 1]?.focus()
85+
break
86+
case 'Escape': {
87+
// Check if the Escape key came from a dropdown trigger or content
88+
// We can't check dropdown state because Radix UI closes it before our handler runs
89+
const target = event.target as HTMLElement
90+
const activeElement = document.activeElement as HTMLElement
91+
92+
// Check if the event target or currently focused element is a dropdown trigger
93+
const isFromDropdownTrigger =
94+
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
95+
activeElement?.getAttribute('data-slot') ===
96+
'dropdown-menu-trigger' ||
97+
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
98+
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
99+
100+
// Check if the focused element is inside dropdown content (which is portaled)
101+
const isFromDropdownContent =
102+
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
103+
target?.closest('[data-slot="dropdown-menu-content"]')
104+
105+
if (isFromDropdownTrigger || isFromDropdownContent) {
106+
// Escape was meant for the dropdown - don't clear selection
107+
return
108+
}
109+
110+
// Escape was meant for the toolbar - clear selection
111+
event.preventDefault()
112+
handleClearSelection()
113+
break
114+
}
115+
}
116+
}
117+
118+
if (selectedCount === 0) {
119+
return null
120+
}
121+
122+
return (
123+
<>
124+
{/* Live region for screen reader announcements */}
125+
<div
126+
aria-live='polite'
127+
aria-atomic='true'
128+
className='sr-only'
129+
role='status'
130+
>
131+
{announcement}
132+
</div>
133+
134+
<div
135+
ref={toolbarRef}
136+
role='toolbar'
137+
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
138+
aria-describedby='bulk-actions-description'
139+
tabIndex={-1}
140+
onKeyDown={handleKeyDown}
141+
className={cn(
142+
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
143+
'transition-all delay-100 duration-300 ease-out hover:scale-105',
144+
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
145+
)}
146+
>
147+
<div
148+
className={cn(
149+
'p-2 shadow-xl',
150+
'rounded-xl border',
151+
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
152+
'flex items-center gap-x-2'
153+
)}
154+
>
155+
<Tooltip>
156+
<TooltipTrigger asChild>
157+
<Button
158+
variant='outline'
159+
size='icon'
160+
onClick={handleClearSelection}
161+
className='size-6 rounded-full'
162+
aria-label='Clear selection'
163+
title='Clear selection (Escape)'
164+
>
165+
<X />
166+
<span className='sr-only'>Clear selection</span>
167+
</Button>
168+
</TooltipTrigger>
169+
<TooltipContent>
170+
<p>Clear selection (Escape)</p>
171+
</TooltipContent>
172+
</Tooltip>
173+
174+
<Separator
175+
className='h-5'
176+
orientation='vertical'
177+
aria-hidden='true'
178+
/>
179+
180+
<div
181+
className='flex items-center gap-x-1 text-sm'
182+
id='bulk-actions-description'
183+
>
184+
<Badge
185+
variant='default'
186+
className='min-w-8 rounded-lg'
187+
aria-label={`${selectedCount} selected`}
188+
>
189+
{selectedCount}
190+
</Badge>{' '}
191+
<span className='hidden sm:inline'>
192+
{entityName}
193+
{selectedCount > 1 ? 's' : ''}
194+
</span>{' '}
195+
selected
196+
</div>
197+
198+
<Separator
199+
className='h-5'
200+
orientation='vertical'
201+
aria-hidden='true'
202+
/>
203+
204+
{children}
205+
</div>
206+
</div>
207+
</>
208+
)
209+
}

0 commit comments

Comments
 (0)