Files
redux-scraper/app/javascript/bundles/Main/components/SortableTable.tsx
2025-07-23 17:59:10 +00:00

168 lines
4.5 KiB
TypeScript

import * as React from 'react';
export interface TableCell {
value: string;
sortKey: string | number;
}
export interface TableData {
id: string;
cells: TableCell[];
}
export interface TableHeader {
label: string;
key: string;
align: 'left' | 'right';
}
interface SortableTableProps {
headers: TableHeader[];
data: TableData[];
defaultSortKey: string;
defaultSortOrder: 'asc' | 'desc';
}
type SortOrder = 'asc' | 'desc';
export const SortableTable: React.FC<SortableTableProps> = ({
headers,
data,
defaultSortKey,
defaultSortOrder,
}) => {
const [sortKey, setSortKey] = React.useState<string>(defaultSortKey);
const [sortOrder, setSortOrder] = React.useState<SortOrder>(defaultSortOrder);
const handleSort = (headerKey: string) => {
if (sortKey === headerKey) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(headerKey);
setSortOrder('desc');
}
};
const sortedData = React.useMemo(() => {
const headerIndex = headers.findIndex((h) => h.key === sortKey);
if (headerIndex === -1) return data;
return [...data].sort((a, b) => {
const aValue = a.cells[headerIndex]?.sortKey;
const bValue = b.cells[headerIndex]?.sortKey;
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
const comparison = aValue - bValue;
return sortOrder === 'asc' ? comparison : -comparison;
}
return 0;
});
}, [data, sortKey, sortOrder, headers]);
const gridStyle: React.CSSProperties = {
borderRadius: '0.5rem',
border: '1px solid #e2e8f0',
backgroundColor: 'white',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
overflow: 'hidden',
};
const containerStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr auto auto',
};
const headerStyle: React.CSSProperties = {
backgroundColor: '#f8fafc',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
fontWeight: 500,
color: '#334155',
cursor: 'pointer',
userSelect: 'none',
};
const cellStyle: React.CSSProperties = {
padding: '0.25rem 1rem',
borderRight: '1px solid #e2e8f0',
};
const getSortIndicator = (headerKey: string) => {
if (sortKey !== headerKey) {
return (
<span
style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}
>
</span>
);
}
return (
<span style={{ marginLeft: '0.25rem', fontSize: '0.75rem' }}>
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
);
};
return (
<div style={gridStyle}>
<div style={containerStyle}>
{/* Header Row */}
<div className="contents">
{headers.map((header, index) => (
<div
key={header.key}
style={{
...headerStyle,
textAlign: header.align,
...(index === headers.length - 1
? { borderRight: 'none' }
: {}),
}}
onClick={() => handleSort(header.key)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#f8fafc';
}}
>
{header.label}
{getSortIndicator(header.key)}
</div>
))}
</div>
{/* Data Rows */}
{sortedData.map((row) => (
<div key={row.id} className="group contents">
{row.cells.map((cell, cellIndex) => (
<div
key={cellIndex}
style={{
...cellStyle,
textAlign: headers[cellIndex].align,
fontSize: '0.875rem',
fontWeight: cellIndex === 0 ? 500 : 400,
color: cellIndex === 0 ? '#0f172a' : '#64748b',
...(cellIndex === row.cells.length - 1
? { borderRight: 'none' }
: {}),
}}
className="transition-colors duration-150 group-hover:bg-slate-50"
>
{cell.value}
</div>
))}
</div>
))}
</div>
</div>
);
};
export default SortableTable;