380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
|
|
|
type Align = 'left' | 'center' | 'right'
|
|
|
|
export type SortDirection = 'asc' | 'desc'
|
|
export type SortState = { key: string; direction: SortDirection } | null
|
|
|
|
export type Column<T> = {
|
|
key: string
|
|
header: React.ReactNode
|
|
widthClassName?: string
|
|
align?: Align
|
|
className?: string
|
|
headerClassName?: string
|
|
|
|
/** Wenn gesetzt: so wird die Zelle gerendert */
|
|
cell?: (row: T, rowIndex: number) => React.ReactNode
|
|
/** Standard: row[col.key] */
|
|
accessor?: (row: T) => React.ReactNode
|
|
/** Optional: sr-only Header (z.B. für Action-Spalte) */
|
|
srOnlyHeader?: boolean
|
|
|
|
/** Sortieren aktivieren (Header wird klickbar) */
|
|
sortable?: boolean
|
|
/**
|
|
* Wert, nach dem sortiert werden soll (empfohlen!),
|
|
* z.B. number/string/Date/boolean.
|
|
* Fallback ist row[col.key].
|
|
*/
|
|
sortValue?: (row: T) => string | number | boolean | Date | null | undefined
|
|
/** Optional: komplett eigene Vergleichsfunktion */
|
|
sortFn?: (a: T, b: T) => number
|
|
}
|
|
|
|
export type TableProps<T> = {
|
|
columns: Array<Column<T>>
|
|
rows: T[]
|
|
getRowKey?: (row: T, index: number) => string
|
|
/** Optionaler Titelbereich über der Tabelle */
|
|
title?: React.ReactNode
|
|
description?: React.ReactNode
|
|
actions?: React.ReactNode
|
|
|
|
/** Styling / Layout */
|
|
fullWidth?: boolean
|
|
card?: boolean
|
|
striped?: boolean
|
|
stickyHeader?: boolean
|
|
compact?: boolean
|
|
|
|
/** States */
|
|
isLoading?: boolean
|
|
emptyLabel?: React.ReactNode
|
|
|
|
className?: string
|
|
|
|
rowClassName?: (row: T, rowIndex: number) => string | undefined
|
|
|
|
onRowClick?: (row: T) => void
|
|
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
|
|
|
|
/**
|
|
* Controlled Sorting:
|
|
* - sort: aktueller Sort-Status
|
|
* - onSortChange: wird bei Klick auf Header aufgerufen
|
|
*/
|
|
sort?: SortState
|
|
onSortChange?: (next: SortState) => void
|
|
|
|
/**
|
|
* Uncontrolled Sorting:
|
|
* Initiale Sortierung (wenn sort nicht gesetzt ist)
|
|
*/
|
|
defaultSort?: SortState
|
|
}
|
|
|
|
function cn(...parts: Array<string | false | null | undefined>) {
|
|
return parts.filter(Boolean).join(' ')
|
|
}
|
|
|
|
function alignTd(a?: Align) {
|
|
if (a === 'center') return 'text-center'
|
|
if (a === 'right') return 'text-right'
|
|
return 'text-left'
|
|
}
|
|
|
|
function justifyForAlign(a?: Align) {
|
|
if (a === 'center') return 'justify-center'
|
|
if (a === 'right') return 'justify-end'
|
|
return 'justify-start'
|
|
}
|
|
|
|
function xPadForColumn(colIndex: number, totalCols: number) {
|
|
const isFirst = colIndex === 0
|
|
const isLast = colIndex === totalCols - 1
|
|
// außen weniger Padding, innen normal
|
|
if (isFirst) return 'pl-2 pr-2'
|
|
if (isLast) return 'pl-2 pr-2'
|
|
return 'px-2'
|
|
}
|
|
|
|
function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } {
|
|
if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' }
|
|
if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() }
|
|
if (typeof v === 'number') return { isNull: false, kind: 'number', value: v }
|
|
if (typeof v === 'boolean') return { isNull: false, kind: 'number', value: v ? 1 : 0 }
|
|
if (typeof v === 'bigint') return { isNull: false, kind: 'number', value: Number(v) }
|
|
// strings & sonstiges
|
|
return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() }
|
|
}
|
|
|
|
export default function Table<T>({
|
|
columns,
|
|
rows,
|
|
getRowKey,
|
|
title,
|
|
description,
|
|
actions,
|
|
fullWidth = false,
|
|
card = true,
|
|
striped = false,
|
|
stickyHeader = false,
|
|
compact = false,
|
|
isLoading = false,
|
|
emptyLabel = 'Keine Daten vorhanden.',
|
|
className,
|
|
rowClassName,
|
|
onRowClick,
|
|
onRowContextMenu,
|
|
sort,
|
|
onSortChange,
|
|
defaultSort = null,
|
|
}: TableProps<T>) {
|
|
const cellY = compact ? 'py-2' : 'py-4'
|
|
const headY = compact ? 'py-3' : 'py-3.5'
|
|
|
|
const isControlled = sort !== undefined
|
|
const [internalSort, setInternalSort] = React.useState<SortState>(defaultSort)
|
|
const sortState = isControlled ? sort! : internalSort
|
|
|
|
const setSortState = React.useCallback(
|
|
(next: SortState) => {
|
|
if (!isControlled) setInternalSort(next)
|
|
onSortChange?.(next)
|
|
},
|
|
[isControlled, onSortChange]
|
|
)
|
|
|
|
const sortedRows = React.useMemo(() => {
|
|
if (!sortState) return rows
|
|
const col = columns.find((c) => c.key === sortState.key)
|
|
if (!col) return rows
|
|
|
|
const dirMul = sortState.direction === 'asc' ? 1 : -1
|
|
|
|
const decorated = rows.map((r, i) => ({ r, i }))
|
|
decorated.sort((x, y) => {
|
|
let res = 0
|
|
|
|
if (col.sortFn) {
|
|
res = col.sortFn(x.r, y.r)
|
|
} else {
|
|
const ax = col.sortValue ? col.sortValue(x.r) : (x.r as any)?.[col.key]
|
|
const by = col.sortValue ? col.sortValue(y.r) : (y.r as any)?.[col.key]
|
|
|
|
const na = normalizeSortValue(ax)
|
|
const nb = normalizeSortValue(by)
|
|
|
|
// nulls immer nach hinten
|
|
if (na.isNull && !nb.isNull) res = 1
|
|
else if (!na.isNull && nb.isNull) res = -1
|
|
else if (na.kind === 'number' && nb.kind === 'number') res = na.value < nb.value ? -1 : na.value > nb.value ? 1 : 0
|
|
else res = String(na.value).localeCompare(String(nb.value), undefined, { numeric: true })
|
|
}
|
|
|
|
if (res === 0) return x.i - y.i // stable
|
|
return res * dirMul
|
|
})
|
|
|
|
return decorated.map((d) => d.r)
|
|
}, [rows, columns, sortState])
|
|
|
|
return (
|
|
<div className={cn(fullWidth ? '' : 'px-4 sm:px-6 lg:px-8', className)}>
|
|
{(title || description || actions) && (
|
|
<div className="sm:flex sm:items-center">
|
|
<div className="sm:flex-auto">
|
|
{title && (
|
|
<h1 className="text-base font-semibold text-gray-900 dark:text-white">
|
|
{title}
|
|
</h1>
|
|
)}
|
|
{description && (
|
|
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
|
{description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{actions && (
|
|
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">{actions}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className={cn(title || description || actions ? 'mt-8' : '')}>
|
|
<div className="flow-root">
|
|
<div className="overflow-x-auto">
|
|
<div className={cn('inline-block min-w-full align-middle', fullWidth ? '' : 'sm:px-6 lg:px-8')}>
|
|
<div
|
|
className={cn(
|
|
card &&
|
|
'overflow-hidden shadow-sm outline-1 outline-black/5 rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10'
|
|
)}
|
|
>
|
|
<table className="relative min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead
|
|
className={cn(
|
|
card && 'bg-gray-50/90 dark:bg-gray-800/70',
|
|
stickyHeader &&
|
|
'sticky top-0 z-10 backdrop-blur-sm shadow-sm'
|
|
)}
|
|
>
|
|
<tr>
|
|
{columns.map((col, colIndex) => {
|
|
const xPad = xPadForColumn(colIndex, columns.length)
|
|
const isSorted = !!sortState && sortState.key === col.key
|
|
const dir = isSorted ? sortState!.direction : undefined
|
|
const ariaSort =
|
|
col.sortable && !col.srOnlyHeader
|
|
? isSorted
|
|
? dir === 'asc'
|
|
? 'ascending'
|
|
: 'descending'
|
|
: 'none'
|
|
: undefined
|
|
|
|
const nextSort = () => {
|
|
if (!col.sortable || col.srOnlyHeader) return
|
|
if (!isSorted) return setSortState({ key: col.key, direction: 'asc' })
|
|
if (dir === 'asc') return setSortState({ key: col.key, direction: 'desc' })
|
|
return setSortState(null)
|
|
}
|
|
|
|
return (
|
|
<th
|
|
key={col.key}
|
|
scope="col"
|
|
aria-sort={ariaSort as any}
|
|
className={cn(
|
|
headY,
|
|
xPad,
|
|
'text-xs font-semibold tracking-wide text-gray-700 dark:text-gray-200 whitespace-nowrap',
|
|
alignTd(col.align),
|
|
col.widthClassName,
|
|
col.headerClassName
|
|
)}
|
|
>
|
|
{col.srOnlyHeader ? (
|
|
<span className="sr-only">{col.header}</span>
|
|
) : col.sortable ? (
|
|
<button
|
|
type="button"
|
|
onClick={nextSort}
|
|
className={cn(
|
|
'group inline-flex w-full items-center gap-2 select-none rounded-md px-1.5 py-1 -my-1 hover:bg-gray-100/70 dark:hover:bg-white/5',
|
|
justifyForAlign(col.align)
|
|
)}
|
|
>
|
|
<span>{col.header}</span>
|
|
|
|
<span
|
|
className={cn(
|
|
'flex-none rounded-sm text-gray-400 dark:text-gray-500',
|
|
isSorted
|
|
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
|
|
: 'invisible group-hover:visible group-focus-visible:visible'
|
|
)}
|
|
>
|
|
<ChevronDownIcon
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'size-5 transition-transform',
|
|
isSorted && dir === 'asc' && 'rotate-180'
|
|
)}
|
|
/>
|
|
</span>
|
|
</button>
|
|
) : (
|
|
col.header
|
|
)}
|
|
</th>
|
|
)
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody
|
|
className={cn(
|
|
'divide-y divide-gray-200 dark:divide-white/10',
|
|
card ? 'bg-white dark:bg-gray-800/50' : 'bg-white dark:bg-gray-900'
|
|
)}
|
|
>
|
|
{isLoading ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className={cn(cellY, 'px-2 text-sm text-gray-500 dark:text-gray-400')}>
|
|
Lädt…
|
|
</td>
|
|
</tr>
|
|
) : sortedRows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className={cn(cellY, 'px-2 text-sm text-gray-500 dark:text-gray-400')}>
|
|
{emptyLabel}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sortedRows.map((row, rowIndex) => {
|
|
const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex)
|
|
|
|
return (
|
|
<tr
|
|
key={key}
|
|
className={cn(
|
|
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
|
onRowClick && 'cursor-pointer',
|
|
onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors',
|
|
rowClassName?.(row, rowIndex)
|
|
)}
|
|
onClick={() => onRowClick?.(row)}
|
|
onContextMenu={
|
|
onRowContextMenu
|
|
? (e) => {
|
|
e.preventDefault()
|
|
onRowContextMenu(row, e)
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{columns.map((col, colIndex) => {
|
|
const xPad = xPadForColumn(colIndex, columns.length)
|
|
const content =
|
|
col.cell?.(row, rowIndex) ??
|
|
col.accessor?.(row) ??
|
|
(row as any)?.[col.key]
|
|
|
|
return (
|
|
<td
|
|
key={col.key}
|
|
className={cn(
|
|
cellY,
|
|
xPad,
|
|
'text-sm whitespace-nowrap',
|
|
alignTd(col.align),
|
|
col.className,
|
|
col.key === columns[0]?.key
|
|
? 'font-medium text-gray-900 dark:text-white'
|
|
: 'text-gray-500 dark:text-gray-400'
|
|
)}
|
|
>
|
|
{content}
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|