shadimage

Data Table

A generic data table with search, sorting, row selection, CSV export, pagination, row actions, and destructive action confirmation.

registry/corr/components/data-table.tsx

import {
  useEffect,
  useMemo,
  useState,
  type DragEvent,
  type ReactNode,
} from "react"
import {
  ArrowDown,
  ArrowUp,
  ArrowUpDown,
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
  GripVertical,
  MoreHorizontal,
} from "lucide-react"

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { generateThreeWordGuard } from "@/lib/typed-guard"

export type DataTableColumn<TData> = {
  id: string
  header: ReactNode
  cell: (row: TData) => ReactNode
  sortValue?: (row: TData) => string | number | null | undefined
  sortable?: boolean
  className?: string
  width?: string
  exportHeader?: string
  exportValue?: (row: TData) => string | number | null | undefined
  exportable?: boolean
}

export type DataTableRowAction<TData> = {
  label: string
  onClick: (row: TData) => void | Promise<void>
  icon?: ReactNode
  tone?: "default" | "warning" | "destructive"
  destructive?: boolean
  disabled?: boolean
  confirmTitle?: string
  confirmDescription?: string
  confirmKeyword?: string
  confirmKeywordMode?: "fixed" | "random-3-words"
}

export type DataTableRowActionGroup<TData> = {
  label?: string
  actions: DataTableRowAction<TData>[]
}

type SortDirection = "asc" | "desc"
export type DataTableSortState = {
  columnId: string
  direction: SortDirection
} | null

export type DataTableToolbarContext<TData> = {
  selectedRows: TData[]
  filteredRows: TData[]
  exportRows: TData[]
  exportCsv: (fileName?: string) => void
  clearSelection: () => void
}

export type DataTableServerPagination = {
  page: number
  pageSize: number
  total: number
  onPageChange: (page: number) => void
  onPageSizeChange: (pageSize: number) => void
}

type DataTableProps<TData> = {
  columns: DataTableColumn<TData>[]
  data: TData[]
  getRowId: (row: TData) => string
  emptyMessage?: string
  enableRowSelection?: boolean
  enableSorting?: boolean
  rowActions?: (row: TData) => DataTableRowAction<TData>[]
  rowActionGroups?: (row: TData) => DataTableRowActionGroup<TData>[]
  enableRowReordering?: boolean
  onRowOrderChange?: (rows: TData[]) => void
  toolbarActions?: (context: DataTableToolbarContext<TData>) => ReactNode
  selectionActions?: (context: DataTableToolbarContext<TData>) => ReactNode
  searchPlaceholder?: string
  searchableText?: (row: TData) => string
  exportFileName?: string
  isLoading?: boolean
  skeletonRows?: number
  enablePagination?: boolean
  pageSizeOptions?: number[]
  defaultPageSize?: number
  serverPagination?: DataTableServerPagination
  searchValue?: string
  onSearchValueChange?: (value: string) => void
  sortState?: DataTableSortState
  onSortStateChange?: (state: DataTableSortState) => void
}

function compareValues(
  left: string | number | null | undefined,
  right: string | number | null | undefined
) {
  if (left == null && right == null) return 0
  if (left == null) return -1
  if (right == null) return 1

  if (typeof left === "number" && typeof right === "number") {
    return left - right
  }

  return String(left).localeCompare(String(right), undefined, {
    numeric: true,
    sensitivity: "base",
  })
}

function csvEscape(value: string) {
  if (value.includes(",") || value.includes("\n") || value.includes('"')) {
    return `"${value.split('"').join('""')}"`
  }
  return value
}

function formatTableNumber(value: number) {
  return value.toLocaleString()
}

function formatCellValue(value: ReactNode): ReactNode {
  if (typeof value === "number" && Number.isFinite(value)) {
    return formatTableNumber(value)
  }
  if (typeof value === "bigint") {
    return value.toLocaleString()
  }
  return value
}

