turash/bugulma/frontend/components/ui/Table.tsx

429 lines
11 KiB
TypeScript

import React from 'react';
import { clsx } from 'clsx';
export interface TableProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* Table container using divs for better responsiveness and control
*/
export const Table = React.forwardRef<HTMLDivElement, TableProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx(
'w-full border rounded-lg overflow-hidden',
'bg-background',
className
)}
role="table"
{...props}
>
{children}
</div>
);
}
);
Table.displayName = 'Table';
export interface TableHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* Table header container
*/
export const TableHeader = React.forwardRef<HTMLDivElement, TableHeaderProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx(
'bg-muted/50 border-b',
'grid gap-4 px-4 py-3',
'text-xs font-medium text-muted-foreground uppercase tracking-wider',
className
)}
role="rowgroup"
{...props}
>
{children}
</div>
);
}
);
TableHeader.displayName = 'TableHeader';
export interface TableRowProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
selected?: boolean;
interactive?: boolean;
}
/**
* Table row using div
*/
export const TableRow = React.forwardRef<HTMLDivElement, TableRowProps>(
({ className, children, selected, interactive = false, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx(
'grid gap-4 px-4 py-3 border-b border-border last:border-b-0',
'bg-background',
'transition-colors',
{
'bg-primary/5 border-primary/20': selected,
'hover:bg-muted/50 cursor-pointer': interactive && !selected,
'hover:bg-primary/10': interactive && selected,
},
className
)}
role="row"
{...props}
>
{children}
</div>
);
}
);
TableRow.displayName = 'TableRow';
export interface TableHeadProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
sortable?: boolean;
sorted?: 'asc' | 'desc' | null;
onSort?: () => void;
width?: string | number;
minWidth?: string | number;
}
/**
* Table header cell
*/
export const TableHead = React.forwardRef<HTMLDivElement, TableHeadProps>(
(
{
className,
children,
sortable = false,
sorted = null,
onSort,
width,
minWidth,
...props
},
ref
) => {
const style: React.CSSProperties = {};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (minWidth) style.minWidth = typeof minWidth === 'number' ? `${minWidth}px` : minWidth;
return (
<div
ref={ref}
className={clsx(
'flex items-center gap-2',
{
'cursor-pointer select-none hover:text-foreground': sortable,
},
className
)}
style={style}
role="columnheader"
onClick={sortable ? onSort : undefined}
{...props}
>
{children}
{sortable && (
<span className="text-muted-foreground/50">
{sorted === 'asc' ? '↑' : sorted === 'desc' ? '↓' : '⇅'}
</span>
)}
</div>
);
}
);
TableHead.displayName = 'TableHead';
export interface TableCellProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
width?: string | number;
minWidth?: string | number;
align?: 'left' | 'center' | 'right';
}
/**
* Table cell
*/
export const TableCell = React.forwardRef<HTMLDivElement, TableCellProps>(
({ className, children, width, minWidth, align = 'left', ...props }, ref) => {
const style: React.CSSProperties = {};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (minWidth) style.minWidth = typeof minWidth === 'number' ? `${minWidth}px` : minWidth;
return (
<div
ref={ref}
className={clsx(
'flex items-center',
{
'justify-start': align === 'left',
'justify-center': align === 'center',
'justify-end': align === 'right',
},
className
)}
style={style}
role="cell"
{...props}
>
{children}
</div>
);
}
);
TableCell.displayName = 'TableCell';
export interface TableBodyProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* Table body container
*/
export const TableBody = React.forwardRef<HTMLDivElement, TableBodyProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx('divide-y divide-border', className)}
role="rowgroup"
{...props}
>
{children}
</div>
);
}
);
TableBody.displayName = 'TableBody';
export interface TableEmptyProps extends React.HTMLAttributes<HTMLDivElement> {
message?: string;
description?: string;
action?: React.ReactNode;
}
/**
* Empty state for table
*/
export const TableEmpty = React.forwardRef<HTMLDivElement, TableEmptyProps>(
({ className, message = 'No data available', description, action, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx(
'flex flex-col items-center justify-center py-12 px-4 text-center',
className
)}
{...props}
>
<p className="text-sm font-medium text-foreground mb-1">{message}</p>
{description && (
<p className="text-sm text-muted-foreground mb-4">{description}</p>
)}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
);
TableEmpty.displayName = 'TableEmpty';
/**
* Responsive table wrapper that switches to card view on mobile
*/
export interface ResponsiveTableProps<T> {
data: T[];
columns: Array<{
key: string;
header: string;
render: (item: T, index: number) => React.ReactNode;
width?: string | number;
minWidth?: string | number;
sortable?: boolean;
align?: 'left' | 'center' | 'right';
}>;
onSort?: (key: string, direction: 'asc' | 'desc') => void;
sortedColumn?: string | null;
sortDirection?: 'asc' | 'desc' | null;
emptyMessage?: string;
emptyDescription?: string;
emptyAction?: React.ReactNode;
selectedRows?: Set<string>;
onRowSelect?: (id: string) => void;
getRowId?: (item: T) => string;
renderMobileCard?: (item: T, index: number) => React.ReactNode;
isLoading?: boolean;
loadingRows?: number;
className?: string;
}
export function ResponsiveTable<T>({
data,
columns,
onSort,
sortedColumn = null,
sortDirection = null,
emptyMessage = 'No data available',
emptyDescription,
emptyAction,
selectedRows,
onRowSelect,
getRowId,
renderMobileCard,
isLoading = false,
loadingRows = 5,
className,
}: ResponsiveTableProps<T>) {
const handleSort = (key: string) => {
if (!onSort) return;
const newDirection =
sortedColumn === key && sortDirection === 'asc' ? 'desc' : 'asc';
onSort(key, newDirection);
};
const gridTemplateColumns = columns
.map((col) => {
if (col.width) {
return typeof col.width === 'number' ? `${col.width}px` : col.width;
}
return '1fr';
})
.join(' ');
if (isLoading) {
// Loading skeleton
return (
<Table className={className}>
<TableHeader style={{ gridTemplateColumns }} className="grid">
{columns.map((column) => (
<TableHead key={column.key} width={column.width} minWidth={column.minWidth}>
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
</TableHead>
))}
</TableHeader>
<TableBody>
{Array.from({ length: loadingRows }).map((_, index) => (
<TableRow key={`loading-${index}`} style={{ gridTemplateColumns }} className="grid">
{columns.map((column) => (
<TableCell key={column.key}>
<div className="h-4 w-full bg-muted animate-pulse rounded" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
if (data.length === 0) {
return (
<Table className={className}>
<TableEmpty
message={emptyMessage}
description={emptyDescription}
action={emptyAction}
/>
</Table>
);
}
return (
<>
{/* Desktop Table View */}
<div className="hidden md:block">
<Table className={className}>
<TableHeader
style={{ gridTemplateColumns }}
className="grid"
>
{columns.map((column) => (
<TableHead
key={column.key}
sortable={column.sortable}
sorted={sortedColumn === column.key ? sortDirection : null}
onSort={column.sortable ? () => handleSort(column.key) : undefined}
width={column.width}
minWidth={column.minWidth}
align={column.align}
>
{column.header}
</TableHead>
))}
</TableHeader>
<TableBody>
{data.map((item, index) => {
const rowId = getRowId ? getRowId(item) : String(index);
const isSelected = selectedRows?.has(rowId) ?? false;
return (
<TableRow
key={rowId}
selected={isSelected}
interactive={!!onRowSelect}
onClick={onRowSelect ? () => onRowSelect(rowId) : undefined}
style={{ gridTemplateColumns }}
className="grid"
>
{columns.map((column) => (
<TableCell
key={column.key}
width={column.width}
minWidth={column.minWidth}
align={column.align}
>
{column.render(item, index)}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Mobile Card View */}
<div className="md:hidden space-y-4">
{data.map((item, index) => {
if (renderMobileCard) {
return <React.Fragment key={getRowId ? getRowId(item) : index}>{renderMobileCard(item, index)}</React.Fragment>;
}
// Default mobile card rendering
return (
<div
key={getRowId ? getRowId(item) : index}
className="border rounded-lg p-4 space-y-3 bg-card"
>
{columns.map((column) => (
<div key={column.key} className="flex flex-col">
<span className="text-xs font-medium text-muted-foreground mb-1">
{column.header}
</span>
<div className="text-sm text-foreground">
{column.render(item, index)}
</div>
</div>
))}
</div>
);
})}
</div>
</>
);
}