mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-20 09:16:13 +01:00
📂 fix: My Files Modal Accessibility Improvements (#10844)
* feat: show column sort direction in all headers for my files datatable * fix: refactor SortFilterHeader to use DropdownPopup so that keyboard nav and portaling actually work * feat: visually indicate when a column filter is active * chore: remove debug visuals * chore: fix types and import order * chore: add missing subItems prop to MenuItemProps interface * feat: add arrow indicator for name column * fix: page counter no longer shows 1/0 when no results * feat: keep my files datatable size consistent to avoid issues with sizing of dropdown filter menus which made it difficult to see options * fix: refactor filter cols button in my files datatable to use ariakit dropdown so keyboard nav works * feat: better datatable column spacing following tanstack docs * chore: ESlint complaints * fix: localize string literals * fix: move localize hook call inside the function components * feat: add tooltip label for select all * feat: better styling on floating label for file filter input * feat: focus outline on search input * feat: add search icon * feat: add aria-sort props to header sort buttons * feat: better screen reader labels to include information visually conveyed by filter and sort icons * feat: add descriptive tooltips for headers for better accessibility for cognitive impairments * chore: import orders * feat: add more aria states for better feedback of filtered and sorted columns * chore: add translation key
This commit is contained in:
parent
70e854eb59
commit
abcf606328
8 changed files with 336 additions and 177 deletions
|
|
@ -1,13 +1,13 @@
|
|||
import { useState } from 'react';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
flexRender,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import type {
|
||||
ColumnDef,
|
||||
|
|
@ -17,26 +17,22 @@ import type {
|
|||
} from '@tanstack/react-table';
|
||||
import { FileContext } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
Button,
|
||||
Spinner,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
TrashIcon,
|
||||
Spinner,
|
||||
TableHeader,
|
||||
useMediaQuery,
|
||||
} from '@librechat/client';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { AugmentedColumnDef } from '~/common';
|
||||
import { ColumnVisibilityDropdown } from './ColumnVisibilityDropdown';
|
||||
import { useDeleteFilesFromTable } from '~/hooks/Files';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -45,7 +41,7 @@ interface DataTableProps<TData, TValue> {
|
|||
data: TData[];
|
||||
}
|
||||
|
||||
const contextMap = {
|
||||
const contextMap: Record<string, TranslationKeys> = {
|
||||
[FileContext.filename]: 'com_ui_name',
|
||||
[FileContext.updatedAt]: 'com_ui_date',
|
||||
[FileContext.filterSource]: 'com_ui_storage',
|
||||
|
|
@ -75,6 +71,11 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
defaultColumn: {
|
||||
minSize: 0,
|
||||
size: Number.MAX_SAFE_INTEGER,
|
||||
maxSize: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
|
|
@ -115,62 +116,40 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-text-secondary" />
|
||||
<Input
|
||||
id="files-filter"
|
||||
placeholder=" "
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
className="peer w-full text-sm"
|
||||
className="peer w-full pl-10 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={localize('com_files_filter_input')}
|
||||
/>
|
||||
<label className="absolute left-2 top-1/2 -translate-y-1/2 text-sm text-text-primary transition-all duration-200 peer-focus:-top-2 peer-focus:text-xs peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-top-2 peer-[:not(:placeholder-shown)]:text-xs">
|
||||
<label
|
||||
htmlFor="files-filter"
|
||||
className="pointer-events-none absolute left-10 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
|
||||
>
|
||||
{localize('com_files_filter')}
|
||||
</label>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={localize('com_files_filter_by')}
|
||||
className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}
|
||||
>
|
||||
<ListFilter className="size-3.5 sm:size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-[300px] overflow-y-auto dark:border-gray-700 dark:bg-gray-850"
|
||||
>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="cursor-pointer text-sm capitalize dark:text-white dark:hover:bg-gray-800"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(Boolean(value))}
|
||||
>
|
||||
{localize(contextMap[column.id])}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="relative focus-within:z-[100]">
|
||||
<ColumnVisibilityDropdown
|
||||
table={table}
|
||||
contextMap={contextMap}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative grid h-full max-h-[calc(100vh-20rem)] w-full flex-1 overflow-hidden overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
|
||||
<div className="relative grid h-full max-h-[calc(100vh-20rem)] min-h-[calc(100vh-20rem)] w-full flex-1 overflow-hidden overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
|
||||
<Table className="w-full min-w-[300px] border-separate border-spacing-0">
|
||||
<TableHeader className="sticky top-0 z-50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const style: Style = {};
|
||||
if (index === 0 && header.id === 'select') {
|
||||
style.width = '36px';
|
||||
style.minWidth = '36px';
|
||||
} else if (header.id === 'filename') {
|
||||
style.width = isSmallScreen ? '60%' : '40%';
|
||||
} else {
|
||||
style.width = isSmallScreen ? '20%' : '15%';
|
||||
}
|
||||
{headerGroup.headers.map((header, _index) => {
|
||||
const size = header.getSize();
|
||||
const style: Style = {
|
||||
width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size,
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
|
|
@ -195,17 +174,11 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
data-state={row.getIsSelected() && 'selected'}
|
||||
className="border-b border-border-light transition-colors hover:bg-surface-secondary [tr:last-child_&]:border-b-0"
|
||||
>
|
||||
{row.getVisibleCells().map((cell, index) => {
|
||||
const maxWidth =
|
||||
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta?.size ??
|
||||
'auto';
|
||||
|
||||
const style: Style = {};
|
||||
if (cell.column.id === 'filename') {
|
||||
style.maxWidth = maxWidth;
|
||||
} else if (index === 0) {
|
||||
style.maxWidth = '20px';
|
||||
}
|
||||
{row.getVisibleCells().map((cell, _index) => {
|
||||
const size = cell.column.getSize();
|
||||
const style: Style = {
|
||||
width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size,
|
||||
};
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
|
|
@ -251,7 +224,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
<span className="hidden sm:inline">{localize('com_ui_page')}</span>
|
||||
<span>{table.getState().pagination.pageIndex + 1}</span>
|
||||
<span>/</span>
|
||||
<span>{table.getPageCount()}</span>
|
||||
<span>{Math.max(table.getPageCount(), 1)}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="select-none"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue