Просмотр исходного кода

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.
Sat Naing 11 месяцев назад
Родитель
Сommit
bb0bc7d9bc

+ 209 - 0
src/components/bulk-actions-toolbar.tsx

@@ -0,0 +1,209 @@
+import { useState, useEffect, useRef } from 'react'
+import { 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>
+    </>
+  )
+}

+ 193 - 0
src/features/tasks/components/data-table-bulk-actions.tsx

@@ -0,0 +1,193 @@
+import { useState } from 'react'
+import { Table } from '@tanstack/react-table'
+import { Trash2, CircleArrowUp, ArrowUpDown, Download } from 'lucide-react'
+import { toast } from 'sonner'
+import { sleep } from '@/utils/sleep'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { BulkActionsToolbar } from '@/components/bulk-actions-toolbar'
+import { priorities, statuses } from '../data/data'
+import { Task } from '../data/schema'
+import { TasksMultiDeleteDialog } from './tasks-multi-delete-dialog'
+
+interface DataTableBulkActionsProps<TData> {
+  table: Table<TData>
+}
+
+export function DataTableBulkActions<TData>({
+  table,
+}: DataTableBulkActionsProps<TData>) {
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+  const selectedRows = table.getFilteredSelectedRowModel().rows
+
+  const handleBulkStatusChange = (status: string) => {
+    const selectedTasks = selectedRows.map((row) => row.original as Task)
+    toast.promise(sleep(2000), {
+      loading: 'Updating status...',
+      success: () => {
+        table.resetRowSelection()
+        return `Status updated to "${status}" for ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''}.`
+      },
+      error: 'Error',
+    })
+    table.resetRowSelection()
+  }
+
+  const handleBulkPriorityChange = (priority: string) => {
+    const selectedTasks = selectedRows.map((row) => row.original as Task)
+    toast.promise(sleep(2000), {
+      loading: 'Updating priority...',
+      success: () => {
+        table.resetRowSelection()
+        return `Priority updated to "${priority}" for ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''}.`
+      },
+      error: 'Error',
+    })
+    table.resetRowSelection()
+  }
+
+  const handleBulkExport = () => {
+    const selectedTasks = selectedRows.map((row) => row.original as Task)
+    toast.promise(sleep(2000), {
+      loading: 'Exporting tasks...',
+      success: () => {
+        table.resetRowSelection()
+        return `Exported ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''} to CSV.`
+      },
+      error: 'Error',
+    })
+    table.resetRowSelection()
+  }
+
+  return (
+    <>
+      <BulkActionsToolbar table={table} entityName='task'>
+        <DropdownMenu>
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <DropdownMenuTrigger asChild>
+                <Button
+                  variant='outline'
+                  size='icon'
+                  className='size-8'
+                  aria-label='Update status'
+                  title='Update status'
+                >
+                  <CircleArrowUp />
+                  <span className='sr-only'>Update status</span>
+                </Button>
+              </DropdownMenuTrigger>
+            </TooltipTrigger>
+            <TooltipContent>
+              <p>Update status</p>
+            </TooltipContent>
+          </Tooltip>
+          <DropdownMenuContent sideOffset={14}>
+            {statuses.map((status) => (
+              <DropdownMenuItem
+                key={status.value}
+                defaultValue={status.value}
+                onClick={() => handleBulkStatusChange(status.value)}
+              >
+                {status.icon && (
+                  <status.icon className='text-muted-foreground me-2 h-4 w-4' />
+                )}
+                {status.label}
+              </DropdownMenuItem>
+            ))}
+          </DropdownMenuContent>
+        </DropdownMenu>
+
+        <DropdownMenu>
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <DropdownMenuTrigger asChild>
+                <Button
+                  variant='outline'
+                  size='icon'
+                  className='size-8'
+                  aria-label='Update priority'
+                  title='Update priority'
+                >
+                  <ArrowUpDown />
+                  <span className='sr-only'>Update priority</span>
+                </Button>
+              </DropdownMenuTrigger>
+            </TooltipTrigger>
+            <TooltipContent>
+              <p>Update priority</p>
+            </TooltipContent>
+          </Tooltip>
+          <DropdownMenuContent sideOffset={14}>
+            {priorities.map((priority) => (
+              <DropdownMenuItem
+                key={priority.value}
+                defaultValue={priority.value}
+                onClick={() => handleBulkPriorityChange(priority.value)}
+              >
+                {priority.icon && (
+                  <priority.icon className='text-muted-foreground me-2 h-4 w-4' />
+                )}
+                {priority.label}
+              </DropdownMenuItem>
+            ))}
+          </DropdownMenuContent>
+        </DropdownMenu>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='outline'
+              size='icon'
+              onClick={() => handleBulkExport()}
+              className='size-8'
+              aria-label='Export tasks'
+              title='Export tasks'
+            >
+              <Download />
+              <span className='sr-only'>Export tasks</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Export tasks</p>
+          </TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='destructive'
+              size='icon'
+              onClick={() => setShowDeleteConfirm(true)}
+              className='size-8'
+              aria-label='Delete selected tasks'
+              title='Delete selected tasks'
+            >
+              <Trash2 />
+              <span className='sr-only'>Delete selected tasks</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Delete selected tasks</p>
+          </TooltipContent>
+        </Tooltip>
+      </BulkActionsToolbar>
+
+      <TasksMultiDeleteDialog
+        open={showDeleteConfirm}
+        onOpenChange={setShowDeleteConfirm}
+        table={table}
+      />
+    </>
+  )
+}

