LibreChat/packages/client/src/components/DataTable.tsx
Dustin Healy e6ce861c22
🛗 fix: Address Accessibility Issues - Axe Rating: Serious (#10521)
* feat: add light/dark differentiation on text color for login footer links for more accessible contrast in light mode

* feat: add darker color focus ring on ThemeSelector in light mode for more accessible contrast

* feat: increase contrast on text color for rendered error messages in light and dark mode so that they pass the 4.5:1 accessibility contrast threshold against their backgrounds

* feat: add more accessible color vars to style.css for better contrast against light/dark backgrounds

* feat: un-nest DropdownMenu from ListCard and make them siblings instead for better accessibility

* feat: tweak --border-heavy in light mode so that it uses --gray-410 rather than --gray-400 so that the contrast ratio threshold is hit for accessibility

* feat: switch email and password input border to border-heavy for more accessible contrast on Login page

* fix: add proper focus ring for Action menu button in Prompts Sidenav

* fix: align light and dark focus rings with surrounding elements on preview/edit menu dropdown button in Prompt Card

* fix: remove aria-hidden on parent div with focusable child element according to accessibility guidelines

* fix: add missing aria-readonly false property that should have been in previous accessibility PR

* feat: add horizontal padding on rowRenderer's CellMeasurer div so that focus ring on rows doesnt clip behind virtualized table borders side-to-side

(still need to figure out vertical clipping on final row / a better solution to be able to get overflows to work properly within the virtualized table)

* feat: remove render prop override so that Share and Delete Buttons in Conversation dropdown can be pressed with Enter keystroke

* fix: undo additional colors and changes to --surface-hover

the initial changes came from a misunderstanding of contrast threshold requirements for hover effect accessibility

* feat: better layout for non-nested prompt card / action menu combination

* fix: add proper focus restoration behavior for Preview modal on close

* fix: undo change to --border-heavy in light mode

* fix: set borders for login input boxes back to light

* feat: add announcement for state change when link copied to clipboard in conversation share modal

* feat: add announcement to Refresh Link button

* feat: add announcement for archiving chats

* feat: make date sections in conversation history list <h2> rather than generic <div> for improved screen reader support

* feat: ensure Share Link modal is accessible at high zoom percentage and low viewport width / height requirements by adding max height and overflow attributes to allow scrolling

* feat: bold toast text so that it hits font size accessibility threshold (above 14 px when bolded - change makes text 16 px bold) so that the more disruptive contrast change of the toast background color is no longer necessary.

The background color would need to achieve a 4.5:1 contrast ratio, which would significantly affect the established aesthetic of the current toast system if achieved.

* fix: do not render side nav when it is hidden to avoid keyboard navigation with screen reader

* fix: add side nav button state change announcements and don't render components that were previosuly reachable via keyboard navigation while in the side nav

* feat: add tooltip anchor for Model Select

* fix: only hide the model selector, export, and temp chat buttons when in mobile view and the sidenav is expanded

* feat: add aria-haspopup support for MenuItems and add aria-haspopup: 'dialog' for Share and Delete buttons in ConvoOptions

* feat: add label for DataTable search so that it does not rely on placeholder attribute for function identification

* feat: make X buttons on dialogs 24x24px to achieve AA compliance

* feat: add announcements for the search bar for model selector

* feat: persistent label for DataTable

* feat: make filter files text contrast compliant

* feat: add non-color visual indicator to AudioRecorder listening state

* feat: add aria-expanded attribute to tool call dropdown for screen reader

* feat: add high contrast and rounded outlines for focus indicators on Run Code and Copy Code buttons for code blocks

* fix: change Button to anchor tag in Shared Links component when linking to original conversation

* fix: allow overflow in datatable cells so that focus indicators dont get cut off

* feat: round out focus outline for link name in SharedLinks modal

* feat: add aria-controls and aria-haspopup: "dialog" to SharedLinks delete button and modal

* feat: add aria-controls for dropdown menu items on ConvoOptions for share and delete modals

* feat: add trigger ref to 2FA button and modal in settings menu so focus returns to button on modal close

* feat: add refs so that open sidebar and close sidebar buttons transfer focus to one another

* chore: formatting

* feat: make sure settings modal is accessible at 200% zoom for screen size 1366x768 viewport

* feat: round out focus outline for link names in archived chats modal

* feat: add result announcements for screen reader in DataTable search

* feat: simplify layout for checkbox / api key components for better accessibility

* feat: return focus to chat input on prompt variables modal close

* feat: add persistent labels to TextareaAutosize Inputs in Variable form

* feat: tighten max width so side scrolling not necessary at 400% zoom for VariableForm modal

* feat: add persistent labels to prompt management page

* feat: announce results found for search bars in prompts page and improve them in datatable

* feat: de-nest DashGroupItem buttons in Prompts page to allow better navigation and comply with accessibility standard

* feat: add heading for new prompt creation page for screen readers

* feat: remove non-compliant description truncation for small screen sizes by making labels static on small enough viewport width

* feat: add mobile view sidebar for prompts page

* feat: add bolded text on select for AdvancedSwitch so that there is a visual indicator of selection and it does not rely solely on color as an indication of state

* feat: add persistent labels to ModelSelector search inputs

* feat: align aria-label with visual label for speech recognition users

* feat: make MemoryCreateDialog accessible at 400% zoom (introduce max viewport height attr and make scrollable)

* feat: add persistent label to Filter input for DataTable in file attach sidebar menu

* feat: add persistent label for bookmark filter input in bookmarks sidebar menu

* feat: add alert for screen readers for invalid inputs when editting bookmarks

* feat: bold font in BookmarkForm error readout to pass contrast compliance thresholds for 14pt text

* feat: align aria-label with visual label for BookmarkForm Ttile input

* feat: add 400% zoom support for ALL modals utilizing OriginalDialog to prevent clipping

* feat: remove state change on aria label and give consistent labelling for button, offload state change notification to the announcement div and make more assertive

* feat: add aria-labels which convey that the buttons are sortable (divergence from visual text because iconography is used to signify sort functionality)

* feat: add supplemental visuals to indicate link is clickable other than color in SharedLinks

* feat: increase saturation to hit contrast threshold minimums on Link color in SharedLinks

* feat: stop DataTable from disappearing at 400% zoom in SharedLinks

* feat: increase contrast to hit contrast threshold minimums on Animated Search Input visual indicators

* feat: add aria-label for AnimatedSearchInput (doesn't require explicit labelling because of Search icon)

* fix: stop long example variable declaration from clipping at high zoom in variables info

* feat: add aria-label to bettter describe sort button functionality for vision impaired users

* chore: remove unused translation key

* chore: address ESLint comments

* fix: modify test to account for new alert on theme toggle switch for login page

* chore: interpolate translation key
2025-12-04 14:34:45 -05:00

520 lines
16 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Row,
ColumnDef,
flexRender,
SortingState,
useReactTable,
getCoreRowModel,
VisibilityState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
} from '@tanstack/react-table';
import type { Table as TTable } from '@tanstack/react-table';
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table';
import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks';
import AnimatedSearchInput from './AnimatedSearchInput';
import { TrashIcon, Spinner } from '~/svgs';
import { Skeleton } from './Skeleton';
import { Checkbox } from './Checkbox';
import { Button } from './Button';
import { cn } from '~/utils';
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
meta?: {
size?: string | number;
mobileSize?: string | number;
minWidth?: string | number;
};
};
const SelectionCheckbox = memo(
({
checked,
onChange,
ariaLabel,
}: {
checked: boolean;
onChange: (value: boolean) => void;
ariaLabel: string;
}) => (
<div
role="button"
tabIndex={0}
onKeyDown={(e) => e.stopPropagation()}
className="flex h-full w-[30px] items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
</div>
),
);
SelectionCheckbox.displayName = 'SelectionCheckbox';
interface DataTableProps<TData, TValue> {
columns: TableColumn<TData, TValue>[];
data: TData[];
onDelete?: (selectedRows: TData[]) => Promise<void>;
filterColumn?: string;
defaultSort?: SortingState;
columnVisibilityMap?: Record<string, string>;
className?: string;
pageSize?: number;
isFetchingNextPage?: boolean;
hasNextPage?: boolean;
fetchNextPage?: (options?: unknown) => Promise<unknown>;
enableRowSelection?: boolean;
showCheckboxes?: boolean;
onFilterChange?: (value: string) => void;
filterValue?: string;
isLoading?: boolean;
enableSearch?: boolean;
}
const TableRowComponent = <TData, TValue>({
row,
isSmallScreen,
onSelectionChange,
index,
isSearching,
}: {
row: Row<TData>;
isSmallScreen: boolean;
onSelectionChange?: (rowId: string, selected: boolean) => void;
index: number;
isSearching: boolean;
}) => {
const handleSelection = useCallback(
(value: boolean) => {
row.toggleSelected(value);
onSelectionChange?.(row.id, value);
},
[row, onSelectionChange],
);
return (
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary"
style={{
animationDelay: `${index * 20}ms`,
transform: `translateY(${isSearching ? '4px' : '0'})`,
opacity: isSearching ? 0.5 : 1,
}}
>
{row.getVisibleCells().map((cell) => {
if (cell.column.id === 'select') {
return (
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={handleSelection}
ariaLabel="Select row"
/>
</TableCell>
);
}
if (cell.column.id === 'title') {
return (
<TableHead
key={cell.id}
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
style={getColumnStyle(
cell.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
scope="row"
>
<div className="overflow-visible text-ellipsis">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableHead>
);
}
return (
<TableCell
key={cell.id}
className="w-0 max-w-0 overflow-visible px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
style={getColumnStyle(
cell.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
>
<div className="overflow-visible text-ellipsis">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
);
})}
</TableRow>
);
};
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
function getColumnStyle<TData, TValue>(
column: TableColumn<TData, TValue>,
isSmallScreen: boolean,
): React.CSSProperties {
return {
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
minWidth: column.meta?.minWidth,
maxWidth: column.meta?.size,
};
}
const DeleteButton = memo(
({
onDelete,
isDeleting,
disabled,
isSmallScreen,
ariaLabel,
}: {
onDelete?: () => Promise<void>;
isDeleting: boolean;
disabled: boolean;
isSmallScreen: boolean;
ariaLabel: string;
}) => {
if (!onDelete) {
return null;
}
return (
<Button
variant="outline"
onClick={onDelete}
disabled={disabled}
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
aria-label={ariaLabel}
>
{isDeleting ? (
<Spinner className="size-4" />
) : (
<>
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
{!isSmallScreen && <span className="ml-2">Delete</span>}
</>
)}
</Button>
);
},
);
export default function DataTable<TData, TValue>({
columns,
data,
onDelete,
filterColumn,
defaultSort = [],
className = '',
isFetchingNextPage = false,
hasNextPage = false,
fetchNextPage,
enableRowSelection = true,
showCheckboxes = true,
onFilterChange,
filterValue,
isLoading,
enableSearch = true,
}: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const tableContainerRef = useRef<HTMLDivElement>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [sorting, setSorting] = useState<SortingState>(defaultSort);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
const [isSearching, setIsSearching] = useState(false);
const [searchResultsAnnouncement, setSearchResultsAnnouncement] = useState('');
const tableColumns = useMemo(() => {
if (!enableRowSelection || !showCheckboxes) {
return columns;
}
const selectColumn = {
id: 'select',
header: ({ table }: { table: TTable<TData> }) => (
<div className="flex h-full w-[30px] items-center justify-center">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
aria-label="Select all"
/>
</div>
),
cell: ({ row }: { row: Row<TData> }) => (
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={(value) => row.toggleSelected(value)}
ariaLabel="Select row"
/>
),
meta: { size: '50px' },
};
return [selectColumn, ...columns];
}, [columns, enableRowSelection, showCheckboxes]);
const table = useReactTable({
data,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection,
enableMultiRowSelection: true,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: useCallback(() => 48, []),
overscan: 10,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
useEffect(() => {
const scrollElement = tableContainerRef.current;
if (!scrollElement) {
return;
}
const handleScroll = async () => {
if (!hasNextPage || isFetchingNextPage) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
try {
// Safely fetch next page without breaking if lastPage is undefined
await fetchNextPage?.();
} catch (error) {
console.error('Unable to fetch next page:', error);
}
}
};
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
return () => scrollElement.removeEventListener('scroll', handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
useEffect(() => {
setIsSearching(true);
const timeout = setTimeout(() => {
onFilterChange?.(searchTerm);
setIsSearching(false);
}, 300);
return () => clearTimeout(timeout);
}, [searchTerm, onFilterChange]);
useEffect(() => {
if (!searchTerm.trim() || isSearching) {
setSearchResultsAnnouncement('');
return;
}
const resultCount = rows.length;
const announcement =
resultCount === 1
? localize('com_ui_result_found' as TranslationKeys, {
count: resultCount,
})
: localize('com_ui_results_found' as TranslationKeys, {
count: resultCount,
});
const timeout = setTimeout(() => {
setSearchResultsAnnouncement(announcement);
}, 300);
return () => clearTimeout(timeout);
}, [rows.length, searchTerm, isSearching, localize]);
const handleDelete = useCallback(async () => {
if (!onDelete) {
return;
}
setIsDeleting(true);
try {
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
await onDelete(itemsToDelete);
setRowSelection({});
} finally {
setIsDeleting(false);
}
}, [onDelete, table]);
const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170;
const skeletons = Array.from({ length: 13 }, (_, index) => {
const randomWidth = getRandomWidth();
const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0;
return (
<TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light">
{tableColumns.map((column, columnIndex) => {
const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen);
const isFirstDataColumn = columnIndex === firstDataColumnIndex;
return (
<TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}>
<Skeleton
className="h-6"
style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }}
/>
</TableCell>
);
})}
</TableRow>
);
});
return (
<div className={cn('flex h-full flex-col gap-4', className)}>
<div aria-live="assertive" aria-atomic="true" className="sr-only">
{searchResultsAnnouncement}
</div>
{/* Table controls */}
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
{enableRowSelection && showCheckboxes && (
<DeleteButton
onDelete={handleDelete}
isDeleting={isDeleting}
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
isSmallScreen={isSmallScreen}
ariaLabel={localize('com_ui_delete_selected_items')}
/>
)}
{filterColumn !== undefined && table.getColumn(filterColumn) && enableSearch && (
<div className="relative flex-1">
<AnimatedSearchInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
isSearching={isSearching}
placeholder="Search..."
/>
</div>
)}
</div>
{/* Virtualized table */}
<div
ref={tableContainerRef}
className={cn(
'relative min-h-0 max-w-full flex-1 overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
'transition-all duration-300 ease-out',
isSearching && 'bg-surface-secondary/50',
className,
)}
>
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b border-border-light">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
style={getColumnStyle(
header.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
scope="col"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{isLoading && skeletons}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<MemoizedTableRow
key={row.id}
row={row}
isSmallScreen={isSmallScreen}
index={virtualRow.index}
isSearching={isSearching}
/>
);
})}
{!virtualRows.length && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-4 text-center">
No data available
</TableCell>
</TableRow>
)}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
{/* Loading indicator */}
{(isFetchingNextPage || hasNextPage) && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-4">
<div className="flex h-full items-center justify-center">
{isFetchingNextPage ? (
<Spinner className="size-4" />
) : (
hasNextPage && <div className="h-6" />
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}