673 lines
18 KiB
TypeScript
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'
|
|
} |