+ 3 - 1
src/features/tasks/components/data-table.tsx

@@ -23,6 +23,7 @@ import {
 } from '@/components/ui/table'
 import { DataTablePagination } from '../components/data-table-pagination'
 import { DataTableToolbar } from '../components/data-table-toolbar'
+import { DataTableBulkActions } from './data-table-bulk-actions'
 
 interface DataTableProps<TData, TValue> {
   columns: ColumnDef<TData, TValue>[]
@@ -64,7 +65,7 @@ export function DataTable<TData, TValue>({
   })
 
   return (
-    <div className='space-y-4'>
+    <div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
       <DataTableToolbar table={table} />
       <div className='overflow-hidden rounded-md border'>
         <Table>
@@ -117,6 +118,7 @@ export function DataTable<TData, TValue>({
         </Table>
       </div>
       <DataTablePagination table={table} />
+      <DataTableBulkActions table={table} />
     </div>
   )
 }

+ 94 - 0
src/features/tasks/components/tasks-multi-delete-dialog.tsx

@@ -0,0 +1,94 @@
+'use client'
+
+import { useState } from 'react'
+import { Table } from '@tanstack/react-table'
+import { AlertTriangle } from 'lucide-react'
+import { toast } from 'sonner'
+import { sleep } from '@/utils/sleep'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { ConfirmDialog } from '@/components/confirm-dialog'
+
+interface Props<TData> {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  table: Table<TData>
+}
+
+const CONFIRM_WORD = 'DELETE'
+
+export function TasksMultiDeleteDialog<TData>({
+  open,
+  onOpenChange,
+  table,
+}: Props<TData>) {
+  const [value, setValue] = useState('')
+
+  const selectedRows = table.getFilteredSelectedRowModel().rows
+
+  const handleDelete = () => {
+    if (value.trim() !== CONFIRM_WORD) {
+      toast.error(`Please type "${CONFIRM_WORD}" to confirm.`)
+      return
+    }
+
+    onOpenChange(false)
+
+    toast.promise(sleep(2000), {
+      loading: 'Deleting tasks...',
+      success: () => {
+        table.resetRowSelection()
+        return `Deleted ${selectedRows.length} ${
+          selectedRows.length > 1 ? 'tasks' : 'task'
+        }`
+      },
+      error: 'Error',
+    })
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={onOpenChange}
+      handleConfirm={handleDelete}
+      disabled={value.trim() !== CONFIRM_WORD}
+      title={
+        <span className='text-destructive'>
+          <AlertTriangle
+            className='stroke-destructive me-1 inline-block'
+            size={18}
+          />{' '}
+          Delete {selectedRows.length}{' '}
+          {selectedRows.length > 1 ? 'tasks' : 'task'}
+        </span>
+      }
+      desc={
+        <div className='space-y-4'>
+          <p className='mb-2'>
+            Are you sure you want to delete the selected tasks? <br />
+            This action cannot be undone.
+          </p>
+
+          <Label className='my-4 flex flex-col items-start gap-1.5'>
+            <span className=''>Confirm by typing "{CONFIRM_WORD}":</span>
+            <Input
+              value={value}
+              onChange={(e) => setValue(e.target.value)}
+              placeholder={`Type "${CONFIRM_WORD}" to confirm.`}
+            />
+          </Label>
+
+          <Alert variant='destructive'>
+            <AlertTitle>Warning!</AlertTitle>
+            <AlertDescription>
+              Please be careful, this operation can not be rolled back.
+            </AlertDescription>
+          </Alert>
+        </div>
+      }
+      confirmText='Delete'
+      destructive
+    />
+  )
+}

+ 139 - 0
src/features/users/components/data-table-bulk-actions.tsx

@@ -0,0 +1,139 @@
+import { useState } from 'react'
+import { Table } from '@tanstack/react-table'
+import { Trash2, UserX, UserCheck, Mail } from 'lucide-react'
+import { toast } from 'sonner'
+import { sleep } from '@/utils/sleep'
+import { Button } from '@/components/ui/button'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { BulkActionsToolbar } from '@/components/bulk-actions-toolbar'
+import { User } from '../data/schema'
+import { UsersMultiDeleteDialog } from './users-multi-delete-dialog'
+
+interface DataTableBulkActionsProps<TData> {
+  table: Table<TData>
+}
+
+export function DataTableBulkActions<TData>({
+  table,
+}: DataTableBulkActionsProps<TData>) {
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+  const selectedRows = table.getFilteredSelectedRowModel().rows
+
+  const handleBulkStatusChange = (status: 'active' | 'inactive') => {
+    const selectedUsers = selectedRows.map((row) => row.original as User)
+    toast.promise(sleep(2000), {
+      loading: `${status === 'active' ? 'Activating' : 'Deactivating'} users...`,
+      success: () => {
+        table.resetRowSelection()
+        return `${status === 'active' ? 'Activated' : 'Deactivated'} ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
+      },
+      error: `Error ${status === 'active' ? 'activating' : 'deactivating'} users`,
+    })
+    table.resetRowSelection()
+  }
+
+  const handleBulkInvite = () => {
+    const selectedUsers = selectedRows.map((row) => row.original as User)
+    toast.promise(sleep(2000), {
+      loading: 'Inviting users...',
+      success: () => {
+        table.resetRowSelection()
+        return `Invited ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
+      },
+      error: 'Error inviting users',
+    })
+    table.resetRowSelection()
+  }
+
+  return (
+    <>
+      <BulkActionsToolbar table={table} entityName='user'>
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='outline'
+              size='icon'
+              onClick={handleBulkInvite}
+              className='size-8'
+              aria-label='Invite selected users'
+              title='Invite selected users'
+            >
+              <Mail />
+              <span className='sr-only'>Invite selected users</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Invite selected users</p>
+          </TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='outline'
+              size='icon'
+              onClick={() => handleBulkStatusChange('active')}
+              className='size-8'
+              aria-label='Activate selected users'
+              title='Activate selected users'
+            >
+              <UserCheck />
+              <span className='sr-only'>Activate selected users</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Activate selected users</p>
+          </TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='outline'
+              size='icon'
+              onClick={() => handleBulkStatusChange('inactive')}
+              className='size-8'
+              aria-label='Deactivate selected users'
+              title='Deactivate selected users'
+            >
+              <UserX />
+              <span className='sr-only'>Deactivate selected users</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Deactivate selected users</p>
+          </TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='destructive'
+              size='icon'
+              onClick={() => setShowDeleteConfirm(true)}
+              className='size-8'
+              aria-label='Delete selected users'
+              title='Delete selected users'
+            >
+              <Trash2 />
+              <span className='sr-only'>Delete selected users</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Delete selected users</p>
+          </TooltipContent>
+        </Tooltip>
+      </BulkActionsToolbar>
+
+      <UsersMultiDeleteDialog
+        table={table}
+        open={showDeleteConfirm}
+        onOpenChange={setShowDeleteConfirm}
+      />
+    </>
+  )
+}

+ 1 - 1
src/features/users/components/users-delete-dialog.tsx

@@ -65,7 +65,7 @@ export function UsersDeleteDialog({ open, onOpenChange, currentRow }: Props) {
           <Alert variant='destructive'>
             <AlertTitle>Warning!</AlertTitle>
             <AlertDescription>
-              Please be carefull, this operation can not be rolled back.
+              Please be careful, this operation can not be rolled back.
             </AlertDescription>
           </Alert>
         </div>

+ 94 - 0
src/features/users/components/users-multi-delete-dialog.tsx

@@ -0,0 +1,94 @@
+'use client'
+
+import { useState } from 'react'
+import { Table } from '@tanstack/react-table'
+import { AlertTriangle } from 'lucide-react'
+import { toast } from 'sonner'
+import { sleep } from '@/utils/sleep'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { ConfirmDialog } from '@/components/confirm-dialog'
+
+interface Props<TData> {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  table: Table<TData>
+}
+
+const CONFIRM_WORD = 'DELETE'
+
+export function UsersMultiDeleteDialog<TData>({
+  open,
+  onOpenChange,
+  table,
+}: Props<TData>) {
+  const [value, setValue] = useState('')
+
+  const selectedRows = table.getFilteredSelectedRowModel().rows
+
+  const handleDelete = () => {
+    if (value.trim() !== CONFIRM_WORD) {
+      toast.error(`Please type "${CONFIRM_WORD}" to confirm.`)
+      return
+    }
+
+    onOpenChange(false)
+
+    toast.promise(sleep(2000), {
+      loading: 'Deleting users...',
+      success: () => {
+        table.resetRowSelection()
+        return `Deleted ${selectedRows.length} ${
+          selectedRows.length > 1 ? 'users' : 'user'
+        }`
+      },
+      error: 'Error',
+    })
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={onOpenChange}
+      handleConfirm={handleDelete}
+      disabled={value.trim() !== CONFIRM_WORD}
+      title={
+        <span className='text-destructive'>
+          <AlertTriangle
+            className='stroke-destructive me-1 inline-block'
+            size={18}
+          />{' '}
+          Delete {selectedRows.length}{' '}
+          {selectedRows.length > 1 ? 'users' : 'user'}
+        </span>
+      }
+      desc={
+        <div className='space-y-4'>
+          <p className='mb-2'>
+            Are you sure you want to delete the selected users? <br />
+            This action cannot be undone.
+          </p>
+
+          <Label className='my-4 flex flex-col items-start gap-1.5'>
+            <span className=''>Confirm by typing "{CONFIRM_WORD}":</span>
+            <Input
+              value={value}
+              onChange={(e) => setValue(e.target.value)}
+              placeholder={`Type "${CONFIRM_WORD}" to confirm.`}
+            />
+          </Label>
+
+          <Alert variant='destructive'>
+            <AlertTitle>Warning!</AlertTitle>
+            <AlertDescription>
+              Please be careful, this operation can not be rolled back.
+            </AlertDescription>
+          </Alert>
+        </div>
+      }
+      confirmText='Delete'
+      destructive
+    />
+  )
+}

+ 3 - 1
src/features/users/components/users-table.tsx

@@ -24,6 +24,7 @@ import {
   TableRow,
 } from '@/components/ui/table'
 import { User } from '../data/schema'
+import { DataTableBulkActions } from './data-table-bulk-actions'
 import { DataTablePagination } from './data-table-pagination'
 import { DataTableToolbar } from './data-table-toolbar'
 
@@ -68,7 +69,7 @@ export function UsersTable({ columns, data }: DataTableProps) {
   })
 
   return (
-    <div className='space-y-4'>
+    <div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
       <DataTableToolbar table={table} />
       <div className='overflow-hidden rounded-md border'>
         <Table>
@@ -135,6 +136,7 @@ export function UsersTable({ columns, data }: DataTableProps) {
         </Table>
       </div>
       <DataTablePagination table={table} />
+      <DataTableBulkActions table={table} />
     </div>
   )
 }

+ 8 - 0
src/utils/sleep.ts

@@ -0,0 +1,8 @@
+/**
+ * A simple promise that resolves after a given delay.
+ * @param {number} ms The delay in milliseconds.
+ * @returns {Promise<void>} A promise that resolves after the delay.
+ */
+export const sleep = (ms: number = 1000): Promise<void> => {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}