frontend initiatewith shadcn uis

This commit is contained in:
Kalana B. Thilakarathna
2026-08-25 16:20:50 +05:30
parent 92fef1dfa8
commit f1c036d1d8
136 changed files with 20257 additions and 0 deletions
@@ -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>
);
}
@@ -0,0 +1,73 @@
import { type ColumnDef, type ExportFormat, type FieldConfig } from '../types';
import { getNestedValue } from '../utils/nested-value';
export function exportTable<T extends object>(
format: ExportFormat, data: T[], columns: ColumnDef<T>[], filename = 'export'
): void {
const exportable = columns.filter(c => c.exportable !== false);
if (format === 'csv') exportCsv(data, exportable, filename);
if (format === 'excel') exportExcel(data, exportable, filename);
if (format === 'pdf') exportPdf(data, exportable, filename);
}
function resolveCellText<T>(row: T, col: ColumnDef<T>): string {
return col.fields.map(f => {
const field: FieldConfig = typeof f === 'string' ? { key: f } : f;
const raw = getNestedValue(row, field.key);
if (field.transform) return field.transform(raw, row);
return `${field.prefix ?? ''}${raw ?? ''}${field.suffix ?? ''}`;
}).join(' ');
}
function download(blob: Blob, name: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
}
function escHtml(s: string) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function exportCsv<T>(data: T[], columns: ColumnDef<T>[], filename: string) {
const headers = columns.map(c => `"${c.title}"`).join(',');
const rows = data.map(row =>
columns.map(col => `"${resolveCellText(row, col).replace(/"/g, '""')}"`).join(',')
);
download(new Blob([[headers, ...rows].join('\r\n')], { type: 'text/csv;charset=utf-8;' }), `${filename}.csv`);
}
function exportExcel<T>(data: T[], columns: ColumnDef<T>[], filename: string) {
const thead = columns.map(c => `<th>${escHtml(c.title)}</th>`).join('');
const tbody = data.map(row =>
`<tr>${columns.map(col => `<td>${escHtml(resolveCellText(row, col))}</td>`).join('')}</tr>`
).join('');
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel">
<head><meta charset="UTF-8"></head>
<body><table border="1">${thead}${tbody}</table></body></html>`;
download(new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8;' }), `${filename}.xls`);
}
function exportPdf<T>(data: T[], columns: ColumnDef<T>[], filename: string) {
const thead = columns.map(c =>
`<th style="padding:6px 10px;border:1px solid #ccc;background:#f5f5f5">${escHtml(c.title)}</th>`
).join('');
const tbody = data.map(row =>
`<tr>${columns.map(col =>
`<td style="padding:5px 10px;border:1px solid #ccc">${escHtml(resolveCellText(row, col))}</td>`
).join('')}</tr>`
).join('');
const win = window.open('', '_blank');
if (!win) return;
win.document.write(`<!DOCTYPE html><html><head>
<title>${escHtml(filename)}</title>
<style>body{font-family:sans-serif;font-size:12px}table{border-collapse:collapse;width:100%}
@media print{button{display:none}}</style></head>
<body><h2>${escHtml(filename)}</h2>
<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>
<br><button onclick="window.print()">Print / Save PDF</button>
</body></html>`);
win.document.close();
}
@@ -0,0 +1,33 @@
import { type ColumnDef, type FieldConfig, type FilterState } from '../types';
import { getNestedValue } from '../utils/nested-value';
export function filterData<T extends object>(data: T[], state: FilterState, columns: ColumnDef<T>[]): T[] {
let result = data;
if (state.global.trim()) {
const term = state.global.toLowerCase();
result = result.filter(row =>
columns.some(col =>
col.fields.some(f => {
const key = typeof f === 'string' ? f : (f as FieldConfig).key;
return String(getNestedValue(row, key) ?? '').toLowerCase().includes(term);
})
)
);
}
const colFilters = Object.entries(state.columns).filter(([, v]) => v.trim());
for (const [colKey, term] of colFilters) {
const col = columns.find(c => c.key === colKey);
if (!col) continue;
const lower = term.toLowerCase();
result = result.filter(row =>
col.fields.some(f => {
const key = typeof f === 'string' ? f : (f as FieldConfig).key;
return String(getNestedValue(row, key) ?? '').toLowerCase().includes(lower);
})
);
}
return result;
}
@@ -0,0 +1,31 @@
import { type ColumnDef, type SortState } from '../types';
import { getNestedValue } from '../utils/nested-value';
export function sortData<T extends object>(data: T[], state: SortState, columns: ColumnDef<T>[]): T[] {
if (!state.column || !state.direction) return data;
const col = columns.find(c => c.key === state.column);
if (!col) return data;
if (col.sortFn) {
const sorted = [...data].sort(col.sortFn);
return state.direction === 'desc' ? sorted.reverse() : sorted;
}
const primaryField = col.fields[0];
const fieldKey = typeof primaryField === 'string' ? primaryField : primaryField.key;
return [...data].sort((a, b) => {
const av = getNestedValue(a, fieldKey);
const bv = getNestedValue(b, fieldKey);
const result = compareValues(av, bv);
return state.direction === 'asc' ? result : -result;
});
}
function compareValues(a: unknown, b: unknown): number {
if (a == null) return -1;
if (b == null) return 1;
if (typeof a === 'number' && typeof b === 'number') return a - b;
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
@@ -0,0 +1,17 @@
export { DataTable } from './data-table';
export type {
TableConfig,
ColumnDef,
FieldConfig,
MobileCardConfig,
SortState,
FilterState,
PageState,
ServerSideParams,
ExportFormat,
BadgeConfig,
IconConfig,
LinkConfig,
PaginationConfig,
ExportConfig,
} from './types';
@@ -0,0 +1,106 @@
'use client';
import { HugeiconsIcon } from '@hugeicons/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@hugeicons/core-free-icons';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface TablePaginationProps {
page: number;
pageSize: number;
totalRecords: number;
pageSizeOptions?: number[];
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}
function buildPageNumbers(page: number, totalPages: number): (number | '...')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: (number | '...')[] = [1];
if (page > 3) pages.push('...');
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
pages.push(i);
}
if (page < totalPages - 2) pages.push('...');
pages.push(totalPages);
return pages;
}
export function TablePagination({
page,
pageSize,
totalRecords,
pageSizeOptions = [10, 25, 50, 100],
onPageChange,
onPageSizeChange,
}: TablePaginationProps) {
const totalPages = Math.max(1, Math.ceil(totalRecords / pageSize));
const from = Math.min((page - 1) * pageSize + 1, totalRecords);
const to = Math.min(page * pageSize, totalRecords);
const pages = buildPageNumbers(page, totalPages);
return (
<div className="flex flex-col gap-3 px-4 py-3 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page:</span>
<Select
value={pageSize}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<SelectTrigger size="sm" className="w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<span className="ml-2">
{totalRecords === 0 ? '0 records' : `${from}–${to} of ${totalRecords} records`}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
<HugeiconsIcon icon={ChevronLeftIcon} className="size-4" />
</Button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-muted-foreground text-sm">…</span>
) : (
<Button
key={p}
variant={p === page ? 'default' : 'outline'}
size="icon-sm"
onClick={() => onPageChange(p as number)}
>
{p}
</Button>
)
)}
<Button
variant="outline"
size="icon-sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
<HugeiconsIcon icon={ChevronRightIcon} className="size-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,124 @@
'use client';
import { useRef } from 'react';
import { HugeiconsIcon } from '@hugeicons/react';
import {
Search01Icon,
Cancel01Icon,
ColumnsThreeCogIcon,
Download01Icon,
} from '@hugeicons/core-free-icons';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { type ColumnDef, type ExportFormat } from './types';
interface TableToolbarProps<T> {
columns: ColumnDef<T>[];
hiddenColumns: Set<string>;
globalFilter: string;
showExport?: boolean;
exportFormats?: ExportFormat[];
selectedCount?: number;
onGlobalFilterChange: (value: string) => void;
onColumnVisibilityChange: (key: string) => void;
onExport?: (format: ExportFormat) => void;
onClearSelection?: () => void;
}
export function TableToolbar<T>({
columns,
hiddenColumns,
globalFilter,
showExport,
exportFormats = ['csv', 'excel', 'pdf'],
selectedCount = 0,
onGlobalFilterChange,
onColumnVisibilityChange,
onExport,
onClearSelection,
}: TableToolbarProps<T>) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex items-center gap-2 p-3 border-b">
{/* Global search */}
<div className="relative flex-1 max-w-xs">
<HugeiconsIcon
icon={Search01Icon}
className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
/>
<Input
ref={inputRef}
value={globalFilter}
onChange={e => onGlobalFilterChange(e.target.value)}
placeholder="Search…"
className="pl-8 pr-8 h-8 text-sm"
/>
{globalFilter && (
<button
onClick={() => onGlobalFilterChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5" />
</button>
)}
</div>
{/* Selection badge */}
{selectedCount > 0 && (
<div className="flex items-center gap-1.5">
<Badge variant="secondary" className="text-xs">
{selectedCount} selected
</Badge>
<Button variant="ghost" size="xs" onClick={onClearSelection} className="text-xs h-6">
Clear
</Button>
</div>
)}
{/* Column visibility */}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" className="gap-1.5" />}>
<HugeiconsIcon icon={ColumnsThreeCogIcon} className="size-4" />
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{columns.map(col => (
<DropdownMenuCheckboxItem
key={col.key}
checked={!hiddenColumns.has(col.key)}
onCheckedChange={() => onColumnVisibilityChange(col.key)}
>
{col.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Export */}
{showExport && onExport && (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" className="gap-1.5" />}>
<HugeiconsIcon icon={Download01Icon} className="size-4" />
Export
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{exportFormats.map(fmt => (
<DropdownMenuItem key={fmt} onClick={() => onExport(fmt)}>
{fmt.toUpperCase()}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
@@ -0,0 +1,117 @@
export type CellRenderer = 'text' | 'badge' | 'icon' | 'link' | 'custom';
export type ColumnLayout = 'vertical' | 'horizontal';
export type SortDirection = 'asc' | 'desc' | null;
export type ExportFormat = 'csv' | 'excel' | 'pdf';
export type ColumnAlign = 'left' | 'center' | 'right';
export interface BadgeConfig {
colorMap?: Record<string, string>;
defaultColor?: string;
}
export interface IconConfig {
iconMap?: Record<string, string>;
defaultIcon?: string;
colorMap?: Record<string, string>;
defaultColorClass?: string;
}
export interface LinkConfig {
urlKey?: string;
urlTemplate?: string;
target?: '_blank' | '_self';
}
export interface FieldConfig {
key: string;
label?: string;
renderer?: CellRenderer;
primary?: boolean;
class?: string;
badgeConfig?: BadgeConfig;
iconConfig?: IconConfig;
linkConfig?: LinkConfig;
truncate?: boolean;
prefix?: string;
suffix?: string;
transform?: (value: unknown, row: unknown) => string;
}
export interface ColumnDef<T = object> {
key: string;
title: string;
fields: (FieldConfig | string)[];
layout?: ColumnLayout;
sortable?: boolean;
filterable?: boolean;
sticky?: 'left' | 'right' | false;
width?: number;
minWidth?: number;
resizable?: boolean;
visible?: boolean;
align?: ColumnAlign;
group?: string;
exportable?: boolean;
sortFn?: (a: T, b: T) => number;
cellRender?: (row: T, col: ColumnDef<T>) => React.ReactNode;
headerRender?: (col: ColumnDef<T>) => React.ReactNode;
}
export interface PaginationConfig {
enabled: boolean;
pageSize?: number;
pageSizeOptions?: number[];
}
export interface ExportConfig {
formats?: ExportFormat[];
filename?: string;
}
export interface MobileCardConfig<T = object> {
/** Column keys to show in the compact summary (after the primary column). Defaults to first 2. */
previewColumns?: string[];
/** Return a URL to navigate to on card tap. Takes precedence over onRowClick. */
detailHref?: (row: T) => string;
}
export interface TableConfig<T = object> {
columns: ColumnDef<T>[];
rowKey?: keyof T | ((row: T) => string);
selectable?: boolean;
multiSelect?: boolean;
expandable?: boolean;
stickyHeader?: boolean;
pagination?: PaginationConfig;
virtualScroll?: boolean;
virtualScrollHeight?: string;
serverSide?: boolean;
exportable?: boolean | ExportConfig;
density?: 'compact' | 'normal' | 'comfortable';
emptyMessage?: string;
rowClass?: (row: T) => string;
isRowSelectable?: (row: T) => boolean;
expandRender?: (row: T) => React.ReactNode;
mobileCard?: MobileCardConfig<T>;
}
export interface SortState {
column: string | null;
direction: SortDirection;
}
export interface FilterState {
global: string;
columns: Record<string, string>;
}
export interface PageState {
page: number;
pageSize: number;
}
export interface ServerSideParams {
sort: SortState;
filter: FilterState;
page: PageState;
}
@@ -0,0 +1,11 @@
export function getNestedValue(obj: unknown, path: string): unknown {
return path.split('.').reduce(
(acc, key) => (acc as Record<string, unknown>)?.[key], obj
);
}
export function resolveUrlTemplate(template: string, row: unknown): string {
return template.replace(/\{(\w+(?:\.\w+)*)\}/g, (_, path) =>
String(getNestedValue(row, path) ?? '')
);
}