teg/frontend/src/components/Tables.tsx
2026-05-12 15:29:56 +02:00

673 lines
18 KiB
TypeScript

// frontend/src/components/Tables.tsx
import {
Fragment,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import Button from './Button'
export type TableVariant =
| 'simple'
| 'card'
| 'full-width'
| 'constrained'
| 'striped'
| 'uppercase'
| 'stacked-mobile'
| 'hidden-mobile'
| 'avatars'
| 'sticky-header'
| 'vertical-lines'
| 'condensed'
| 'sortable'
| 'grouped'
| 'summary'
| 'border'
export type TableColumn<T> = {
key: string
header: string
render: (row: T) => ReactNode
mobileRender?: (row: T) => ReactNode
isPrimary?: boolean
sortable?: boolean
hideOnMobile?: 'sm' | 'md' | 'lg'
align?: 'left' | 'center' | 'right'
className?: string
headerClassName?: string
}
export type TableGroup<T> = {
id: string | number
name: string
rows: T[]
}
export type TableSummaryRow = {
label: ReactNode
value: ReactNode
emphasized?: boolean
colSpan?: number
}
type SortDirection = 'asc' | 'desc'
type TablesProps<T> = {
title?: string
description?: string
rows?: T[]
groups?: TableGroup<T>[]
columns: TableColumn<T>[]
getRowId: (row: T) => string | number
variant?: TableVariant
emptyText?: string
actionLabel?: string
onAction?: () => void
headerAction?: ReactNode
rowActions?: (row: T) => ReactNode
selectable?: boolean
selectedRowIds?: Array<string | number>
onSelectedRowIdsChange?: (ids: Array<string | number>) => void
sortKey?: string
sortDirection?: SortDirection
onSort?: (column: TableColumn<T>) => void
summaryRows?: TableSummaryRow[]
hideHeadings?: boolean
stickyTopClassName?: string
className?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getAlignmentClass(align?: TableColumn<unknown>['align']) {
if (align === 'right') {
return 'text-right'
}
if (align === 'center') {
return 'text-center'
}
return 'text-left'
}
function getHiddenClass(hideOnMobile?: TableColumn<unknown>['hideOnMobile']) {
if (hideOnMobile === 'sm') {
return 'hidden sm:table-cell'
}
if (hideOnMobile === 'md') {
return 'hidden md:table-cell'
}
if (hideOnMobile === 'lg') {
return 'hidden lg:table-cell'
}
return ''
}
function Checkbox({
checked,
onChange,
inputRef,
value,
}: {
checked: boolean
onChange: (checked: boolean) => void
inputRef?: React.Ref<HTMLInputElement>
value?: string
}) {
return (
<div className="group absolute top-1/2 left-4 -mt-2 grid size-4 grid-cols-1">
<input
ref={inputRef}
type="checkbox"
value={value}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/10 dark:disabled:bg-gray-800 dark:disabled:checked:bg-gray-800 forced-colors:appearance-auto"
/>
<svg
viewBox="0 0 14 14"
fill="none"
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
>
<path
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-checked:opacity-100"
/>
<path
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-indeterminate:opacity-100"
/>
</svg>
</div>
)
}
export default function Tables<T>({
title,
description,
rows = [],
groups,
columns,
getRowId,
variant = 'simple',
emptyText = 'Keine Einträge vorhanden.',
actionLabel,
onAction,
headerAction,
rowActions,
selectable = false,
selectedRowIds,
onSelectedRowIdsChange,
sortKey,
sortDirection = 'asc',
onSort,
summaryRows,
hideHeadings = false,
stickyTopClassName = 'top-0',
className,
}: TablesProps<T>) {
const headerCheckboxRef = useRef<HTMLInputElement>(null)
const [internalSelectedRowIds, setInternalSelectedRowIds] = useState<Array<string | number>>([])
const rowGroups =
groups && groups.length > 0
? groups
: [
{
id: 'default',
name: '',
rows,
},
]
const allRows = rowGroups.flatMap((group) => group.rows)
const allRowIds = allRows.map(getRowId)
const selectedIds = selectedRowIds ?? internalSelectedRowIds
const selectedSet = new Set(selectedIds)
const checked = allRowIds.length > 0 && selectedIds.length === allRowIds.length
const indeterminate = selectedIds.length > 0 && selectedIds.length < allRowIds.length
const isCard = variant === 'card'
const isBorder = variant === 'border'
const isFullWidth = variant === 'full-width'
const isConstrained = variant === 'constrained'
const isStriped = variant === 'striped'
const isUppercase = variant === 'uppercase'
const isStackedMobile = variant === 'stacked-mobile'
const isStickyHeader = variant === 'sticky-header'
const isVerticalLines = variant === 'vertical-lines'
const isCondensed = variant === 'condensed'
const isSortable = variant === 'sortable'
const isGrouped = variant === 'grouped' || Boolean(groups?.length)
const hasSummary = variant === 'summary' || Boolean(summaryRows?.length)
const totalColumns =
columns.length +
(selectable ? 1 : 0) +
(rowActions ? 1 : 0)
useLayoutEffect(() => {
if (!headerCheckboxRef.current) {
return
}
headerCheckboxRef.current.indeterminate = indeterminate
}, [indeterminate])
function updateSelected(nextSelectedIds: Array<string | number>) {
if (!selectedRowIds) {
setInternalSelectedRowIds(nextSelectedIds)
}
onSelectedRowIdsChange?.(nextSelectedIds)
}
function toggleAll(nextChecked: boolean) {
updateSelected(nextChecked ? allRowIds : [])
}
function toggleRow(rowId: string | number, nextChecked: boolean) {
if (nextChecked) {
updateSelected(Array.from(new Set([...selectedIds, rowId])))
return
}
updateSelected(selectedIds.filter((id) => id !== rowId))
}
function renderHeaderAction() {
if (headerAction) {
return headerAction
}
if (!actionLabel) {
return null
}
return (
<Button
type="button"
color="indigo"
size="md"
onClick={onAction}
>
{actionLabel}
</Button>
)
}
function renderHeader() {
if (!title && !description && !actionLabel && !headerAction) {
return null
}
return (
<div
className={classNames(
isConstrained && 'mx-auto max-w-7xl',
!isFullWidth && 'px-4 sm:px-6 lg:px-8',
isFullWidth && 'px-4 sm:px-6 lg:px-8',
'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>
{(actionLabel || headerAction) && (
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
{renderHeaderAction()}
</div>
)}
</div>
)
}
function renderColumnHeader(column: TableColumn<T>) {
const activeSortColumn = sortKey === column.key
const showSortIcon = isSortable || column.sortable
if (!showSortIcon) {
return column.header
}
return (
<button
type="button"
onClick={() => onSort?.(column)}
className="group inline-flex"
>
{column.header}
<span
className={classNames(
activeSortColumn
? 'ml-2 flex-none rounded-sm bg-gray-100 text-gray-900 group-hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:group-hover:bg-gray-700'
: 'invisible ml-2 flex-none rounded-sm text-gray-400 group-hover:visible group-focus:visible dark:text-gray-500',
)}
>
<ChevronDownIcon
aria-hidden="true"
className={classNames(
'size-5',
activeSortColumn && sortDirection === 'asc' && 'rotate-180',
)}
/>
</span>
</button>
)
}
function renderTableHead() {
return (
<thead
className={classNames(
hideHeadings && 'sr-only',
isCard && 'bg-gray-50 dark:bg-gray-800/75',
!isCard && 'bg-white dark:bg-gray-900',
)}
>
<tr className={classNames(isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10')}>
{selectable && (
<th scope="col" className="relative px-7 sm:w-12 sm:px-6">
<Checkbox
inputRef={headerCheckboxRef}
checked={checked}
onChange={toggleAll}
/>
</th>
)}
{columns.map((column, columnIndex) => {
const isFirst = columnIndex === 0
const isLast = columnIndex === columns.length - 1 && !rowActions
const hiddenClass = getHiddenClass(column.hideOnMobile)
return (
<th
key={column.key}
scope="col"
className={classNames(
isStickyHeader &&
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
isUppercase
? 'py-3 text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400'
: 'py-3.5 text-sm font-semibold text-gray-900 dark:text-white',
isCondensed ? 'px-2' : 'px-3',
isFirst && !selectable && 'pl-4 sm:pl-0',
isCard && isFirst && 'sm:pl-6',
isLast && !isCard && 'pr-4 sm:pr-0',
isLast && isCard && 'sm:pr-6',
getAlignmentClass(column.align),
hiddenClass,
column.headerClassName,
)}
>
{renderColumnHeader(column)}
</th>
)
})}
{rowActions && (
<th
scope="col"
className={classNames(
isStickyHeader &&
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
'py-3.5 pr-4 pl-3 sm:pr-0',
isCard && 'sm:pr-6',
)}
>
<span className="sr-only">Aktionen</span>
</th>
)}
</tr>
</thead>
)
}
function renderRow(row: T, rowIndex: number) {
const rowId = getRowId(row)
const selected = selectedSet.has(rowId)
return (
<tr
key={rowId}
className={classNames(
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
selected && 'bg-gray-50 dark:bg-gray-800/50',
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
)}
>
{selectable && (
<td className="relative px-7 sm:w-12 sm:px-6">
{selected && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-indigo-600 dark:bg-indigo-500" />
)}
<Checkbox
checked={selected}
value={String(rowId)}
onChange={(nextChecked) => toggleRow(rowId, nextChecked)}
/>
</td>
)}
{columns.map((column, columnIndex) => {
const isFirst = columnIndex === 0
const isLast = columnIndex === columns.length - 1 && !rowActions
const hiddenClass = getHiddenClass(column.hideOnMobile)
return (
<td
key={column.key}
className={classNames(
isCondensed ? 'py-2' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
isCondensed ? 'px-2' : 'px-3',
isFirst && !selectable && 'pl-4 sm:pl-0',
isCard && isFirst && 'sm:pl-6',
isLast && !isCard && 'pr-4 sm:pr-0',
isLast && isCard && 'sm:pr-6',
'text-sm text-gray-500 dark:text-gray-400',
!isStackedMobile && 'whitespace-nowrap',
column.isPrimary && 'font-medium text-gray-900 dark:text-white',
selected && column.isPrimary && 'text-indigo-600 dark:text-indigo-400',
getAlignmentClass(column.align),
hiddenClass,
column.className,
)}
>
{column.render(row)}
{isStackedMobile && column.isPrimary && column.mobileRender && (
<dl className="font-normal lg:hidden">
{column.mobileRender(row)}
</dl>
)}
</td>
)
})}
{rowActions && (
<td
className={classNames(
isCondensed ? 'py-2' : 'py-4',
'pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0',
isCard && 'sm:pr-6',
)}
>
{rowActions(row)}
</td>
)}
</tr>
)
}
function renderGroupedRows() {
return rowGroups.map((group) => (
<Fragment key={group.id}>
{isGrouped && group.name && (
<tr className="border-t border-gray-200 dark:border-white/10">
<th
scope="colgroup"
colSpan={totalColumns}
className="bg-gray-50 py-2 pr-3 pl-4 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:bg-gray-800/50 dark:text-white"
>
{group.name}
</th>
</tr>
)}
{group.rows.map(renderRow)}
</Fragment>
))
}
function renderEmptyRow() {
if (allRows.length > 0) {
return null
}
return (
<tr>
<td
colSpan={totalColumns}
className="px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400"
>
{emptyText}
</td>
</tr>
)
}
function renderSummaryRows() {
if (!hasSummary || !summaryRows?.length) {
return null
}
return (
<tfoot>
{summaryRows.map((row, index) => {
const colSpan = row.colSpan ?? Math.max(totalColumns - 1, 1)
return (
<tr key={index}>
<th
scope="row"
colSpan={colSpan}
className={classNames(
index === 0 ? 'pt-6' : 'pt-4',
'pr-3 pl-4 text-right text-sm sm:pl-0',
row.emphasized
? 'font-semibold text-gray-900 dark:text-white'
: 'font-normal text-gray-500 dark:text-gray-400',
)}
>
{row.label}
</th>
<td
className={classNames(
index === 0 ? 'pt-6' : 'pt-4',
'pr-4 pl-3 text-right text-sm sm:pr-0',
row.emphasized
? 'font-semibold text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400',
)}
>
{row.value}
</td>
</tr>
)
})}
</tfoot>
)
}
function renderTable() {
return (
<table
className={classNames(
isStickyHeader
? 'min-w-full border-separate border-spacing-0'
: 'relative min-w-full divide-y divide-gray-300 dark:divide-white/15',
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
)}
>
{renderTableHead()}
<tbody
className={classNames(
!isStickyHeader && 'divide-y divide-gray-200 dark:divide-white/10',
isCard
? 'bg-white dark:bg-gray-800/50'
: 'bg-white dark:bg-gray-900',
)}
>
{renderEmptyRow()}
{renderGroupedRows()}
</tbody>
{renderSummaryRows()}
</table>
)
}
return (
<div className={className}>
{renderHeader()}
<div
className={classNames(
title || description || actionLabel || headerAction ? 'mt-8' : '',
'flow-root',
isFullWidth && 'overflow-hidden',
)}
>
<div
className={classNames(
isConstrained && 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8',
!isConstrained && '-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8',
)}
>
<div
className={classNames(
isConstrained
? ''
: 'inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8',
isFullWidth && 'sm:px-0 lg:px-0',
)}
>
{isCard || isBorder ? (
<div
className={classNames(
isCard &&
'overflow-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10',
isBorder &&
'overflow-hidden ring-1 ring-gray-300 sm:rounded-lg dark:ring-white/15',
)}
>
{renderTable()}
</div>
) : (
renderTable()
)}
</div>
</div>
</div>
</div>
)
}
function isAvatarVariant(variant: TableVariant) {
return variant === 'avatars'
}