mirror of
https://github.com/rustfs/console.git
synced 2026-10-31 03:52:11 +08:00
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { flexRender, type Column, type Table } from "@tanstack/react-table"
|
|
import { RiArrowUpDownLine, RiArrowUpSLine, RiArrowDownSLine } from "@remixicon/react"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow, Table as UiTable } from "@/components/ui/table"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import { EmptyState } from "@/components/empty-state"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface DataTableProps<TData> {
|
|
table: Table<TData>
|
|
isLoading?: boolean
|
|
emptyTitle?: string
|
|
emptyDescription?: string
|
|
caption?: string
|
|
className?: string
|
|
tableClass?: string
|
|
stickyHeader?: boolean
|
|
bodyHeight?: string
|
|
}
|
|
|
|
function getColumnStyles<TData>(column: Column<TData, unknown>) {
|
|
const meta = column.columnDef.meta as
|
|
| { width?: number | string; minWidth?: number | string; maxWidth?: number | string }
|
|
| undefined
|
|
if (!meta) return undefined
|
|
|
|
const styles: React.CSSProperties = {}
|
|
if (meta.width != null) {
|
|
styles.width = typeof meta.width === "number" ? `${meta.width}px` : meta.width
|
|
}
|
|
if (meta.minWidth != null) {
|
|
styles.minWidth = typeof meta.minWidth === "number" ? `${meta.minWidth}px` : meta.minWidth
|
|
}
|
|
if (meta.maxWidth != null) {
|
|
styles.maxWidth = typeof meta.maxWidth === "number" ? `${meta.maxWidth}px` : meta.maxWidth
|
|
}
|
|
return Object.keys(styles).length > 0 ? styles : undefined
|
|
}
|
|
|
|
function canSort<TData>(column: Column<TData, unknown>) {
|
|
const def = column.columnDef
|
|
if (def.enableSorting === false) return false
|
|
|
|
const hasAccessorKey =
|
|
Object.prototype.hasOwnProperty.call(def, "accessorKey") &&
|
|
typeof (def as { accessorKey?: unknown }).accessorKey === "string" &&
|
|
((def as { accessorKey: string }).accessorKey?.length ?? 0) > 0
|
|
|
|
const hasAccessorFn =
|
|
Object.prototype.hasOwnProperty.call(def, "accessorFn") &&
|
|
typeof (def as { accessorFn?: unknown }).accessorFn === "function"
|
|
|
|
return hasAccessorKey || hasAccessorFn
|
|
}
|
|
|
|
function getAriaSort<TData>(column: Column<TData, unknown>): React.AriaAttributes["aria-sort"] {
|
|
if (!canSort(column)) return undefined
|
|
const direction = column.getIsSorted()
|
|
if (direction === "asc") return "ascending"
|
|
if (direction === "desc") return "descending"
|
|
return "none"
|
|
}
|
|
|
|
export function DataTable<TData>({
|
|
table,
|
|
isLoading = false,
|
|
emptyTitle = "No data",
|
|
emptyDescription = "There is nothing to display yet.",
|
|
caption,
|
|
className,
|
|
tableClass,
|
|
stickyHeader = false,
|
|
bodyHeight,
|
|
}: DataTableProps<TData>) {
|
|
const visibleColumnCount = table.getVisibleLeafColumns().length
|
|
const hasRows = table.getRowModel().rows.length > 0
|
|
|
|
const tableContent = (
|
|
<UiTable className={cn("min-w-full tabular-nums", tableClass)} aria-busy={isLoading}>
|
|
{caption ? <caption className="sr-only">{caption}</caption> : null}
|
|
<TableHeader className={stickyHeader ? "sticky top-0 z-10 bg-muted/40 backdrop-blur" : "bg-muted/20"}>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow
|
|
key={headerGroup.id}
|
|
className={stickyHeader ? "bg-muted/40 backdrop-blur" : "bg-muted/20 hover:bg-muted/30"}
|
|
>
|
|
{headerGroup.headers.map((header) => (
|
|
<TableHead
|
|
key={header.id}
|
|
scope="col"
|
|
aria-sort={getAriaSort(header.column)}
|
|
className={bodyHeight ? undefined : "py-2"}
|
|
style={getColumnStyles(header.column)}
|
|
>
|
|
{!header.isPlaceholder && (
|
|
<>
|
|
{canSort(header.column) ? (
|
|
<button
|
|
type="button"
|
|
className="flex cursor-pointer items-center gap-2 select-none rounded-none outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring/50"
|
|
onClick={header.column.getToggleSortingHandler()}
|
|
>
|
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
{header.column.getIsSorted() === "asc" ? (
|
|
<RiArrowUpSLine className="size-4" aria-hidden />
|
|
) : header.column.getIsSorted() === "desc" ? (
|
|
<RiArrowDownSLine className="size-4" aria-hidden />
|
|
) : (
|
|
<RiArrowUpDownLine className="size-4 text-muted-foreground opacity-50" aria-hidden />
|
|
)}
|
|
</button>
|
|
) : (
|
|
flexRender(header.column.columnDef.header, header.getContext())
|
|
)}
|
|
</>
|
|
)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={visibleColumnCount} className="h-48 text-center align-middle">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Spinner className="size-6" />
|
|
<span className="text-sm text-muted-foreground">Loading…</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : hasRows ? (
|
|
table.getRowModel().rows.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
data-state={row.getIsSelected() ? "selected" : undefined}
|
|
className="transition-colors hover:bg-muted/40"
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell
|
|
key={cell.id}
|
|
className={bodyHeight ? undefined : "py-2"}
|
|
style={getColumnStyles(cell.column)}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={visibleColumnCount} className="h-48">
|
|
<EmptyState title={emptyTitle} description={emptyDescription} />
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</UiTable>
|
|
)
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-4", className)}>
|
|
{bodyHeight ? (
|
|
<ScrollArea className={cn("border", bodyHeight)}>{tableContent}</ScrollArea>
|
|
) : (
|
|
<div className="w-full border">{tableContent}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|