2026-02-23 17:00:22 +01:00

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>
)
}