initial: standalone repo from monorepo split
This commit is contained in:
238
src/components/ui/data-table.tsx
Normal file
238
src/components/ui/data-table.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { EmptyState } from './empty-state';
|
||||
import { TableSkeleton } from './skeleton';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
render: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface FilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface QuickFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
keyExtractor: (item: T) => string;
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
quickFilters?: QuickFilter[];
|
||||
activeFilters?: Record<string, string>;
|
||||
onFilterChange?: (key: string, value: string) => void;
|
||||
pageSize?: number;
|
||||
onRowClick?: (item: T) => void;
|
||||
maxHeight?: string;
|
||||
headerActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50, 100];
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
loading,
|
||||
emptyTitle = 'No data',
|
||||
emptyDescription,
|
||||
keyExtractor,
|
||||
searchPlaceholder,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
quickFilters,
|
||||
activeFilters,
|
||||
onFilterChange,
|
||||
pageSize: initialPageSize = 20,
|
||||
onRowClick,
|
||||
maxHeight,
|
||||
headerActions,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(initialPageSize);
|
||||
|
||||
function handleSort(key: string) {
|
||||
if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortKey(key); setSortDir('asc'); }
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortKey) return data;
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = (a as any)[sortKey];
|
||||
const bVal = (b as any)[sortKey];
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
const cmp = typeof aVal === 'string' ? aVal.localeCompare(bVal) : aVal - bVal;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [data, sortKey, sortDir]);
|
||||
|
||||
const totalPages = Math.ceil(sortedData.length / perPage);
|
||||
const paginatedData = sortedData.slice((page - 1) * perPage, page * perPage);
|
||||
const startItem = sortedData.length === 0 ? 0 : (page - 1) * perPage + 1;
|
||||
const endItem = Math.min(page * perPage, sortedData.length);
|
||||
|
||||
// Reset page when data changes
|
||||
if (page > totalPages && totalPages > 0) setPage(totalPages);
|
||||
|
||||
function getPageNumbers(): (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('...');
|
||||
if (totalPages > 1) pages.push(totalPages);
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (loading) return <TableSkeleton rows={6} cols={columns.length} />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Search + Quick Filters + Header Actions row */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{onSearchChange && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-400" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<circle cx="7" cy="7" r="5" /><path d="M11 11l3.5 3.5" />
|
||||
</svg>
|
||||
<input type="text" value={searchValue} onChange={(e) => { onSearchChange(e.target.value); setPage(1); }}
|
||||
placeholder={searchPlaceholder || 'Search...'} aria-label={searchPlaceholder || 'Search'}
|
||||
className="w-64 pl-9 pr-3 py-2 rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-sm text-surface-900 dark:text-surface-200 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick filter chips */}
|
||||
{quickFilters && onFilterChange && quickFilters.map((filter) => (
|
||||
<div key={filter.key} className="flex items-center gap-1">
|
||||
<span className="text-xs text-surface-400 dark:text-surface-500 mr-1">{filter.label}:</span>
|
||||
<button onClick={() => onFilterChange(filter.key, '')}
|
||||
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-all duration-150 cursor-pointer ${
|
||||
!activeFilters?.[filter.key] ? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-400' : 'bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-600'
|
||||
}`}>All</button>
|
||||
{filter.options.map((opt) => (
|
||||
<button key={opt.value} onClick={() => onFilterChange(filter.key, opt.value)}
|
||||
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-all duration-150 cursor-pointer ${
|
||||
activeFilters?.[filter.key] === opt.value ? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-400' : 'bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-600'
|
||||
}`}>{opt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{headerActions && <div className="flex-shrink-0">{headerActions}</div>}
|
||||
</div>
|
||||
|
||||
{sortedData.length === 0 ? (
|
||||
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription}
|
||||
icon={<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M9 9h6M9 13h4" /></svg>} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 flex flex-col">
|
||||
{/* Scrollable table body with sticky header */}
|
||||
<div className="overflow-x-auto" style={{ maxHeight: maxHeight ?? 'calc(100vh - 300px)' }}>
|
||||
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700" role="grid">
|
||||
<thead className="bg-surface-50/50 dark:bg-surface-800/80 sticky top-0 z-10">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key}
|
||||
className={`px-5 py-3 text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider bg-surface-50/80 dark:bg-surface-800/90 backdrop-blur-sm ${
|
||||
col.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${col.sortable ? 'cursor-pointer select-none hover:text-surface-700 dark:hover:text-surface-300 transition-colors' : ''}`}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}>
|
||||
<span className="flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable && sortKey === col.key && (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
{sortDir === 'asc' ? <path d="M6 3l4 6H2z" /> : <path d="M6 9l4-6H2z" />}
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-100 dark:divide-surface-700">
|
||||
{paginatedData.map((item) => (
|
||||
<tr key={keyExtractor(item)}
|
||||
className={`group transition-colors duration-100 ${
|
||||
onRowClick
|
||||
? 'cursor-pointer hover:bg-primary-50/40 dark:hover:bg-primary-900/20 border-l-2 border-l-transparent hover:border-l-primary-400'
|
||||
: 'hover:bg-surface-50/50 dark:hover:bg-surface-700/50'
|
||||
}`}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-5 py-3.5 text-sm text-surface-700 dark:text-surface-300 ${col.align === 'right' ? 'text-right' : 'text-left'}`}>
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls — always visible */}
|
||||
<div className="mt-3 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm text-surface-500 dark:text-surface-400">
|
||||
<span>Showing {startItem}-{endItem} of {sortedData.length}</span>
|
||||
<select value={perPage} onChange={(e) => { setPerPage(Number(e.target.value)); setPage(1); }}
|
||||
aria-label="Items per page"
|
||||
className="ml-2 rounded-md border border-surface-200 dark:border-surface-700 px-2 py-1 text-xs bg-white dark:bg-surface-800 text-surface-700 dark:text-surface-300 cursor-pointer focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20">
|
||||
{PAGE_SIZES.map((s) => <option key={s} value={s}>{s}/page</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="flex items-center gap-1" aria-label="Pagination">
|
||||
<button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}
|
||||
className="p-1.5 rounded-md text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
aria-label="Previous page">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M10 4l-4 4 4 4" /></svg>
|
||||
</button>
|
||||
{getPageNumbers().map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-surface-300 dark:text-surface-500">...</span>
|
||||
) : (
|
||||
<button key={p} onClick={() => setPage(p as number)}
|
||||
className={`min-w-[32px] h-8 rounded-md text-sm font-medium transition-colors cursor-pointer ${
|
||||
page === p ? 'bg-primary-600 text-white' : 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700'
|
||||
}`}>{p}</button>
|
||||
),
|
||||
)}
|
||||
<button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages}
|
||||
className="p-1.5 rounded-md text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
aria-label="Next page">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M6 4l4 4-4 4" /></svg>
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user