Files
web-ui/src/components/Table.tsx
Beatrice Dellacà f1c7e245aa
All checks were successful
continuous-integration/drone/push Build is passing
update prettier
2026-02-23 14:23:37 +01:00

207 lines
9.5 KiB
TypeScript

import type { ReactNode } from 'react';
import {
ArrowPathIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
} from '@heroicons/react/24/solid';
import { ArrowsUpDownIcon } from '@heroicons/react/24/outline';
import { Button } from './Button';
import { Dropdown } from './Dropdown';
import { Label } from './Label';
import type { SortState } from '../types/sort';
type HeaderValue<T> = ReactNode | ((row: T) => ReactNode);
export type TableHeader<T> = {
label: string;
id: string;
value: HeaderValue<T>;
sortable?: boolean;
sortField?: string;
headerClassName?: string;
cellClassName?: string;
};
type TableProps<T> = {
headers: TableHeader<T>[];
data: T[];
rowKey: (row: T, index: number) => string;
isLoading?: boolean;
emptyMessage?: string;
className?: string;
sorting?: SortState | null;
onSortChange?: (field: string) => void;
pagination?: {
page: number;
pageSize: number;
total: number;
totalPages: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
};
};
export function Table<T>({
headers,
data,
rowKey,
isLoading = false,
emptyMessage = 'No data to show.',
className = '',
sorting = null,
onSortChange,
pagination,
}: Readonly<TableProps<T>>) {
const canGoPrev = pagination != null && pagination.page > 1;
const canGoNext = pagination != null && pagination.page < pagination.totalPages;
return (
<div className={`table-shell ${className}`.trim()}>
<div className="table-scroll">
<table className="table-root">
<thead className="table-head">
<tr>
{headers.map((header) => {
const canSort =
header.sortable === true &&
typeof onSortChange === 'function' &&
typeof header.sortField === 'string' &&
header.sortField.length > 0;
const isActiveSort = canSort && sorting?.field === header.sortField;
const sortDirection = isActiveSort ? sorting?.direction : null;
return (
<th
key={header.id}
className={`table-head-cell ${header.headerClassName ?? ''}`.trim()}
>
{canSort ? (
<button
type="button"
className="table-sort-button"
onClick={() =>
onSortChange(header.sortField as string)
}
aria-label={`Sort by ${header.label}`}
>
<span>{header.label}</span>
<span
className="table-sort-icon"
aria-hidden="true"
data-sort-state={sortDirection ?? 'none'}
>
{sortDirection === 'asc' ? (
<ChevronUpIcon className="h-4 w-4" />
) : null}
{sortDirection === 'desc' ? (
<ChevronDownIcon className="h-4 w-4" />
) : null}
{sortDirection == null ? (
<ArrowsUpDownIcon className="h-4 w-4" />
) : null}
</span>
</button>
) : (
header.label
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr className="table-body-row">
<td colSpan={headers.length} className="px-4 py-6 text-center">
<Label
as="span"
variant="body2"
className="inline-flex items-center justify-center ui-loading"
>
<ArrowPathIcon
className="h-5 w-5 animate-spin"
aria-hidden="true"
/>
</Label>
</td>
</tr>
) : null}
{!isLoading && data.length === 0 ? (
<tr className="table-body-row">
<td colSpan={headers.length} className="px-4 py-6 text-center">
<Label variant="body2" className="ui-empty">
{emptyMessage}
</Label>
</td>
</tr>
) : null}
{!isLoading &&
data.map((row, index) => (
<tr key={rowKey(row, index)} className="table-body-row">
{headers.map((header) => {
const content =
typeof header.value === 'function'
? (header.value as (item: T) => ReactNode)(row)
: header.value;
return (
<td
key={`${header.id}-${index}`}
className={`table-cell-secondary ${header.cellClassName ?? ''}`.trim()}
>
{content}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{pagination ? (
<div className="flex flex-col gap-3 border-t border-zinc-500/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<Label variant="body2">{pagination.total} results</Label>
<div className="flex flex-wrap items-center gap-2">
{pagination.onPageSizeChange ? (
<Dropdown
label="Rows"
value={String(pagination.pageSize)}
choices={[5, 10, 20, 50, 100].map((size) => ({
id: String(size),
label: String(size),
}))}
size="sm"
layout="inline"
className="max-w-none"
selectClassName="rounded-lg px-2"
disabled={isLoading}
onChange={(value) => pagination.onPageSizeChange?.(Number(value))}
/>
) : null}
<Button
type="outlined"
size="sm"
icon={ChevronLeftIcon}
ariaLabel="Previous page"
disabled={!canGoPrev || isLoading}
onClick={() => pagination.onPageChange(pagination.page - 1)}
/>
<Label variant="body2" className="px-1 text-xs ui-body-secondary">
Page {pagination.page} of {Math.max(pagination.totalPages, 1)}
</Label>
<Button
type="outlined"
size="sm"
icon={ChevronRightIcon}
ariaLabel="Next page"
disabled={!canGoNext || isLoading}
onClick={() => pagination.onPageChange(pagination.page + 1)}
/>
</div>
</div>
) : null}
</div>
);
}