extract ui to lib

This commit is contained in:
2026-02-22 20:35:27 +01:00
parent 23423784df
commit ac60855ae5
24 changed files with 4913 additions and 0 deletions

180
src/components/Table.tsx Normal file
View File

@@ -0,0 +1,180 @@
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>
);
}