export function DataTable<TData>({
  columns,
  data,
  getRowId,
  emptyMessage = "No results.",
  enableRowSelection = false,
  enableSorting = false,
  rowActions,
  rowActionGroups,
  enableRowReordering = false,
  onRowOrderChange,
  toolbarActions,
  selectionActions,
  searchPlaceholder = "Search",
  searchableText,
  exportFileName = "export.csv",
  isLoading = false,
  skeletonRows = 6,
  enablePagination = true,
  pageSizeOptions = [5, 10, 15, 20, 50],
  defaultPageSize = 5,
  serverPagination,
  searchValue,
  onSearchValueChange,
  sortState,
  onSortStateChange,
}: DataTableProps<TData>) {
  const [search, setSearch] = useState(searchValue ?? "")
  const [internalSortState, setInternalSortState] =
    useState<DataTableSortState>(null)
  const [selectedRowIds, setSelectedRowIds] = useState<Record<string, boolean>>(
    {}
  )
  const [pendingDestructiveAction, setPendingDestructiveAction] = useState<{
    row: TData
    action: DataTableRowAction<TData>
  } | null>(null)
  const [confirmKeywordInput, setConfirmKeywordInput] = useState("")
  const [confirmKeywordValue, setConfirmKeywordValue] = useState("DELETE")
  const [draggedRowId, setDraggedRowId] = useState<string | null>(null)
  const [dragOverRowId, setDragOverRowId] = useState<string | null>(null)
  const [pageIndex, setPageIndex] = useState(0)
  const [pageSize, setPageSize] = useState(defaultPageSize)
  const serverPaginationState = serverPagination ?? null
  const isServerPagination = Boolean(serverPaginationState)
  const effectiveSortState = sortState ?? internalSortState

  useEffect(() => {
    if (searchValue === undefined) return
    setSearch(searchValue)
  }, [searchValue])

  const activeSearch = searchValue ?? search

  const filteredData = useMemo(() => {
    if (isServerPagination) {
      return data
    }

    const query = activeSearch.trim().toLowerCase()
    if (!query || !searchableText) {
      return data
    }

    return data.filter((row) =>
      searchableText(row).toLowerCase().includes(query)
    )
  }, [activeSearch, data, isServerPagination, searchableText])

  const sortedData = useMemo(() => {
    if (isServerPagination) {
      return filteredData
    }

    if (!effectiveSortState) {
      return filteredData
    }

    const column = columns.find(
      (item) => item.id === effectiveSortState.columnId
    )
    if (!column?.sortValue) {
      return filteredData
    }

    const direction = effectiveSortState.direction === "asc" ? 1 : -1
    return [...filteredData].sort(
      (left, right) =>
        direction *
        compareValues(column.sortValue?.(left), column.sortValue?.(right))
    )
  }, [columns, effectiveSortState, filteredData, isServerPagination])

  const totalRows = serverPaginationState
    ? serverPaginationState.total
    : sortedData.length
  const currentPageSize = serverPaginationState
    ? serverPaginationState.pageSize
    : pageSize
  const effectiveSkeletonRows = isServerPagination
    ? currentPageSize
    : skeletonRows
  const pageCount = Math.max(1, Math.ceil(totalRows / currentPageSize))
  const effectivePageIndex = serverPaginationState
    ? Math.min(Math.max(serverPaginationState.page - 1, 0), pageCount - 1)
    : Math.min(pageIndex, pageCount - 1)
  const pagedData = useMemo(() => {
    if (isServerPagination) {
      return sortedData
    }
    if (!enablePagination) {
      return sortedData
    }
    const start = effectivePageIndex * currentPageSize
    const end = start + currentPageSize
    return sortedData.slice(start, end)
  }, [
    currentPageSize,
    effectivePageIndex,
    enablePagination,
    isServerPagination,
    sortedData,
  ])

  useEffect(() => {
    if (isServerPagination) {
      return
    }
    if (!enablePagination) {
      setPageIndex(0)
      return
    }
    if (pageIndex > pageCount - 1) {
      setPageIndex(Math.max(pageCount - 1, 0))
    }
  }, [enablePagination, isServerPagination, pageCount, pageIndex])

  useEffect(() => {
    if (isServerPagination) {
      return
    }
    setPageIndex(0)
  }, [isServerPagination, activeSearch, effectiveSortState, pageSize])

  const selectedRows = useMemo(
    () => data.filter((row) => selectedRowIds[getRowId(row)]),
    [data, getRowId, selectedRowIds]
  )

  const exportRows = useMemo(() => {
    if (selectedRows.length > 0) {
      return selectedRows
    }
    if (activeSearch.trim().length > 0) {
      return filteredData
    }
    return data
  }, [activeSearch, data, filteredData, selectedRows])

  const allVisibleRowsSelected =
    pagedData.length > 0 &&
    pagedData.every((row) => selectedRowIds[getRowId(row)])
  const someVisibleRowsSelected =
    !allVisibleRowsSelected &&
    pagedData.some((row) => selectedRowIds[getRowId(row)])

  const exportableColumns = useMemo(
    () => columns.filter((column) => column.exportable !== false),
    [columns]
  )

  function clearSelection() {
    setSelectedRowIds({})
  }

  function downloadCsv(fileName: string, rows: TData[]) {
    const header = exportableColumns
      .map((column) =>
        csvEscape(String(column.exportHeader ?? column.header ?? column.id))
      )
      .join(",")

    const lines = rows.map((row) => {
      return exportableColumns
        .map((column) => {
          const raw = column.exportValue
            ? column.exportValue(row)
            : column.sortValue
              ? column.sortValue(row)
              : ""
          return csvEscape(raw == null ? "" : String(raw))
        })
        .join(",")
    })

    const csv = [header, ...lines].join("\n")
    const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
    const url = URL.createObjectURL(blob)
    const anchor = document.createElement("a")
    anchor.href = url
    anchor.download = fileName
    anchor.click()
    URL.revokeObjectURL(url)
  }

  function exportCsv(fileName = exportFileName) {
    downloadCsv(fileName, exportRows)
  }

  const context: DataTableToolbarContext<TData> = {
    selectedRows,
    filteredRows: filteredData,
    exportRows,
    exportCsv,
    clearSelection,
  }

  function toggleSort(column: DataTableColumn<TData>) {
    const canSortColumn = isServerPagination
      ? Boolean(column.sortable)
      : Boolean(column.sortable && column.sortValue)
    if (!enableSorting || !canSortColumn) {
      return
    }

    const nextSortState: DataTableSortState =
      !effectiveSortState || effectiveSortState.columnId !== column.id
        ? { columnId: column.id, direction: "asc" }
        : effectiveSortState.direction === "asc"
          ? { columnId: column.id, direction: "desc" }
          : null

    if (sortState === undefined) {
      setInternalSortState(nextSortState)
    }
    onSortStateChange?.(nextSortState)
  }

  function reorderRows(activeRowId: string, overRowId: string) {
    if (activeRowId === overRowId) {
      return
    }

    const activeIndex = data.findIndex((row) => getRowId(row) === activeRowId)
    const overIndex = data.findIndex((row) => getRowId(row) === overRowId)

    if (activeIndex === -1 || overIndex === -1) {
      return
    }

    const nextRows = [...data]
    const [activeRow] = nextRows.splice(activeIndex, 1)
    nextRows.splice(overIndex, 0, activeRow)
    onRowOrderChange?.(nextRows)
  }

  function handleRowDragStart(event: DragEvent<HTMLButtonElement>, row: TData) {
    const rowId = getRowId(row)
    setDraggedRowId(rowId)
    event.dataTransfer.effectAllowed = "move"
    event.dataTransfer.setData("text/plain", rowId)
  }

  function handleRowDrop(event: DragEvent<HTMLTableRowElement>, row: TData) {
    event.preventDefault()
    const activeRowId = event.dataTransfer.getData("text/plain") || draggedRowId

    if (activeRowId) {
      reorderRows(activeRowId, getRowId(row))
    }

    setDraggedRowId(null)
    setDragOverRowId(null)
  }

  const destructiveKeyword = confirmKeywordValue

  if (isLoading) {
    return (
      <div className="flex flex-col gap-4">
        <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
          <Skeleton className="h-10 w-full sm:max-w-sm" />
          <div className="sm:flex sm:flex-1 sm:justify-center">
            <Skeleton className="h-10 w-56" />
          </div>
          <div className="flex items-center gap-2">
            <Skeleton className="h-10 w-24" />
            <Skeleton className="h-10 w-28" />
          </div>
        </div>

        <div className="overflow-x-auto rounded-lg border bg-card">
          <Table className="table-fixed">
            <colgroup>
              {enableRowSelection ? <col style={{ width: "2.5rem" }} /> : null}
              {enableRowReordering ? <col style={{ width: "2.5rem" }} /> : null}
              {columns.map((column) => (
                <col
                  key={`skeleton-col-${column.id}`}
                  style={
                    column.width
                      ? { width: column.width, minWidth: column.width }
                      : undefined
                  }
                />
              ))}
              <col style={{ width: "5rem", minWidth: "5rem" }} />
            </colgroup>
            <TableHeader>
              <TableRow>
                {enableRowSelection ? (
                  <TableHead className="w-10">
                    <Skeleton className="h-4 w-4" />
                  </TableHead>
                ) : null}
                {enableRowReordering ? <TableHead className="w-10" /> : null}
                {columns.map((column) => (
                  <TableHead key={column.id} className={column.className}>
                    <Skeleton className="h-4 w-24" />
                  </TableHead>
                ))}
                <TableHead className="sticky right-0 w-20 bg-card pr-2 text-right">
                  <Skeleton className="ml-auto h-4 w-12" />
                </TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {Array.from({ length: effectiveSkeletonRows }).map(
                (_, rowIndex) => (
                  <TableRow key={`skeleton-row-${rowIndex}`}>
                    {enableRowSelection ? (
                      <TableCell className="w-10">
                        <Skeleton className="h-4 w-4" />
                      </TableCell>
                    ) : null}
                    {enableRowReordering ? (
                      <TableCell className="w-10">
                        <Skeleton className="h-4 w-4" />
                      </TableCell>
                    ) : null}
                    {columns.map((column) => (
                      <TableCell
                        key={`skeleton-cell-${rowIndex}-${column.id}`}
                        className={column.className}
                      >
                        <Skeleton className="h-5 w-full max-w-40" />
                      </TableCell>
                    ))}
                    <TableCell className="sticky right-0 bg-card text-right">
                      <Skeleton className="ml-auto h-8 w-8" />
                    </TableCell>
                  </TableRow>
                )
              )}
            </TableBody>
          </Table>
        </div>
      </div>
    )
  }

  return (
    <>
      <div className="flex flex-col gap-4">
        <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
          <Input
            value={activeSearch}
            onChange={(event) => {
              const value = event.target.value
              if (onSearchValueChange) {
                onSearchValueChange(value)
                return
              }
              setSearch(value)
            }}
            placeholder={searchPlaceholder}
            className="w-full sm:max-w-sm"
          />

          {selectedRows.length > 0 ? (
            <div className="min-w-0">
              <div className="flex flex-wrap items-center gap-2 text-sm">
                <span className="text-muted-foreground">
                  {formatTableNumber(selectedRows.length)} selected
                </span>
                {selectionActions?.(context)}
              </div>
            </div>
          ) : null}

          <div className="hidden sm:block sm:flex-1" />

          {toolbarActions ? (
            <div className="max-w-full min-w-0 overflow-x-auto sm:overflow-visible">
              <div className="flex w-max max-w-full items-center gap-2">
                {toolbarActions(context)}
              </div>
            </div>
          ) : null}
        </div>

        <div className="overflow-x-auto rounded-lg border bg-card">
          <Table className="table-fixed">
            <colgroup>
              {enableRowSelection ? <col style={{ width: "2.5rem" }} /> : null}
              {enableRowReordering ? <col style={{ width: "2.5rem" }} /> : null}
              {columns.map((column) => (
                <col
                  key={`col-${column.id}`}
                  style={
                    column.width
                      ? { width: column.width, minWidth: column.width }
                      : undefined
                  }
                />
              ))}
              <col style={{ width: "5rem", minWidth: "5rem" }} />
            </colgroup>
            <TableHeader>
              <TableRow>
                {enableRowSelection ? (
                  <TableHead className="w-10">
                    <Checkbox
                      checked={
                        allVisibleRowsSelected ||
                        (someVisibleRowsSelected ? "indeterminate" : false)
                      }
                      onCheckedChange={(checked) => {
                        const value = Boolean(checked)
                        setSelectedRowIds((prev) => {
                          const next = { ...prev }
                          for (const row of pagedData) {
                            next[getRowId(row)] = value
                          }
                          return next
                        })
                      }}
                      aria-label="Select all rows"
                    />
                  </TableHead>
                ) : null}
                {enableRowReordering ? <TableHead className="w-10" /> : null}

                {columns.map((column) => {
                  const isSorted = effectiveSortState?.columnId === column.id
                  const canSortColumn = isServerPagination
                    ? Boolean(column.sortable)
                    : Boolean(column.sortable && column.sortValue)

                  return (
                    <TableHead key={column.id} className={column.className}>
                      {enableSorting && canSortColumn ? (
                        <Button
                          variant="ghost"
                          className="-ml-2 h-8 px-2"
                          onClick={() => toggleSort(column)}
                        >
                          {column.header}
                          {isSorted ? (
                            effectiveSortState?.direction === "asc" ? (
                              <ArrowUp className="ml-1 size-3.5" />
                            ) : (
                              <ArrowDown className="ml-1 size-3.5" />
                            )
                          ) : (
                            <ArrowUpDown className="ml-1 size-3.5" />
                          )}
                        </Button>
                      ) : (
                        column.header
                      )}
                    </TableHead>
                  )
                })}

                <TableHead className="sticky right-0 w-20 bg-card pr-2 text-right">
                  Actions
                </TableHead>
              </TableRow>
            </TableHeader>

            <TableBody>
              {pagedData.length > 0 ? (
                pagedData.map((row) => {
                  const rowId = getRowId(row)
                  const groups = rowActionGroups
                    ? rowActionGroups(row)
                    : rowActions
                      ? [{ actions: rowActions(row) }]
                      : []

                  return (
                    <TableRow
                      key={rowId}
                      data-state={
                        selectedRowIds[rowId] ? "selected" : undefined
                      }
                      data-dragging={
                        draggedRowId === rowId ? "true" : undefined
                      }
                      data-drag-over={
                        dragOverRowId === rowId ? "true" : undefined
                      }
                      className="group data-[drag-over=true]:bg-muted/60 data-[dragging=true]:opacity-50"
                      onDragOver={(event) => {
                        if (!enableRowReordering || !draggedRowId) {
                          return
                        }
                        event.preventDefault()
                        event.dataTransfer.dropEffect = "move"
                        setDragOverRowId(rowId)
                      }}
                      onDragLeave={() => {
                        if (dragOverRowId === rowId) {
                          setDragOverRowId(null)
                        }
                      }}
                      onDrop={(event) => handleRowDrop(event, row)}
                    >
                      {enableRowSelection ? (
                        <TableCell className="w-10">
                          <Checkbox
                            checked={Boolean(selectedRowIds[rowId])}
                            onCheckedChange={(checked) => {
                              const value = Boolean(checked)
                              setSelectedRowIds((prev) => ({
                                ...prev,
                                [rowId]: value,
                              }))
                            }}
                            aria-label={`Select row ${rowId}`}
                          />
                        </TableCell>
                      ) : null}
                      {enableRowReordering ? (
                        <TableCell className="w-10">
                          <Button
                            type="button"
                            variant="ghost"
                            size="icon"
                            draggable
                            onDragStart={(event) =>
                              handleRowDragStart(event, row)
                            }
                            onDragEnd={() => {
                              setDraggedRowId(null)
                              setDragOverRowId(null)
                            }}
                            className="h-7 w-7 cursor-grab text-muted-foreground active:cursor-grabbing"
                            aria-label={`Reorder row ${rowId}`}
                          >
                            <GripVertical className="h-4 w-4" />
                          </Button>
                        </TableCell>
                      ) : null}

                      {columns.map((column) => (
                        <TableCell
                          key={`${rowId}:${column.id}`}
                          className={column.className}
                        >
                          {formatCellValue(column.cell(row))}
                        </TableCell>
                      ))}

                      <TableCell className="sticky right-0 bg-card text-right group-data-[state=selected]:bg-muted">
                        <DropdownMenu>
                          <DropdownMenuTrigger asChild>
                            <Button
                              variant="ghost"
                              size="icon"
                              className="h-8 w-8 p-0"
                            >
                              <span className="sr-only">Open menu</span>
                              <MoreHorizontal className="h-4 w-4" />
                            </Button>
                          </DropdownMenuTrigger>
                          <DropdownMenuContent align="end" className="w-52">
                            {groups.length > 0 ? (
                              groups.map((group, groupIndex) => (
                                <div
                                  key={`${rowId}:group:${group.label ?? groupIndex}`}
                                >
                                  {group.label ? (
                                    <DropdownMenuLabel>
                                      {group.label}
                                    </DropdownMenuLabel>
                                  ) : null}
                                  {group.actions.map((action) => (
                                    <DropdownMenuItem
                                      key={`${rowId}:${action.label}`}
                                      disabled={action.disabled}
                                      variant={
                                        action.tone === "destructive" ||
                                        action.destructive
                                          ? "destructive"
                                          : "default"
                                      }
                                      className={
                                        action.tone === "destructive" ||
                                        action.destructive
                                          ? "my-0.5 bg-destructive/10 font-medium text-destructive! focus:bg-destructive/15 focus:text-destructive! dark:bg-destructive/20 dark:focus:bg-destructive/30 [&_svg]:text-destructive!"
                                          : "my-0.5"
                                      }
                                      onSelect={(event) => {
                                        event.preventDefault()
                                        if (
                                          action.destructive ||
                                          action.tone === "destructive"
                                        ) {
                                          setPendingDestructiveAction({
                                            row,
                                            action,
                                          })
                                          setConfirmKeywordInput("")
                                          setConfirmKeywordValue(
                                            action.confirmKeywordMode ===
                                              "random-3-words"
                                              ? generateThreeWordGuard()
                                              : (action.confirmKeyword ??
                                                  "DELETE")
                                          )
                                          return
                                        }
                                        void action.onClick(row)
                                      }}
                                    >
                                      {action.icon ? (
                                        <span className="shrink-0">
                                          {action.icon}
                                        </span>
                                      ) : null}
                                      {action.label}
                                    </DropdownMenuItem>
                                  ))}
                                  {groupIndex < groups.length - 1 ? (
                                    <DropdownMenuSeparator />
                                  ) : null}
                                </div>
                              ))
                            ) : (
                              <DropdownMenuItem disabled>
                                No actions
                              </DropdownMenuItem>
                            )}
                          </DropdownMenuContent>
                        </DropdownMenu>
                      </TableCell>
                    </TableRow>
                  )
                })
              ) : (
                <TableRow>
                  <TableCell
                    colSpan={
                      columns.length +
                      1 +
                      (enableRowSelection ? 1 : 0) +
                      (enableRowReordering ? 1 : 0)
                    }
                    className="h-24 text-center text-muted-foreground"
                  >
                    {emptyMessage}
                  </TableCell>
                </TableRow>
              )}
            </TableBody>
          </Table>
        </div>

        {enablePagination ? (
          <div className="flex flex-col gap-2 px-2 sm:flex-row sm:items-center sm:justify-between">
            <div className="text-sm text-muted-foreground">
              {totalRows === 0
                ? "No rows"
                : `Showing ${formatTableNumber(
                    effectivePageIndex * currentPageSize + 1
                  )}-${formatTableNumber(
                    Math.min(
                      (effectivePageIndex + 1) * currentPageSize,
                      totalRows
                    )
                  )} of ${formatTableNumber(totalRows)}`}
            </div>
            <div className="flex items-center gap-3">
              <div className="hidden items-center gap-2 sm:flex">
                <Label
                  htmlFor="rows-per-page"
                  className="text-xs text-muted-foreground"
                >
                  Rows per page
                </Label>
                <Select
                  value={String(currentPageSize)}
                  onValueChange={(value) => {
                    const nextSize = Number(value)
                    if (serverPaginationState) {
                      serverPaginationState.onPageSizeChange(nextSize)
                      return
                    }
                    setPageSize(nextSize)
                    setPageIndex(0)
                  }}
                >
                  <SelectTrigger id="rows-per-page" className="h-8 w-20">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {pageSizeOptions.map((size) => (
                      <SelectItem key={size} value={String(size)}>
                        {size}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </div>
              <div className="text-sm text-muted-foreground">
                Page {formatTableNumber(effectivePageIndex + 1)} of{" "}
                {formatTableNumber(pageCount)}
              </div>
              <div className="flex items-center gap-1">
                <Button
                  variant="outline"
                  size="icon"
                  className="h-8 w-8"
                  onClick={() => {
                    if (serverPaginationState) {
                      serverPaginationState.onPageChange(1)
                      return
                    }
                    setPageIndex(0)
                  }}
                  disabled={effectivePageIndex === 0}
                >
                  <span className="sr-only">First page</span>
                  <ChevronsLeft className="h-4 w-4" />
                </Button>
                <Button
                  variant="outline"
                  size="icon"
                  className="h-8 w-8"
                  onClick={() => {
                    if (serverPaginationState) {
                      serverPaginationState.onPageChange(
                        Math.max(serverPaginationState.page - 1, 1)
                      )
                      return
                    }
                    setPageIndex((prev) => Math.max(prev - 1, 0))
                  }}
                  disabled={effectivePageIndex === 0}
                >
                  <span className="sr-only">Previous page</span>
                  <ChevronLeft className="h-4 w-4" />
                </Button>
                <Button
                  variant="outline"
                  size="icon"
                  className="h-8 w-8"
                  onClick={() => {
                    if (serverPaginationState) {
                      serverPaginationState.onPageChange(
                        Math.min(serverPaginationState.page + 1, pageCount)
                      )
                      return
                    }
                    setPageIndex((prev) => Math.min(prev + 1, pageCount - 1))
                  }}
                  disabled={effectivePageIndex >= pageCount - 1}
                >
                  <span className="sr-only">Next page</span>
                  <ChevronRight className="h-4 w-4" />
                </Button>
                <Button
                  variant="outline"
                  size="icon"
                  className="h-8 w-8"
                  onClick={() => {
                    if (serverPaginationState) {
                      serverPaginationState.onPageChange(pageCount)
                      return
                    }
                    setPageIndex(pageCount - 1)
                  }}
                  disabled={effectivePageIndex >= pageCount - 1}
                >
                  <span className="sr-only">Last page</span>
                  <ChevronsRight className="h-4 w-4" />
                </Button>
              </div>
            </div>
          </div>
        ) : null}
      </div>

      <AlertDialog
        open={Boolean(pendingDestructiveAction)}
        onOpenChange={(open) => {
          if (!open) {
            setPendingDestructiveAction(null)
            setConfirmKeywordInput("")
            setConfirmKeywordValue("DELETE")
          }
        }}
      >
        <AlertDialogContent className="w-[min(calc(100vw-2rem),34rem)] max-w-lg border ring-0">
          <AlertDialogHeader className="place-items-stretch text-left">
            <AlertDialogTitle>
              {pendingDestructiveAction?.action.confirmTitle ??
                "Confirm destructive action"}
            </AlertDialogTitle>
            <AlertDialogDescription className="w-full space-y-3">
              <p>
                {pendingDestructiveAction?.action.confirmDescription ??
                  "Type the verification phrase below to confirm this action."}
              </p>
              <div className="w-full rounded-md bg-muted px-3 py-3 text-center font-mono text-sm text-foreground">
                {destructiveKeyword}
              </div>
            </AlertDialogDescription>
          </AlertDialogHeader>
          <Input
            className="w-full focus-visible:border-input focus-visible:ring-0"
            value={confirmKeywordInput}
            onChange={(event) => setConfirmKeywordInput(event.target.value)}
            placeholder={`Type ${destructiveKeyword}`}
          />
          <AlertDialogFooter>
            <AlertDialogCancel>Cancel</AlertDialogCancel>
            <AlertDialogAction
              variant="destructive"
              disabled={confirmKeywordInput !== destructiveKeyword}
              onClick={() => {
                if (
                  !pendingDestructiveAction ||
                  confirmKeywordInput !== destructiveKeyword
                ) {
                  return
                }
                void pendingDestructiveAction.action.onClick(
                  pendingDestructiveAction.row
                )
                setPendingDestructiveAction(null)
                setConfirmKeywordInput("")
                setConfirmKeywordValue("DELETE")
              }}
            >
              Confirm
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </>
  )
}
Where it goes
data-table
Interaction
dropdown
Use cases
dashboard

DependenciesBundle weight · light

icons
lucide-react