All checks were successful
continuous-integration/drone/push Build is passing
207 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|