bulk-actions-toolbar.tsx 6.6 KB

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