mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-12 20:44:24 +01:00
* 🎨 feat: Enhance Import Conversations UI with loading state and new localization key * fix: Correct pluralization in selected items message in translation.json * Refactor Chat Input File Table Headers to Use SortFilterHeader Component - Replaced button-based sorting headers in the Chat Input Files Table with a new SortFilterHeader component for better code organization and consistency. - Updated the header for filename, updatedAt, and bytes columns to utilize the new component. Enhance Navigation Component with Skeleton Loading States - Added Skeleton loading states to the Nav component for better user experience during data fetching. - Updated Suspense fallbacks for AgentMarketplaceButton and BookmarkNav components to display Skeletons. Refactor Avatar Component for Improved UI - Enhanced the Avatar component by adding a Label for drag-and-drop functionality. - Improved styling and structure for the file upload area. Update Shared Links Component for Better Error Handling and Sorting - Improved error handling in the Shared Links component for fetching next pages and deleting shared links. - Simplified the header rendering for sorting columns and added sorting functionality to the title and createdAt columns. Refactor Archived Chats Component - Merged ArchivedChats and ArchivedChatsTable components into a single ArchivedChats component for better maintainability. - Implemented sorting and searching functionality with debouncing for improved performance. - Enhanced the UI with better loading states and error handling. Update DataTable Component for Sorting Icons - Added sorting icons (ChevronUp, ChevronDown, ChevronsUpDown) to the DataTable headers for better visual feedback on sorting state. Localization Updates - Updated translation.json to fix missing translations and improve existing ones for better user experience. * ✨ feat: Update DataTable component to streamline props and enhance sorting icons * fix: TS issues * feat: polish and redefine DataTable + shared links and archived chats * feat: enhance DataTable with column pinning and improve sorting functionality * feat: enhance deepEqual function for array support and improve column style stability * refactor: DataTable and ArchivedChats; fix: sorting ArchivedChats API * feat(DataTable): Implement new DataTable component with hooks and optimized features - Added DataTable component with support for virtual scrolling, row selection, and customizable columns. - Introduced hooks for debouncing search input, managing row selection, and calculating column styles. - Enhanced accessibility with keyboard navigation and selection checkboxes. - Implemented skeleton loading state for better user experience during data fetching. - Added DataTableSearch component for filtering data with debounced input. - Created utility logger for improved debugging in development. - Updated translations to support new UI elements and actions. * refactor: update SharedLinks and ArchivedChats to use desktopOnly instead of hideOnMobile; remove unused DataTableColumnHeader component * fix: ensure desktopOnly columns are hidden on mobile in DataTable * refactor: reorganize imports in DataTable components and update index exports * refactor: improve styling and animations in Artifacts, ArtifactsSubMenu, and MCPSubMenu components; update border-radius in style.css * refactor(Artifacts): enhance button toggle functionality and manage expanded state with useEffect * refactor: comment out desktopOnly property in SharedLinks and ArchivedChats components; update translation.json with new keys for link actions * refactor(DataTable): streamline column visibility logic and enhance type definitions; improve cleanup timers and optimize rendering * refactor(DataTable): enhance type definitions for processed data rows and update custom actions renderer type * refactor(DataTable): optimize processed data handling and improve warning for missing IDs; streamline DataTableComponents imports * refactor(DataTable): enhance accessibility features and improve localization for selection and loading states * refactor: improve padding in dialog content and enhance row selection functionality in ArchivedChats and DataTable components * refactor(DataTable): remove unnecessary role and tabindex attributes from select all button for improved accessibility * refactor(translation): remove outdated error messages and unused UI strings for cleaner localization * refactor(DataTable): enhance virtualization and scrolling performance with dynamic overscan adjustments * refactor(DataTableErrorBoundary): enhance error handling and localization support * refactor(DataTable): improve column sizing and visibility handling; remove deprecated features * refactor: enhance UI components with improved class handling and state management * refactor(DataTable): improve column width handling and responsiveness; disable row selection * refactor(DataTable): enhance accessibility with row header support and improve column visibility handling * chore(DataTable): comments update * refactor(Table): add unwrapped prop for direct table rendering; adjust minWidth calculation for responsiveness * refactor(DataTable): simplify search handling by removing unnecessary trimming; adjust column width handling for better responsiveness * refactor(translation): remove redundant drag and drop UI text for clarity * refactor(parsers): change uiResources to a constant and streamline artifacts handling * chore: remove unused file, bump @librechat/client to 0.3.2; fix(SharedLinks): missing import; * refactor: change button variant from destructive to ghost for delete actions in SharedLinks and ArchivedChats components * refactor(DataTable): simplify aria-sort assignment for better readability * refactor(DataTable): update aria-label and ariaLabel to use indexed placeholder for localization * refactor(translation): update no data messages for consistency * Refactor code structure for improved readability and maintainability * chore: restore linting fixes * chore: restore linting fixes 2; refactor: remove unused translation keys * feat(tests): add unit tests for DataTable components and error handling - Implement tests for SelectionCheckbox and SkeletonRows components in DataTable. - Add tests for DataTableErrorBoundary to ensure proper error handling and UI rendering. - Create tests for DataTableSearch to validate search functionality and accessibility. - Update DialogTemplate tests to reflect hardcoded cancel text. - Remove redundant IntersectionObserver mock in SplitText tests. - Unmock react-i18next in Translation tests to validate actual i18n functionality. * refactor: Remove jest-environment-jsdom dependency from package.json; fix: reset package-lock * chore: revert lint fixes * chore: clean up package.json by removing unused devDependencies and redundant test scripts * chore: update package dependencies in package.json and package-lock.json - Added new devDependencies: @babel/core, @babel/preset-env, @babel/preset-react, @babel/preset-typescript, @tanstack/react-table, @tanstack/react-virtual, @testing-library/jest-dom, identity-obj-proxy, jest, jest-environment-jsdom, and lucide-react. - Updated existing devDependencies to their latest versions. - Added new module @asamuzakjp/css-color to package-lock.json with its dependencies. - Updated version of @babel/plugin-transform-destructuring and added @babel/plugin-transform-explicit-resource-management in package-lock.json. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react';
|
|
import {
|
|
useReactTable,
|
|
getCoreRowModel,
|
|
flexRender,
|
|
type SortingState,
|
|
type VisibilityState,
|
|
type ColumnDef,
|
|
type Row,
|
|
type Table as TTable,
|
|
} from '@tanstack/react-table';
|
|
import type { DataTableProps, ProcessedDataRow } from './DataTable.types';
|
|
import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents';
|
|
import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow } from '../Table';
|
|
import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
|
|
import { useMediaQuery, useLocalize } from '~/hooks';
|
|
import { DataTableSearch } from './DataTableSearch';
|
|
import { cn, logger } from '~/utils';
|
|
import { Label } from '../Label';
|
|
import { Spinner } from '~/svgs';
|
|
|
|
function DataTable<TData extends Record<string, unknown>, TValue>({
|
|
columns,
|
|
data,
|
|
className = '',
|
|
isLoading = false,
|
|
isFetching = false,
|
|
config,
|
|
filterValue = '',
|
|
onFilterChange,
|
|
defaultSort = [],
|
|
isFetchingNextPage = false,
|
|
hasNextPage = false,
|
|
fetchNextPage,
|
|
sorting,
|
|
onSortingChange,
|
|
customActionsRenderer,
|
|
}: DataTableProps<TData, TValue>) {
|
|
const localize = useLocalize();
|
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
|
|
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
const scrollTimeoutRef = useRef<number | null>(null);
|
|
const scrollRAFRef = useRef<number | null>(null);
|
|
|
|
const {
|
|
selection: { enableRowSelection = true, showCheckboxes = true } = {},
|
|
search: { enableSearch = true, debounce: debounceDelay = 300 } = {},
|
|
skeleton: { count: skeletonCount = 10 } = {},
|
|
virtualization: {
|
|
overscan = 10,
|
|
minRows = 50,
|
|
rowHeight = 56,
|
|
fastOverscanMultiplier = 4,
|
|
} = {},
|
|
} = config || {};
|
|
|
|
const virtualizationActive = data.length >= minRows;
|
|
|
|
// Dynamic overscan for fast scrolling - increases rendered rows during rapid scroll
|
|
const [dynamicOverscan, setDynamicOverscan] = useState(overscan);
|
|
const lastScrollTopRef = useRef(0);
|
|
const lastScrollTimeRef = useRef(performance.now());
|
|
const fastScrollTimeoutRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
setDynamicOverscan(overscan);
|
|
}, [overscan]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (fastScrollTimeoutRef.current) {
|
|
clearTimeout(fastScrollTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
|
|
const [searchTerm, setSearchTerm] = useState(filterValue);
|
|
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSort);
|
|
|
|
const selectedCount = Object.keys(optimizedRowSelection).length;
|
|
const isAllSelected = useMemo(
|
|
() => data.length > 0 && selectedCount === data.length,
|
|
[data.length, selectedCount],
|
|
);
|
|
const isIndeterminate = selectedCount > 0 && !isAllSelected;
|
|
|
|
const getRowId = useCallback(
|
|
(row: TData, index?: number) => String(row.id ?? `row-${index ?? 0}`),
|
|
[],
|
|
);
|
|
|
|
const selectedRows = useMemo(() => {
|
|
if (Object.keys(optimizedRowSelection).length === 0) return [];
|
|
|
|
const dataMap = new Map(data.map((item, index) => [getRowId(item, index), item]));
|
|
return Object.keys(optimizedRowSelection)
|
|
.map((id) => dataMap.get(id))
|
|
.filter(Boolean) as TData[];
|
|
}, [optimizedRowSelection, data, getRowId]);
|
|
|
|
const cleanupTimers = useCallback(() => {
|
|
if (scrollRAFRef.current) {
|
|
cancelAnimationFrame(scrollRAFRef.current);
|
|
scrollRAFRef.current = null;
|
|
}
|
|
if (scrollTimeoutRef.current) {
|
|
clearTimeout(scrollTimeoutRef.current);
|
|
scrollTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const debouncedTerm = useDebounced(searchTerm, debounceDelay);
|
|
const finalSorting = sorting ?? internalSorting;
|
|
|
|
// Mobile column visibility: columns with desktopOnly meta are hidden via CSS on mobile
|
|
// but remain in DOM for accessibility. CSS classes handle visual hiding.
|
|
const calculatedVisibility = useMemo(() => {
|
|
const newVisibility: VisibilityState = {};
|
|
|
|
columns.forEach((col) => {
|
|
const meta = (col as { meta?: { desktopOnly?: boolean } }).meta;
|
|
if (!meta?.desktopOnly) return;
|
|
|
|
const rawId =
|
|
(col as { id?: string | number; accessorKey?: string | number }).id ??
|
|
(col as { accessorKey?: string | number }).accessorKey;
|
|
|
|
if ((typeof rawId === 'string' || typeof rawId === 'number') && String(rawId).length > 0) {
|
|
newVisibility[String(rawId)] = true;
|
|
} else {
|
|
logger.warn(
|
|
'DataTable: A desktopOnly column is missing id/accessorKey; cannot control header visibility automatically.',
|
|
col,
|
|
);
|
|
}
|
|
});
|
|
return newVisibility;
|
|
}, [isSmallScreen, columns]);
|
|
|
|
useEffect(() => {
|
|
setColumnVisibility((prev) => ({ ...prev, ...calculatedVisibility }));
|
|
}, [calculatedVisibility]);
|
|
|
|
// Warn about missing row IDs - only once per component lifecycle
|
|
const hasWarnedAboutMissingIds = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (data.length > 0 && !hasWarnedAboutMissingIds.current) {
|
|
const missing = data.filter((item) => item.id === null || item.id === undefined);
|
|
if (missing.length > 0) {
|
|
logger.warn(
|
|
`DataTable Warning: ${missing.length} data rows are missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.`,
|
|
{ missingCount: missing.length, sample: missing.slice(0, 3) },
|
|
);
|
|
hasWarnedAboutMissingIds.current = true;
|
|
}
|
|
}
|
|
}, [data]);
|
|
|
|
const tableColumns = useMemo((): ColumnDef<TData, TValue>[] => {
|
|
if (!enableRowSelection || !showCheckboxes) {
|
|
return columns.map((col) => col as unknown as ColumnDef<TData, TValue>);
|
|
}
|
|
|
|
const selectColumn: ColumnDef<TData, TValue> = {
|
|
id: 'select',
|
|
enableResizing: false,
|
|
header: () => {
|
|
const extraCheckboxProps = (isIndeterminate ? { indeterminate: true } : {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
return (
|
|
<div
|
|
className="flex h-full items-center justify-center"
|
|
aria-label={localize('com_ui_select_all')}
|
|
>
|
|
<SelectionCheckbox
|
|
checked={isAllSelected}
|
|
onChange={(value) => {
|
|
if (isAllSelected || !value) {
|
|
setOptimizedRowSelection({});
|
|
} else {
|
|
const allSelection = data.reduce<Record<string, boolean>>((acc, item, index) => {
|
|
acc[getRowId(item, index)] = true;
|
|
return acc;
|
|
}, {});
|
|
setOptimizedRowSelection(allSelection);
|
|
}
|
|
}}
|
|
ariaLabel={localize('com_ui_select_all')}
|
|
{...extraCheckboxProps}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
cell: ({ row }) => {
|
|
const rowDescription = row.original.name
|
|
? `named ${row.original.name}`
|
|
: `at position ${row.index + 1}`;
|
|
return (
|
|
<div
|
|
className="flex h-full items-center justify-center"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={localize(`com_ui_select_row`, { 0: rowDescription })}
|
|
>
|
|
<SelectionCheckbox
|
|
checked={row.getIsSelected()}
|
|
onChange={(value) => row.toggleSelected(value)}
|
|
ariaLabel={localize(`com_ui_select_row`, { 0: rowDescription })}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
meta: {
|
|
className: 'max-w-[20px] flex-1',
|
|
},
|
|
};
|
|
|
|
return [selectColumn, ...columns.map((col) => col as unknown as ColumnDef<TData, TValue>)];
|
|
}, [
|
|
columns,
|
|
enableRowSelection,
|
|
showCheckboxes,
|
|
localize,
|
|
data,
|
|
getRowId,
|
|
isAllSelected,
|
|
isIndeterminate,
|
|
setOptimizedRowSelection,
|
|
]);
|
|
|
|
const sizedColumns = tableColumns;
|
|
|
|
const table = useReactTable<TData>({
|
|
data,
|
|
columns: sizedColumns,
|
|
getRowId: getRowId,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
enableRowSelection,
|
|
enableMultiRowSelection: true,
|
|
manualSorting: true,
|
|
manualFiltering: true,
|
|
state: {
|
|
sorting: finalSorting,
|
|
columnVisibility,
|
|
rowSelection: optimizedRowSelection,
|
|
},
|
|
onSortingChange: onSortingChange ?? setInternalSorting,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
onRowSelectionChange: setOptimizedRowSelection,
|
|
});
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
enabled: virtualizationActive,
|
|
count: data.length,
|
|
getScrollElement: () => tableContainerRef.current,
|
|
getItemKey: (index) => getRowId(data[index] as TData, index),
|
|
estimateSize: useCallback(() => rowHeight, [rowHeight]),
|
|
overscan: dynamicOverscan,
|
|
});
|
|
|
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
|
const totalSize = rowVirtualizer.getTotalSize();
|
|
const paddingTop = virtualRows[0]?.start ?? 0;
|
|
const paddingBottom =
|
|
virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0) : 0;
|
|
|
|
const { rows } = table.getRowModel();
|
|
const headerGroups = table.getHeaderGroups();
|
|
|
|
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
|
|
const shouldShowSearch = enableSearch && onFilterChange;
|
|
|
|
// Render table body based on loading state and virtualization
|
|
let tableBodyContent: React.ReactNode;
|
|
if (showSkeletons) {
|
|
tableBodyContent = (
|
|
<SkeletonRows
|
|
count={skeletonCount}
|
|
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
|
|
/>
|
|
);
|
|
} else if (virtualizationActive) {
|
|
tableBodyContent = (
|
|
<>
|
|
{paddingTop > 0 && (
|
|
<TableRow aria-hidden="true">
|
|
<TableCell
|
|
colSpan={tableColumns.length}
|
|
style={{ height: paddingTop, padding: 0, border: 0 }}
|
|
/>
|
|
</TableRow>
|
|
)}
|
|
{virtualRows.map((virtualRow) => {
|
|
const row = rows[virtualRow.index];
|
|
if (!row) return null;
|
|
|
|
return (
|
|
<MemoizedTableRow
|
|
key={virtualRow.key}
|
|
row={row as unknown as Row<Record<string, unknown>>}
|
|
virtualIndex={virtualRow.index}
|
|
selected={row.getIsSelected()}
|
|
style={{ height: rowHeight }}
|
|
/>
|
|
);
|
|
})}
|
|
{paddingBottom > 0 && (
|
|
<TableRow aria-hidden="true">
|
|
<TableCell
|
|
colSpan={tableColumns.length}
|
|
style={{ height: paddingBottom, padding: 0, border: 0 }}
|
|
/>
|
|
</TableRow>
|
|
)}
|
|
</>
|
|
);
|
|
} else {
|
|
tableBodyContent = rows.map((row) => (
|
|
<MemoizedTableRow
|
|
key={getRowId(row.original as TData, row.index)}
|
|
row={row as unknown as Row<Record<string, unknown>>}
|
|
virtualIndex={row.index}
|
|
selected={row.getIsSelected()}
|
|
style={{ height: rowHeight }}
|
|
/>
|
|
));
|
|
}
|
|
|
|
useEffect(() => {
|
|
setSearchTerm(filterValue);
|
|
}, [filterValue]);
|
|
|
|
useEffect(() => {
|
|
if (debouncedTerm !== filterValue && onFilterChange) {
|
|
onFilterChange(debouncedTerm);
|
|
setOptimizedRowSelection({});
|
|
}
|
|
}, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]);
|
|
|
|
// Recalculate virtual range when data or state changes
|
|
useEffect(() => {
|
|
if (!virtualizationActive) return;
|
|
rowVirtualizer.calculateRange();
|
|
}, [data.length, finalSorting, columnVisibility, virtualizationActive, rowVirtualizer]);
|
|
|
|
// Recalculate when container is resized
|
|
useEffect(() => {
|
|
if (!virtualizationActive) return;
|
|
const container = tableContainerRef.current;
|
|
if (!container) return;
|
|
const ro = new ResizeObserver(() => {
|
|
rowVirtualizer.calculateRange();
|
|
});
|
|
ro.observe(container);
|
|
return () => ro.disconnect();
|
|
}, [virtualizationActive, rowVirtualizer]);
|
|
|
|
const handleScroll = useMemo(() => {
|
|
let rafId: number | null = null;
|
|
let timeoutId: number | null = null;
|
|
|
|
return () => {
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
|
|
rafId = requestAnimationFrame(() => {
|
|
const container = tableContainerRef.current;
|
|
if (container) {
|
|
const now = performance.now();
|
|
const delta = Math.abs(container.scrollTop - lastScrollTopRef.current);
|
|
const dt = now - lastScrollTimeRef.current;
|
|
if (dt > 0) {
|
|
const velocity = delta / dt;
|
|
// Increase overscan during fast scrolling for smoother experience
|
|
if (velocity > 2 && virtualizationActive && dynamicOverscan === overscan) {
|
|
if (fastScrollTimeoutRef.current) {
|
|
window.clearTimeout(fastScrollTimeoutRef.current);
|
|
}
|
|
setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8));
|
|
fastScrollTimeoutRef.current = window.setTimeout(() => {
|
|
setDynamicOverscan((current) => (current !== overscan ? overscan : current));
|
|
}, 160);
|
|
}
|
|
}
|
|
lastScrollTopRef.current = container.scrollTop;
|
|
lastScrollTimeRef.current = now;
|
|
}
|
|
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
|
|
// Trigger infinite scroll pagination
|
|
timeoutId = window.setTimeout(() => {
|
|
const loaderContainer = tableContainerRef.current;
|
|
if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = loaderContainer;
|
|
if (scrollTop + clientHeight >= scrollHeight - 200) {
|
|
fetchNextPage().finally();
|
|
}
|
|
}, 100);
|
|
});
|
|
};
|
|
}, [
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
overscan,
|
|
fastOverscanMultiplier,
|
|
virtualizationActive,
|
|
dynamicOverscan,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const scrollElement = tableContainerRef.current;
|
|
if (!scrollElement) return;
|
|
|
|
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
|
return () => {
|
|
scrollElement.removeEventListener('scroll', handleScroll);
|
|
cleanupTimers();
|
|
};
|
|
}, [handleScroll, cleanupTimers]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-background',
|
|
'h-[calc(100vh-8rem)] max-h-[80vh]',
|
|
className,
|
|
)}
|
|
role="region"
|
|
aria-label={localize('com_ui_data_table')}
|
|
>
|
|
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light md:gap-3">
|
|
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
|
|
{customActionsRenderer &&
|
|
customActionsRenderer({
|
|
selectedCount,
|
|
selectedRows,
|
|
table: table as unknown as TTable<ProcessedDataRow<TData>>,
|
|
})}
|
|
</div>
|
|
<div
|
|
ref={tableContainerRef}
|
|
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
|
|
style={
|
|
{
|
|
WebkitOverflowScrolling: 'touch',
|
|
overscrollBehavior: 'contain',
|
|
} as React.CSSProperties
|
|
}
|
|
role="region"
|
|
aria-label={localize('com_ui_data_table_scroll_area')}
|
|
aria-describedby={showSkeletons ? 'loading-status' : undefined}
|
|
>
|
|
<Table
|
|
role="table"
|
|
aria-label={localize('com_ui_data_table')}
|
|
aria-rowcount={data.length}
|
|
className="table-auto"
|
|
unwrapped={true}
|
|
>
|
|
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
|
|
{headerGroups.map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => {
|
|
const isDesktopOnly =
|
|
(header.column.columnDef.meta as { desktopOnly?: boolean } | undefined)
|
|
?.desktopOnly ?? false;
|
|
|
|
if (!header.column.getIsVisible()) {
|
|
return null;
|
|
}
|
|
|
|
const isSelectHeader = header.id === 'select';
|
|
const meta = header.column.columnDef.meta as { className?: string } | undefined;
|
|
const canSort = header.column.getCanSort();
|
|
|
|
let sortAriaLabel: string | undefined;
|
|
if (canSort) {
|
|
const sortState = header.column.getIsSorted();
|
|
let sortStateLabel = 'sortable';
|
|
if (sortState === 'asc') {
|
|
sortStateLabel = 'ascending';
|
|
} else if (sortState === 'desc') {
|
|
sortStateLabel = 'descending';
|
|
}
|
|
|
|
const headerLabel =
|
|
typeof header.column.columnDef.header === 'string'
|
|
? header.column.columnDef.header
|
|
: header.column.id;
|
|
|
|
sortAriaLabel = `${headerLabel ?? ''} column, ${sortStateLabel}`;
|
|
}
|
|
|
|
const handleSortingKeyDown = (e: React.KeyboardEvent) => {
|
|
if (canSort && (e.key === 'Enter' || e.key === ' ')) {
|
|
e.preventDefault();
|
|
header.column.toggleSorting();
|
|
}
|
|
};
|
|
|
|
const metaWidth = (header.column.columnDef.meta as { width?: number } | undefined)
|
|
?.width;
|
|
const widthStyle = isSelectHeader
|
|
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
|
|
: metaWidth && metaWidth >= 1 && metaWidth <= 100
|
|
? {
|
|
width: `${metaWidth}%`,
|
|
maxWidth: `${metaWidth}%`,
|
|
minWidth: `${metaWidth}%`,
|
|
}
|
|
: {};
|
|
return (
|
|
<TableHead
|
|
key={header.id}
|
|
scope="col"
|
|
className={cn(
|
|
'border-b border-border-light px-2 py-2 md:px-3 md:py-2',
|
|
isSelectHeader && 'px-0 text-center',
|
|
canSort && 'cursor-pointer hover:bg-surface-tertiary',
|
|
meta?.className,
|
|
header.column.getIsResizing() && 'bg-surface-tertiary/60',
|
|
isDesktopOnly && 'hidden md:table-cell',
|
|
)}
|
|
style={widthStyle}
|
|
onClick={header.column.getToggleSortingHandler()}
|
|
onKeyDown={handleSortingKeyDown}
|
|
role={canSort ? 'button' : undefined}
|
|
tabIndex={canSort ? 0 : undefined}
|
|
aria-label={sortAriaLabel}
|
|
aria-sort={
|
|
header.column.getIsSorted() === 'asc'
|
|
? 'ascending'
|
|
: header.column.getIsSorted() === 'desc'
|
|
? 'descending'
|
|
: undefined
|
|
}
|
|
>
|
|
{isSelectHeader ? (
|
|
flexRender(header.column.columnDef.header, header.getContext())
|
|
) : (
|
|
<div className="flex items-center gap-1 md:gap-2">
|
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
{canSort && (
|
|
<span className="text-text-primary" aria-hidden="true">
|
|
{{
|
|
asc: <ArrowUp className="size-4 text-text-primary" />,
|
|
desc: <ArrowDown className="size-4 text-text-primary" />,
|
|
}[header.column.getIsSorted() as string] ?? (
|
|
<ArrowDownUp className="size-4 text-text-primary" />
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
|
|
<TableBody>
|
|
{tableBodyContent}
|
|
{isFetchingNextPage && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={tableColumns.length}
|
|
className="p-4 text-center"
|
|
id="loading-status"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Spinner className="h-5 w-5" aria-hidden="true" />
|
|
<span className="sr-only">{localize('com_ui_loading_more_data')}</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{!isLoading && !showSkeletons && rows.length === 0 && (
|
|
<div
|
|
className="flex flex-col items-center justify-center py-12"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<Label className="text-center text-text-secondary">
|
|
{searchTerm ? localize('com_ui_no_search_results') : localize('com_ui_no_data')}
|
|
</Label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default DataTable;
|