feat(registry): горизонтальная прокрутка внизу области, sticky-шапка, ресайз колонок, лимит строк в ячейке
- ui/table: пропс wrapperClassName (twMerge), в реестре wrapper = w-max min-w-full overflow-visible — переполнение по ширине всплывает в контейнер реестра, скроллбар оказывается внизу видимой области, а не под всей таблицей - TableHeader sticky top-0 (фон tr для plain-варианта скина) - drag правого края шапки меняет ширину колонки (table-fixed + colgroup, min 80 / max 1200 px), двойной клик — сброс; ширины хранятся в localStorage per-form (registry-colwidths-<formId>) — у каждого пользователя свои - настройка «Строк в ячейке» (Авто / 1..8) в диалоге настроек реестра: CSS line-clamp на текстовых ячейках, хранится в registry-rowlines-<formId>; оценка высоты виртуализатора ниже в однострочном режиме check/build чисто, vitest 129/129
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useMemo, useEffect, useCallback, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,9 +36,11 @@ import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
|
||||
import RegistryCellEditor from './RegistryCellEditor';
|
||||
import { InlineField } from '@/components/fields/InlineField';
|
||||
import { useSkinComponents } from '@/themes/ThemeProvider';
|
||||
|
||||
interface TaskAssigneeInfo {
|
||||
userId: number;
|
||||
@@ -264,11 +266,12 @@ const REGISTRY_QUICK_TOGGLE_TYPES = new Set(['checkbox', 'toggle']);
|
||||
|
||||
// Порог виртуализации реестра: при меньшем числе строк рендерим как обычно
|
||||
const REGISTRY_VIRTUALIZATION_THRESHOLD = 30;
|
||||
// Оценка высоты строки до измерения (h-10 по UI Style Guide)
|
||||
const REGISTRY_ROW_ESTIMATED_HEIGHT = 40;
|
||||
|
||||
const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch, isAdmin, selectedTaskIds, anchorTaskId, onToggleSelected, onToggleSelectAll, onSelectRange, onSetAnchorTaskId, onClearSelection, onRequestBulkDelete }: TaskRegistryProps) => {
|
||||
const { toast } = useToast();
|
||||
// Вид шапки таблицы из скина: для plain-варианта sticky-шапке нужен явный фон,
|
||||
// иначе при прокрутке сквозь неё проступают строки
|
||||
const { table: skinTable } = useSkinComponents();
|
||||
|
||||
// Текущая редактируемая ячейка: { taskId, columnKey }
|
||||
const [editingCell, setEditingCell] = useState<{ taskId: number; columnKey: string } | null>(null);
|
||||
@@ -422,6 +425,77 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
const [showCompleted, setShowCompleted] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem(`registry-completed-${formId}`) || 'false'); } catch { return false; }
|
||||
});
|
||||
// Максимум строк текста в ячейке: 'auto' (по умолчанию, текущее поведение) или 1..8.
|
||||
// При 1 строка задачи всегда однострочная с ellipsis (полный текст в title).
|
||||
const [maxLines, setMaxLines] = useState<number | 'auto'>(() => {
|
||||
try {
|
||||
const v = Number(JSON.parse(localStorage.getItem(`registry-rowlines-${formId}`) || 'null'));
|
||||
return Number.isFinite(v) && v >= 1 && v <= 8 ? Math.floor(v) : 'auto';
|
||||
} catch { return 'auto'; }
|
||||
});
|
||||
|
||||
// CSS-стиль клампа многострочного текста; undefined в режиме auto
|
||||
const clampStyle = (lines: number | 'auto'): CSSProperties | undefined =>
|
||||
lines === 'auto'
|
||||
? undefined
|
||||
: { display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: lines, overflow: 'hidden' };
|
||||
|
||||
// Ширины колонок, заданные пользователем перетаскиванием правого края шапки.
|
||||
// Колонки без записи — автоширина. Хранится в localStorage, как и прочие настройки реестра.
|
||||
const [columnWidths, setColumnWidths] = useState<{ [key: string]: number }>(() => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(`registry-colwidths-${formId}`) || '{}');
|
||||
return v && typeof v === 'object' ? v : {};
|
||||
} catch { return {}; }
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(`registry-colwidths-${formId}`, JSON.stringify(columnWidths)); } catch { /* ignore */ }
|
||||
}, [columnWidths, formId]);
|
||||
const hasCustomWidths = Object.keys(columnWidths).length > 0;
|
||||
const resetColumnWidth = (key: string) => setColumnWidths(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
const startColumnResize = (columnKey: string, e: ReactPointerEvent<HTMLElement>) => {
|
||||
if (e.pointerType !== 'mouse' || e.button !== 0) return;
|
||||
const th = e.currentTarget.closest('th');
|
||||
if (!th) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startWidth = th.getBoundingClientRect().width;
|
||||
const prevCursor = document.body.style.cursor;
|
||||
const prevUserSelect = document.body.style.userSelect;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const w = Math.min(1200, Math.max(80, Math.round(startWidth + ev.clientX - startX)));
|
||||
setColumnWidths(prev => ({ ...prev, [columnKey]: w }));
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
document.body.style.cursor = prevCursor;
|
||||
document.body.style.userSelect = prevUserSelect;
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
};
|
||||
|
||||
// Заголовок колонки с ручкой ресайза (drag по правому краю, двойной клик — сброс ширины)
|
||||
const RegistryHead = ({ columnKey, children }: { columnKey: string; children?: ReactNode }) => (
|
||||
<TableHead className="relative">
|
||||
{children}
|
||||
<span
|
||||
className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize select-none touch-none z-10 hidden md:block"
|
||||
title="Перетащите, чтобы изменить ширину. Двойной клик — сброс"
|
||||
onPointerDown={e => startColumnResize(columnKey, e)}
|
||||
onDoubleClick={e => { e.stopPropagation(); resetColumnWidth(columnKey); }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</TableHead>
|
||||
);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '';
|
||||
@@ -443,6 +517,10 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
try { localStorage.setItem(`registry-completed-${formId}`, JSON.stringify(showCompleted)); } catch { /* ignore */ }
|
||||
}, [showCompleted, formId]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(`registry-rowlines-${formId}`, JSON.stringify(maxLines)); } catch { /* ignore */ }
|
||||
}, [maxLines, formId]);
|
||||
|
||||
// При появлении новых полей добавляем их в состояние (не затирая сохранённые)
|
||||
useEffect(() => {
|
||||
setVisibleColumns(prev => {
|
||||
@@ -556,6 +634,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
// Виртуализация строк реестра: рендерятся только видимые строки + overscan.
|
||||
// Фильтрация/сортировка/выделение работают по полному filteredTasks и не затронуты.
|
||||
const tableScrollRef = useRef<HTMLDivElement>(null);
|
||||
// Оценка высоты строки до измерения (h-10 по UI Style Guide; однострочный режим ниже)
|
||||
const REGISTRY_ROW_ESTIMATED_HEIGHT = maxLines === 'auto' ? 40 : 32;
|
||||
const shouldVirtualize = filteredTasks.length > REGISTRY_VIRTUALIZATION_THRESHOLD;
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredTasks.length,
|
||||
@@ -768,6 +848,24 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
/>
|
||||
<Label htmlFor="show-completed">Показать завершённые</Label>
|
||||
</div>
|
||||
{/* Максимум строк текста в ячейке */}
|
||||
<div className="flex items-center justify-between gap-2 pb-2 border-b">
|
||||
<Label htmlFor="registry-max-lines">Строк в ячейке</Label>
|
||||
<Select
|
||||
value={String(maxLines)}
|
||||
onValueChange={v => setMaxLines(v === 'auto' ? 'auto' : Number(v))}
|
||||
>
|
||||
<SelectTrigger id="registry-max-lines" className="h-8 w-32" data-testid="select-max-lines">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Авто</SelectItem>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map(n => (
|
||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DndContext sensors={columnSensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
|
||||
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1 max-h-[50vh] overflow-y-auto pr-1">
|
||||
@@ -874,9 +972,16 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="rounded-md border w-fit min-w-full">
|
||||
<Table wrapperClassName="w-max min-w-full overflow-visible" className={hasCustomWidths ? 'table-fixed' : ''}>
|
||||
{/* Ширины колонок, заданные пользователем (drag шапки); без записи — автоширина */}
|
||||
<colgroup>
|
||||
{isAdmin && <col style={{ width: 32 }} />}
|
||||
{columnOrder.filter(k => visibleColumns[k]).map(k => (
|
||||
<col key={k} style={columnWidths[k] ? { width: columnWidths[k] } : undefined} />
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHeader className={`sticky top-0 z-10${skinTable.header === 'plain' ? ' [&_tr]:bg-background' : ''}`}>
|
||||
<TableRow>
|
||||
{isAdmin && (
|
||||
<TableHead className="w-8 px-2">
|
||||
@@ -897,70 +1002,70 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
{columnOrder.filter(key => visibleColumns[key]).map(key => {
|
||||
if (key === 'title') {
|
||||
return (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column="title" label="Название" filterValue={filters['title'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('title')} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
);
|
||||
}
|
||||
if (key === 'status') {
|
||||
return (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column="status" label="Статус" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('status')} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
);
|
||||
}
|
||||
if (key === 'assignedTo') {
|
||||
return (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column="assignedTo" label="Ответственный" filterValue={filters['assignedTo'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('assignedTo')} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
);
|
||||
}
|
||||
if (key === 'createdAt') {
|
||||
return (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column="createdAt" label="Создана" filterValue={filters['createdAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdAt')} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
);
|
||||
}
|
||||
if (key === 'dueDate') {
|
||||
return (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column="dueDate" label="Срок" filterValue={filters['dueDate'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('dueDate')} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
);
|
||||
}
|
||||
if (key === 'description') {
|
||||
return <TableHead key={key}>Описание</TableHead>;
|
||||
return <RegistryHead key={key} columnKey={key}>Описание</RegistryHead>;
|
||||
}
|
||||
if (key === 'updatedAt') {
|
||||
return (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column="updatedAt" label="Обновлена" filterValue={filters['updatedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('updatedAt')} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
);
|
||||
}
|
||||
if (key === 'createdBy') {
|
||||
return (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column="createdBy" label="Автор" filterValue={filters['createdBy'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdBy')} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
);
|
||||
}
|
||||
if (key === 'completedAt') {
|
||||
return (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column="completedAt" label="Завершена" filterValue={filters['completedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('completedAt')} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
);
|
||||
}
|
||||
if (key.startsWith('field_')) {
|
||||
const fieldId = parseInt(key.replace('field_', ''));
|
||||
const field = fields.find(f => f.id === fieldId);
|
||||
return field ? (
|
||||
<TableHead key={key}>
|
||||
<RegistryHead key={key} columnKey={key}>
|
||||
<ColumnFilter column={key} label={field.name} filterValue={filters[key] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions(key)} />
|
||||
</TableHead>
|
||||
</RegistryHead>
|
||||
) : null;
|
||||
}
|
||||
return null;
|
||||
@@ -1002,18 +1107,23 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
{columnOrder.filter(key => visibleColumns[key]).map(key => {
|
||||
if (key === 'title') {
|
||||
return (
|
||||
<TableCell key={key} className="py-1 font-medium max-w-xs">
|
||||
<TableCell key={key} className={`py-1 font-medium ${columnWidths.title ? '' : 'max-w-xs'}`}>
|
||||
<div>
|
||||
<button
|
||||
className={`font-medium text-sm text-primary hover:text-primary/80 hover:underline text-left truncate block w-full ${isCompleted ? 'line-through text-muted-foreground' : ''}`}
|
||||
className={`font-medium text-sm text-primary hover:text-primary/80 hover:underline text-left w-full block ${maxLines === 'auto' ? 'truncate' : ''} ${isCompleted ? 'line-through text-muted-foreground' : ''}`}
|
||||
style={clampStyle(maxLines)}
|
||||
title={task.title}
|
||||
data-testid={`button-task-title-${task.id}`}
|
||||
onClick={() => onTaskClick?.(task)}
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
{task.description && (
|
||||
<p className={`text-xs text-muted-foreground truncate mt-0.5 ${isCompleted ? 'line-through' : ''}`} title={task.description}>
|
||||
{task.description && (maxLines === 'auto' || maxLines >= 3) && (
|
||||
<p
|
||||
className={`text-xs text-muted-foreground mt-0.5 ${maxLines === 'auto' ? 'truncate' : ''} ${isCompleted ? 'line-through' : ''}`}
|
||||
style={maxLines === 'auto' ? undefined : clampStyle(1)}
|
||||
title={task.description}
|
||||
>
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -1044,15 +1154,15 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
>
|
||||
{(task.assignees && task.assignees.length > 0) ? (
|
||||
<div className="flex items-center">
|
||||
<User className="w-3 h-3 mr-1 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm" style={clampStyle(maxLines)}>
|
||||
{task.assignees.map(a => `${a.user.firstName || ''} ${a.user.middleName || ''} ${a.user.lastName || ''}`.trim() || a.user.email).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
) : task.assignedTo ? (
|
||||
<div className="flex items-center">
|
||||
<User className="w-3 h-3 mr-1 text-muted-foreground" />
|
||||
<span className="text-sm">{getUserName(task.assignedTo)}</span>
|
||||
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm" style={clampStyle(maxLines)}>{getUserName(task.assignedTo)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
@@ -1094,9 +1204,13 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
}
|
||||
if (key === 'description') {
|
||||
return (
|
||||
<TableCell key={key} className="py-2 max-w-sm">
|
||||
<TableCell key={key} className={`py-2 ${columnWidths.description ? '' : 'max-w-sm'}`}>
|
||||
{task.description ? (
|
||||
<p className="text-sm truncate" title={task.description}>
|
||||
<p
|
||||
className={`text-sm ${maxLines === 'auto' ? 'truncate' : ''}`}
|
||||
style={clampStyle(maxLines)}
|
||||
title={task.description}
|
||||
>
|
||||
{task.description}
|
||||
</p>
|
||||
) : (
|
||||
@@ -1119,8 +1233,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
return (
|
||||
<TableCell key={key} className="py-1">
|
||||
<div className="flex items-center">
|
||||
<User className="w-3 h-3 mr-1 text-muted-foreground" />
|
||||
<span className="text-sm">{getUserName(task.createdBy)}</span>
|
||||
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm" style={clampStyle(maxLines)}>{getUserName(task.createdBy)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
@@ -1155,7 +1269,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
) : REGISTRY_QUICK_TOGGLE_TYPES.has(field.type) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm cursor-pointer hover:text-primary"
|
||||
className="text-sm cursor-pointer hover:text-primary text-left w-full"
|
||||
style={clampStyle(maxLines)}
|
||||
onClick={() => handleQuickToggle(task.id, field.id, rawValue)}
|
||||
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
|
||||
>
|
||||
@@ -1165,7 +1280,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
<InlineField
|
||||
ariaLabel={field.name}
|
||||
readView={
|
||||
<span className="text-sm">
|
||||
<span className="text-sm" style={clampStyle(maxLines)}>
|
||||
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
}
|
||||
@@ -1193,7 +1308,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
className={field.type === 'textarea' || field.type === 'rich-text' ? 'items-start min-h-[24px] w-full' : 'items-center min-h-[24px] w-full'}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm">
|
||||
<span className="text-sm" style={clampStyle(maxLines)}>
|
||||
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { tableRecipe } from "@/design-system/recipes"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={tableRecipe.wrapper}>
|
||||
React.HTMLAttributes<HTMLTableElement> & { wrapperClassName?: string }
|
||||
>(({ className, wrapperClassName, ...props }, ref) => (
|
||||
<div className={cn(tableRecipe.wrapper, wrapperClassName)}>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn(tableRecipe.root, className)}
|
||||
|
||||
Reference in New Issue
Block a user