frontend initiatewith shadcn uis
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
ArrowDataTransferVerticalIcon,
|
||||
ArrowUp01Icon,
|
||||
ArrowDown01Icon,
|
||||
ChevronRightIcon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { TableToolbar } from './table-toolbar';
|
||||
import { TablePagination } from './table-pagination';
|
||||
import { filterData } from './hooks/use-table-filter';
|
||||
import { sortData } from './hooks/use-table-sort';
|
||||
import { exportTable } from './hooks/use-table-export';
|
||||
import { getNestedValue, resolveUrlTemplate } from './utils/nested-value';
|
||||
import type {
|
||||
TableConfig, ColumnDef, FieldConfig, SortState, FilterState, PageState,
|
||||
ServerSideParams, ExportFormat,
|
||||
} from './types';
|
||||
|
||||
interface DataTableProps<T extends object> {
|
||||
config: TableConfig<T>;
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
totalCount?: number;
|
||||
onSortChange?: (s: SortState) => void;
|
||||
onFilterChange?: (f: FilterState) => void;
|
||||
onPageChange?: (p: PageState) => void;
|
||||
onSelectionChange?: (rows: T[]) => void;
|
||||
onRowClick?: (row: T) => void;
|
||||
onRowExpand?: (row: T) => void;
|
||||
onServerSideChange?: (p: ServerSideParams) => void;
|
||||
}
|
||||
|
||||
function normalizeField(f: FieldConfig | string): FieldConfig {
|
||||
return typeof f === 'string' ? { key: f } : f;
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState }) {
|
||||
if (sortState.column !== colKey) {
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDataTransferVerticalIcon}
|
||||
className="size-3 text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return sortState.direction === 'asc'
|
||||
? <HugeiconsIcon icon={ArrowUp01Icon} className="size-3" />
|
||||
: <HugeiconsIcon icon={ArrowDown01Icon} className="size-3" />;
|
||||
}
|
||||
|
||||
function MobileCard<T extends object>({
|
||||
row,
|
||||
columns,
|
||||
mobileCardCfg,
|
||||
onClick,
|
||||
}: {
|
||||
row: T;
|
||||
columns: ColumnDef<T>[];
|
||||
mobileCardCfg?: TableConfig<T>['mobileCard'];
|
||||
onClick?: (row: T) => void;
|
||||
}) {
|
||||
const actionCol = columns.find(c => !c.title && c.cellRender);
|
||||
const dataColumns = columns.filter(c => !!c.title);
|
||||
|
||||
const primaryCol = dataColumns[0];
|
||||
// Always 2 columns, max 4 fields (2 rows) to prevent cramping
|
||||
const previewCols = (mobileCardCfg?.previewColumns
|
||||
? dataColumns.filter(c => mobileCardCfg.previewColumns!.includes(c.key))
|
||||
: dataColumns.slice(1, 3)
|
||||
).slice(0, 4);
|
||||
|
||||
const href = mobileCardCfg?.detailHref?.(row);
|
||||
const isNavigable = !!href || !!onClick;
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border bg-card overflow-hidden transition-colors duration-150',
|
||||
isNavigable && 'active:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
{/* Header: primary field + chevron */}
|
||||
<div className="px-4 pt-4 pb-3 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{primaryCol && <CellContent row={row} col={primaryCol} />}
|
||||
</div>
|
||||
{isNavigable && (
|
||||
<HugeiconsIcon
|
||||
icon={ChevronRightIcon}
|
||||
className="size-4 text-muted-foreground/40 shrink-0 mt-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview fields — fixed 2-column grid */}
|
||||
{previewCols.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-4 px-4 pb-4">
|
||||
{previewCols.map(col => (
|
||||
<div key={col.key} className="min-w-0 overflow-hidden">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/50 mb-1.5 truncate">
|
||||
{col.title}
|
||||
</p>
|
||||
<div className="overflow-hidden">
|
||||
<CellContent row={row} col={col} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions — only when not navigating away */}
|
||||
{!href && actionCol && (
|
||||
<div
|
||||
className="border-t px-4 py-2.5 flex justify-end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{actionCol.cellRender!(row, actionCol)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return <Link href={href} className="block">{inner}</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isNavigable ? 'cursor-pointer' : ''} onClick={() => onClick?.(row)}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CellContent<T extends object>({
|
||||
row, col,
|
||||
}: { row: T; col: ColumnDef<T> }) {
|
||||
if (col.cellRender) return <>{col.cellRender(row, col)}</>;
|
||||
|
||||
const fields = col.fields.map(normalizeField);
|
||||
const layout = col.layout ?? 'vertical';
|
||||
|
||||
const rendered = fields.map((field, i) => {
|
||||
const raw = getNestedValue(row, field.key);
|
||||
const text = field.transform
|
||||
? field.transform(raw, row)
|
||||
: `${field.prefix ?? ''}${raw ?? ''}${field.suffix ?? ''}`;
|
||||
|
||||
const renderer = field.renderer ?? 'text';
|
||||
|
||||
if (renderer === 'badge') {
|
||||
const colorClass = field.badgeConfig?.colorMap?.[String(raw)] ?? field.badgeConfig?.defaultColor ?? '';
|
||||
return (
|
||||
<Badge key={field.key} className={cn('capitalize', colorClass)}>
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderer === 'link') {
|
||||
const href = field.linkConfig?.urlKey
|
||||
? String(getNestedValue(row, field.linkConfig.urlKey) ?? '#')
|
||||
: field.linkConfig?.urlTemplate
|
||||
? resolveUrlTemplate(field.linkConfig.urlTemplate, row)
|
||||
: '#';
|
||||
return (
|
||||
<a
|
||||
key={field.key}
|
||||
href={href}
|
||||
target={field.linkConfig?.target ?? '_self'}
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={field.key}
|
||||
className={cn(
|
||||
'truncate block',
|
||||
field.primary ? 'font-medium' : 'text-muted-foreground text-xs',
|
||||
field.class,
|
||||
i === 0 && 'text-sm',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
const crossAlign =
|
||||
col.align === 'right' ? 'items-end' : col.align === 'center' ? 'items-center' : '';
|
||||
|
||||
if (layout === 'horizontal') {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-1.5 flex-wrap',
|
||||
col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : '',
|
||||
)}>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className={cn('flex flex-col gap-0.5', crossAlign)}>{rendered}</div>;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
config,
|
||||
data,
|
||||
loading,
|
||||
totalCount,
|
||||
onSortChange,
|
||||
onFilterChange,
|
||||
onPageChange,
|
||||
onSelectionChange,
|
||||
onRowClick,
|
||||
onServerSideChange,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
|
||||
const [filterState, setFilterState] = useState<FilterState>({ global: '', columns: {} });
|
||||
const [pageState, setPageState] = useState<PageState>({
|
||||
page: 1,
|
||||
pageSize: config.pagination?.pageSize ?? 10,
|
||||
});
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(
|
||||
() => new Set(config.columns.filter(c => c.visible === false).map(c => c.key))
|
||||
);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(() => config.columns.map(c => c.key));
|
||||
const [draggedKey, setDraggedKey] = useState<string | null>(null);
|
||||
const [dropTargetKey, setDropTargetKey] = useState<string | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [containerH, setContainerH] = useState(500);
|
||||
|
||||
function rowKey(row: T): string {
|
||||
if (!config.rowKey) return JSON.stringify(row);
|
||||
if (typeof config.rowKey === 'function') return String(config.rowKey(row));
|
||||
return String(row[config.rowKey as keyof T]);
|
||||
}
|
||||
|
||||
const normalizedColumns = useMemo(() =>
|
||||
config.columns.map(col => ({
|
||||
...col,
|
||||
fields: col.fields.map(normalizeField) as FieldConfig[],
|
||||
})), [config.columns]);
|
||||
|
||||
const orderedColumns = useMemo(() =>
|
||||
columnOrder
|
||||
.map(key => normalizedColumns.find(c => c.key === key))
|
||||
.filter(Boolean) as typeof normalizedColumns,
|
||||
[normalizedColumns, columnOrder]
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(() =>
|
||||
orderedColumns.filter(c => !hiddenColumns.has(c.key)),
|
||||
[orderedColumns, hiddenColumns]
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() =>
|
||||
config.serverSide ? data : filterData(data, filterState, normalizedColumns as ColumnDef<T>[]),
|
||||
[data, filterState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const sortedData = useMemo(() =>
|
||||
config.serverSide ? data : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
|
||||
[filteredData, sortState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const totalRecords = useMemo(() =>
|
||||
config.serverSide ? (totalCount ?? data.length) : filteredData.length,
|
||||
[config.serverSide, totalCount, data.length, filteredData.length]
|
||||
);
|
||||
|
||||
const rowHeightPx = useMemo(() => {
|
||||
const map: Record<string, number> = { compact: 36, normal: 48, comfortable: 60 };
|
||||
return map[config.density ?? 'normal'] ?? 48;
|
||||
}, [config.density]);
|
||||
|
||||
const virtualRange = useMemo(() => {
|
||||
if (!config.virtualScroll) return null;
|
||||
const buf = 6;
|
||||
const start = Math.max(0, Math.floor(scrollTop / rowHeightPx) - buf);
|
||||
const end = Math.min(sortedData.length, Math.ceil((scrollTop + containerH) / rowHeightPx) + buf);
|
||||
return { start, end };
|
||||
}, [config.virtualScroll, scrollTop, containerH, rowHeightPx, sortedData.length]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
if (config.serverSide || !config.pagination?.enabled) return sortedData;
|
||||
const { page, pageSize } = pageState;
|
||||
return sortedData.slice((page - 1) * pageSize, page * pageSize);
|
||||
}, [sortedData, pageState, config.serverSide, config.pagination?.enabled]);
|
||||
|
||||
const displayData = useMemo(() =>
|
||||
virtualRange ? sortedData.slice(virtualRange.start, virtualRange.end) : paginatedData,
|
||||
[virtualRange, sortedData, paginatedData]
|
||||
);
|
||||
|
||||
const spacerTop = (virtualRange?.start ?? 0) * rowHeightPx;
|
||||
const spacerBottom = virtualRange ? (sortedData.length - virtualRange.end) * rowHeightPx : 0;
|
||||
|
||||
const selectablePageData = useMemo(() =>
|
||||
config.isRowSelectable ? paginatedData.filter(r => config.isRowSelectable!(r)) : paginatedData,
|
||||
[paginatedData, config.isRowSelectable]
|
||||
);
|
||||
const allSelected = selectablePageData.length > 0 && selectablePageData.every(r => selectedKeys.has(rowKey(r)));
|
||||
const someSelected = selectablePageData.some(r => selectedKeys.has(rowKey(r))) && !allSelected;
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
function handleSort(col: ColumnDef<T>) {
|
||||
if (!col.sortable) return;
|
||||
setSortState(prev => {
|
||||
const next: SortState =
|
||||
prev.column !== col.key ? { column: col.key, direction: 'asc' }
|
||||
: prev.direction === 'asc' ? { column: col.key, direction: 'desc' }
|
||||
: { column: null, direction: null };
|
||||
onSortChange?.(next);
|
||||
if (config.serverSide) onServerSideChange?.({ sort: next, filter: filterState, page: pageState });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleGlobalFilter(value: string) {
|
||||
const next: FilterState = { ...filterState, global: value };
|
||||
setFilterState(next);
|
||||
setPageState(p => ({ ...p, page: 1 }));
|
||||
onFilterChange?.(next);
|
||||
if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: next, page: { ...pageState, page: 1 } });
|
||||
}
|
||||
|
||||
function toggleRow(row: T) {
|
||||
if (config.isRowSelectable && !config.isRowSelectable(row)) return;
|
||||
const key = rowKey(row);
|
||||
const next = new Set(selectedKeys);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const next = new Set(selectedKeys);
|
||||
if (allSelected) {
|
||||
selectablePageData.map(rowKey).forEach(k => next.delete(k));
|
||||
} else {
|
||||
selectablePageData.forEach(r => next.add(rowKey(r)));
|
||||
}
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelectedKeys(new Set());
|
||||
onSelectionChange?.([]);
|
||||
}
|
||||
|
||||
function toggleExpand(row: T) {
|
||||
const key = rowKey(row);
|
||||
setExpandedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(key: string) {
|
||||
setHiddenColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
const next = { ...pageState, page };
|
||||
setPageState(next);
|
||||
onPageChange?.(next);
|
||||
if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: filterState, page: next });
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
const next = { page: 1, pageSize };
|
||||
setPageState(next);
|
||||
onPageChange?.(next);
|
||||
if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: filterState, page: next });
|
||||
}
|
||||
|
||||
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||
setScrollTop(e.currentTarget.scrollTop);
|
||||
setContainerH(e.currentTarget.clientHeight);
|
||||
}
|
||||
|
||||
const handleResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>, colKey: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const th = (e.currentTarget as HTMLElement).closest('th') as HTMLElement;
|
||||
if (!th) return;
|
||||
const startX = e.clientX;
|
||||
const startWidth = th.offsetWidth;
|
||||
th.setPointerCapture(e.pointerId);
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const newWidth = Math.max(80, startWidth + ev.clientX - startX);
|
||||
setColumnWidths(w => ({ ...w, [colKey]: newWidth }));
|
||||
};
|
||||
const onUp = () => {
|
||||
th.removeEventListener('pointermove', onMove);
|
||||
th.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
th.addEventListener('pointermove', onMove);
|
||||
th.addEventListener('pointerup', onUp);
|
||||
}, []);
|
||||
|
||||
function handleDragStart(e: React.DragEvent, colKey: string) {
|
||||
setDraggedKey(colKey);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent, colKey: string) {
|
||||
e.preventDefault();
|
||||
setDropTargetKey(colKey);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent, colKey: string) {
|
||||
e.preventDefault();
|
||||
if (!draggedKey || draggedKey === colKey) return;
|
||||
setColumnOrder(prev => {
|
||||
const next = [...prev];
|
||||
const from = next.indexOf(draggedKey);
|
||||
const to = next.indexOf(colKey);
|
||||
if (from < 0 || to < 0) return prev;
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, draggedKey);
|
||||
return next;
|
||||
});
|
||||
setDraggedKey(null);
|
||||
setDropTargetKey(null);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
setDraggedKey(null);
|
||||
setDropTargetKey(null);
|
||||
}
|
||||
|
||||
const exportConfig = typeof config.exportable === 'object' ? config.exportable : undefined;
|
||||
const showExport = !!config.exportable;
|
||||
|
||||
function handleExport(format: ExportFormat) {
|
||||
exportTable(format, sortedData, visibleColumns as ColumnDef<T>[], exportConfig?.filename ?? 'export');
|
||||
}
|
||||
|
||||
const skeletonCount = config.pagination?.pageSize ?? 10;
|
||||
const alignClass = (col: ColumnDef<T>) =>
|
||||
col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : 'justify-start';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden bg-background">
|
||||
<TableToolbar
|
||||
columns={orderedColumns as ColumnDef<T>[]}
|
||||
hiddenColumns={hiddenColumns}
|
||||
globalFilter={filterState.global}
|
||||
showExport={showExport}
|
||||
exportFormats={exportConfig?.formats}
|
||||
selectedCount={selectedKeys.size}
|
||||
onGlobalFilterChange={handleGlobalFilter}
|
||||
onColumnVisibilityChange={toggleColumnVisibility}
|
||||
onExport={handleExport}
|
||||
onClearSelection={clearSelection}
|
||||
/>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className="md:hidden p-3 space-y-3">
|
||||
{loading && Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((__, j) => <Skeleton key={j} className="h-4 w-full" />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && displayData.length === 0 && (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{config.emptyMessage ?? 'No records found.'}
|
||||
</div>
|
||||
)}
|
||||
{!loading && displayData.map(row => (
|
||||
<MobileCard
|
||||
key={rowKey(row)}
|
||||
row={row}
|
||||
columns={visibleColumns as ColumnDef<T>[]}
|
||||
mobileCardCfg={config.mobileCard}
|
||||
onClick={onRowClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
style={config.virtualScroll ? { height: config.virtualScrollHeight ?? '520px', overflowY: 'auto' } : undefined}
|
||||
onScroll={handleScroll}
|
||||
className="hidden md:block overflow-x-auto"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className={config.stickyHeader ? 'sticky top-0 z-10 bg-muted/50' : ''}>
|
||||
<TableRow>
|
||||
{config.selectable && (
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{config.expandable && <TableHead className="w-10" />}
|
||||
{visibleColumns.map(col => {
|
||||
const width = columnWidths[col.key] ?? col.width ?? 160;
|
||||
return (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={{ width, minWidth: col.minWidth ?? 80 }}
|
||||
className={cn(
|
||||
'relative select-none group',
|
||||
col.sticky === 'left' && 'sticky left-0 bg-background z-10',
|
||||
col.sticky === 'right' && 'sticky right-0 bg-background z-10',
|
||||
col.sortable && 'cursor-pointer hover:bg-muted/70',
|
||||
draggedKey === col.key && 'opacity-50',
|
||||
dropTargetKey === col.key && draggedKey !== col.key && 'bg-primary/10',
|
||||
)}
|
||||
draggable
|
||||
onDragStart={e => handleDragStart(e, col.key)}
|
||||
onDragOver={e => handleDragOver(e, col.key)}
|
||||
onDrop={e => handleDrop(e, col.key)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => handleSort(col as ColumnDef<T>)}
|
||||
>
|
||||
<div className={cn('flex items-center gap-1', alignClass(col as ColumnDef<T>))}>
|
||||
{col.headerRender ? col.headerRender(col as ColumnDef<T>) : col.title}
|
||||
{col.sortable && <SortIcon colKey={col.key} sortState={sortState} />}
|
||||
</div>
|
||||
{col.resizable !== false && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize opacity-0 group-hover:opacity-100 bg-border"
|
||||
onPointerDown={e => handleResizeStart(e, col.key)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{loading && Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{config.selectable && <TableCell><Skeleton className="h-4 w-4" /></TableCell>}
|
||||
{config.expandable && <TableCell><Skeleton className="h-4 w-4" /></TableCell>}
|
||||
{visibleColumns.map(col => (
|
||||
<TableCell key={col.key}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{!loading && displayData.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (config.selectable ? 1 : 0) + (config.expandable ? 1 : 0)}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
{config.emptyMessage ?? 'No records found.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{!loading && virtualRange && spacerTop > 0 && (
|
||||
<tr style={{ height: spacerTop }} />
|
||||
)}
|
||||
|
||||
{!loading && displayData.map(row => {
|
||||
const key = rowKey(row);
|
||||
const isSelected = selectedKeys.has(key);
|
||||
const isExpanded = expandedKeys.has(key);
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<TableRow
|
||||
className={cn(
|
||||
'cursor-pointer transition-colors',
|
||||
isSelected && 'bg-primary/5',
|
||||
config.rowClass?.(row),
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{config.selectable && (
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={config.isRowSelectable ? !config.isRowSelectable(row) : false}
|
||||
onCheckedChange={() => toggleRow(row)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{config.expandable && (
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => toggleExpand(row)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ChevronRightIcon}
|
||||
className={cn('size-4 transition-transform', isExpanded && 'rotate-90')}
|
||||
/>
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleColumns.map(col => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.sticky === 'left' && 'sticky left-0 bg-background',
|
||||
col.sticky === 'right' && 'sticky right-0 bg-background',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
)}
|
||||
style={{ height: rowHeightPx }}
|
||||
>
|
||||
<CellContent row={row} col={col as ColumnDef<T>} />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{config.expandable && isExpanded && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (config.selectable ? 2 : 1)}
|
||||
className="bg-muted/30 p-4"
|
||||
>
|
||||
{config.expandRender?.(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loading && virtualRange && spacerBottom > 0 && (
|
||||
<tr style={{ height: spacerBottom }} />
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{config.pagination?.enabled && (
|
||||
<TablePagination
|
||||
page={pageState.page}
|
||||
pageSize={pageState.pageSize}
|
||||
totalRecords={totalRecords}
|
||||
pageSizeOptions={config.pagination.pageSizeOptions ?? [10, 25, 50, 100]}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user