This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
ArrowPathIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { ArrowsUpDownIcon } from '@heroicons/react/24/outline';
|
||||
import { Button } from './Button';
|
||||
@@ -15,183 +15,192 @@ 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;
|
||||
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;
|
||||
};
|
||||
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,
|
||||
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;
|
||||
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 (
|
||||
<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>
|
||||
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}
|
||||
{!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>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user