Files
router-lists-ui/frontend/src/components/VirtualizedTable.jsx
T

254 lines
8.8 KiB
React

import { useRef, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
import TableSkeleton from './TableSkeleton.jsx';
import EmptyState from './EmptyState.jsx';
import Tooltip from './Tooltip.jsx';
/**
* Виртуализированная таблица для больших списков данных (1000+ элементов)
* Рендерит только видимые строки для оптимальной производительности
*
* @param {Object} props
* @param {Array} props.columns - Массив описаний колонок
* @param {Array} props.items - Массив данных для отображения
* @param {string} props.itemKey - Ключ уникального идентификатора
* @param {boolean} props.loading - Состояние загрузки
* @param {string} props.sortField - Текущее поле сортировки
* @param {string} props.sortOrder - Направление сортировки
* @param {function} props.onSort - Обработчик сортировки
* @param {Set} props.selectedItems - Выбранные элементы
* @param {function} props.onSelectItem - Обработчик выбора
* @param {function} props.onSelectAll - Обработчик выбора всех
* @param {function} props.onEdit - Обработчик редактирования
* @param {function} props.onDelete - Обработчик удаления
* @param {function} props.onCopy - Обработчик копирования
* @param {number} props.rowHeight - Высота строки в пикселях (default: 48)
* @param {number} props.containerHeight - Высота контейнера (default: 600)
* @param {number} props.overscan - Количество дополнительных строк (default: 5)
* @param {Object} props.emptyState - Настройки пустого состояния
* @param {boolean} props.selectable - Включить чекбоксы (default: true)
*/
function VirtualizedTable({
columns = [],
items = [],
itemKey = 'id',
loading = false,
sortField,
sortOrder = 'asc',
onSort,
selectedItems = new Set(),
onSelectItem,
onSelectAll,
onDeselectAll,
onEdit,
onDelete,
onCopy,
rowHeight = 48,
containerHeight = 600,
overscan = 5,
emptyState,
selectable = true,
actions = [],
}) {
const parentRef = useRef(null);
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
// Рендер заголовка
const renderHeader = () => (
<thead className="sticky-top bg-white" style={{ zIndex: 1 }}>
<tr>
{selectable && (
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={items.length > 0 && items.every(i => selectedItems.has(i[itemKey]))}
onChange={(e) => e.target.checked ? onSelectAll?.() : onDeselectAll?.()}
title="Выбрать все"
/>
</th>
)}
{columns.map((col) => {
const isSortable = col.sortable !== false && onSort;
const isActive = sortField === col.key;
return (
<th
key={col.key}
className={isSortable ? 'cursor-pointer user-select-none' : ''}
onClick={isSortable ? () => onSort(col.key) : undefined}
style={col.width ? { width: col.width } : undefined}
>
<div className="d-flex align-items-center">
{col.icon && <col.icon size={16} className="me-1 text-muted" />}
{col.title}
{isSortable && isActive && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
);
})}
{hasActions && <th className="text-end">Действия</th>}
</tr>
</thead>
);
// Рендер действий
const renderActions = (item) => (
<>
{onEdit && (
<Tooltip content="Редактировать">
<button
className="btn btn-outline-primary btn-icon btn-sm me-1"
onClick={() => onEdit(item)}
>
<IconEdit size={16} />
</button>
</Tooltip>
)}
{onCopy && (
<Tooltip content="Копировать">
<button
className="btn btn-outline-secondary btn-icon btn-sm me-1"
onClick={() => onCopy(item)}
>
<IconCopy size={16} />
</button>
</Tooltip>
)}
{actions.map((action, idx) => (
<Tooltip key={idx} content={action.label}>
<button
className={`btn btn-outline-${action.variant || 'secondary'} btn-icon btn-sm me-1`}
onClick={() => action.onClick(item)}
>
<action.icon size={16} />
</button>
</Tooltip>
))}
{onDelete && (
<Tooltip content="Удалить">
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => onDelete(item)}
>
<IconTrash size={16} />
</button>
</Tooltip>
)}
</>
);
// Загрузка
if (loading) {
return <TableSkeleton rows={10} cols={columns.length + (hasActions ? 1 : 0)} hasCheckbox={selectable} />;
}
// Пустое состояние
if (items.length === 0) {
return (
<div className="p-4">
{emptyState ? (
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
) : (
<EmptyState title="Нет данных" description="Добавьте записи, чтобы начать." />
)}
</div>
);
}
return (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
{renderHeader()}
</table>
<div
ref={parentRef}
style={{
height: `${Math.min(containerHeight, items.length * rowHeight + 20)}px`,
overflow: 'auto',
}}
>
<div style={{ height: `${totalSize}px`, position: 'relative' }}>
<table className="table card-table table-vcenter table-nowrap mb-0">
<tbody>
{virtualItems.map((virtualRow) => {
const item = items[virtualRow.index];
const id = item[itemKey];
const isSelected = selectedItems.has(id);
return (
<tr
key={id}
className={isSelected ? 'table-selected' : ''}
style={{
height: `${rowHeight}px`,
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{selectable && (
<td style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={isSelected}
onChange={() => onSelectItem?.(id)}
/>
</td>
)}
{columns.map((col) => (
<td key={col.key} className={col.className} style={col.width ? { width: col.width } : undefined}>
{col.render ? col.render(item[col.key], item) : item[col.key]}
</td>
))}
{hasActions && (
<td className="text-end">
{renderActions(item)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="card-footer d-flex align-items-center justify-content-between">
<span className="text-muted">
Показано {items.length} записей
{items.length > 100 && (
<span className="badge bg-blue-lt text-blue ms-2">Виртуализация активна</span>
)}
</span>
</div>
</div>
);
}
export default VirtualizedTable;