| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- import { useState, useEffect, useRef } from 'react'
- import { type Table } from '@tanstack/react-table'
- import { X } from 'lucide-react'
- import { cn } from '@/lib/utils'
- import { Badge } from '@/components/ui/badge'
- import { Button } from '@/components/ui/button'
- import { Separator } from '@/components/ui/separator'
- import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from '@/components/ui/tooltip'
- interface BulkActionsToolbarProps<TData> {
- table: Table<TData>
- entityName: string
- children: React.ReactNode
- }
- /**
- * A modular toolbar for displaying bulk actions when table rows are selected.
- *
- * @template TData The type of data in the table.
- * @param {object} props The component props.
- * @param {Table<TData>} props.table The react-table instance.
- * @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
- * @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
- * @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
- */
- export function BulkActionsToolbar<TData>({
- table,
- entityName,
- children,
- }: BulkActionsToolbarProps<TData>): React.ReactNode | null {
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedCount = selectedRows.length
- const toolbarRef = useRef<HTMLDivElement>(null)
- const [announcement, setAnnouncement] = useState('')
- // Announce selection changes to screen readers
- useEffect(() => {
- if (selectedCount > 0) {
- const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
- setAnnouncement(message)
- // Clear announcement after a delay
- const timer = setTimeout(() => setAnnouncement(''), 3000)
- return () => clearTimeout(timer)
- }
- }, [selectedCount, entityName])
- const handleClearSelection = () => {
- table.resetRowSelection()
- }
- const handleKeyDown = (event: React.KeyboardEvent) => {
- const buttons = toolbarRef.current?.querySelectorAll('button')
- if (!buttons) return
- const currentIndex = Array.from(buttons).findIndex(
- (button) => button === document.activeElement
- )
- switch (event.key) {
- case 'ArrowRight': {
- event.preventDefault()
- const nextIndex = (currentIndex + 1) % buttons.length
- buttons[nextIndex]?.focus()
- break
- }
- case 'ArrowLeft': {
- event.preventDefault()
- const prevIndex =
- currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
- buttons[prevIndex]?.focus()
- break
- }
- case 'Home':
- event.preventDefault()
- buttons[0]?.focus()
- break
- case 'End':
- event.preventDefault()
- buttons[buttons.length - 1]?.focus()
- break
- case 'Escape': {
- // Check if the Escape key came from a dropdown trigger or content
- // We can't check dropdown state because Radix UI closes it before our handler runs
- const target = event.target as HTMLElement
- const activeElement = document.activeElement as HTMLElement
- // Check if the event target or currently focused element is a dropdown trigger
- const isFromDropdownTrigger =
- target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
- activeElement?.getAttribute('data-slot') ===
- 'dropdown-menu-trigger' ||
- target?.closest('[data-slot="dropdown-menu-trigger"]') ||
- activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
- // Check if the focused element is inside dropdown content (which is portaled)
- const isFromDropdownContent =
- activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
- target?.closest('[data-slot="dropdown-menu-content"]')
- if (isFromDropdownTrigger || isFromDropdownContent) {
- // Escape was meant for the dropdown - don't clear selection
- return
- }
- // Escape was meant for the toolbar - clear selection
- event.preventDefault()
- handleClearSelection()
- break
- }
- }
- }
- if (selectedCount === 0) {
- return null
- }
- return (
- <>
- {/* Live region for screen reader announcements */}
- <div
- aria-live='polite'
- aria-atomic='true'
- className='sr-only'
- role='status'
- >
- {announcement}
- </div>
- <div
- ref={toolbarRef}
- role='toolbar'
- aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
- aria-describedby='bulk-actions-description'
- tabIndex={-1}
- onKeyDown={handleKeyDown}
- className={cn(
- 'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
- 'transition-all delay-100 duration-300 ease-out hover:scale-105',
- 'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
- )}
- >
- <div
- className={cn(
- 'p-2 shadow-xl',
- 'rounded-xl border',
- 'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
- 'flex items-center gap-x-2'
- )}
- >
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant='outline'
- size='icon'
- onClick={handleClearSelection}
- className='size-6 rounded-full'
- aria-label='Clear selection'
- title='Clear selection (Escape)'
- >
- <X />
- <span className='sr-only'>Clear selection</span>
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>Clear selection (Escape)</p>
- </TooltipContent>
- </Tooltip>
- <Separator
- className='h-5'
- orientation='vertical'
- aria-hidden='true'
- />
- <div
- className='flex items-center gap-x-1 text-sm'
- id='bulk-actions-description'
- >
- <Badge
- variant='default'
- className='min-w-8 rounded-lg'
- aria-label={`${selectedCount} selected`}
- >
- {selectedCount}
- </Badge>{' '}
- <span className='hidden sm:inline'>
- {entityName}
- {selectedCount > 1 ? 's' : ''}
- </span>{' '}
- selected
- </div>
- <Separator
- className='h-5'
- orientation='vertical'
- aria-hidden='true'
- />
- {children}
- </div>
- </div>
- </>
- )
- }
|