mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +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
|
|
@ -16,6 +16,7 @@ export interface MenuItemProps {
|
||||||
hideOnClick?: boolean;
|
hideOnClick?: boolean;
|
||||||
dialog?: React.ReactElement;
|
dialog?: React.ReactElement;
|
||||||
ref?: React.Ref<any>;
|
ref?: React.Ref<any>;
|
||||||
|
className?: string;
|
||||||
render?:
|
render?:
|
||||||
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>
|
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>
|
||||||
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useState, useId, useMemo } from 'react';
|
||||||
|
import { ListFilter } from 'lucide-react';
|
||||||
|
import * as Menu from '@ariakit/react/menu';
|
||||||
|
import { useReactTable } from '@tanstack/react-table';
|
||||||
|
import { DropdownPopup } from '@librechat/client';
|
||||||
|
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface ColumnVisibilityDropdownProps<TData> {
|
||||||
|
table: ReturnType<typeof useReactTable<TData>>;
|
||||||
|
contextMap: Record<string, TranslationKeys>;
|
||||||
|
isSmallScreen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnVisibilityDropdown<TData>({
|
||||||
|
table,
|
||||||
|
contextMap,
|
||||||
|
isSmallScreen,
|
||||||
|
}: ColumnVisibilityDropdownProps<TData>) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const menuId = useId();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const dropdownItems = useMemo(
|
||||||
|
() =>
|
||||||
|
table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => ({
|
||||||
|
label: localize(contextMap[column.id]),
|
||||||
|
onClick: () => column.toggleVisibility(!column.getIsVisible()),
|
||||||
|
icon: column.getIsVisible() ? '✓' : '',
|
||||||
|
id: column.id,
|
||||||
|
})),
|
||||||
|
[table, contextMap, localize],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownPopup
|
||||||
|
portal={false}
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
trigger={
|
||||||
|
<Menu.MenuButton
|
||||||
|
aria-label={localize('com_files_filter_by')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
isSmallScreen && 'px-2 py-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ListFilter className="size-3.5 sm:size-4" aria-hidden="true" />
|
||||||
|
</Menu.MenuButton>
|
||||||
|
}
|
||||||
|
items={dropdownItems}
|
||||||
|
menuId={menuId}
|
||||||
|
className="z-50 max-h-[300px] overflow-y-auto"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
import { Database } from 'lucide-react';
|
import { ArrowUpDown, ArrowUp, ArrowDown, Database } from 'lucide-react';
|
||||||
import { FileSources, FileContext } from 'librechat-data-provider';
|
import { FileSources, FileContext } from 'librechat-data-provider';
|
||||||
import { Checkbox, OpenAIMinimalIcon, AzureMinimalIcon, useMediaQuery } from '@librechat/client';
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
useMediaQuery,
|
||||||
|
TooltipAnchor,
|
||||||
|
AzureMinimalIcon,
|
||||||
|
OpenAIMinimalIcon,
|
||||||
|
} from '@librechat/client';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import type { TFile } from 'librechat-data-provider';
|
import type { TFile } from 'librechat-data-provider';
|
||||||
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
||||||
|
|
@ -22,25 +29,35 @@ const contextMap: Record<any, TranslationKeys> = {
|
||||||
export const columns: ColumnDef<TFile>[] = [
|
export const columns: ColumnDef<TFile>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
|
size: 40,
|
||||||
header: ({ table }) => {
|
header: ({ table }) => {
|
||||||
|
const localize = useLocalize();
|
||||||
return (
|
return (
|
||||||
<Checkbox
|
<TooltipAnchor
|
||||||
checked={
|
description={localize('com_ui_select_all')}
|
||||||
table.getIsAllPageRowsSelected() ||
|
side="top"
|
||||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
role="checkbox"
|
||||||
|
render={
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label={localize('com_ui_select_all')}
|
||||||
|
className="flex"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
aria-label="Select all"
|
|
||||||
className="flex"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
const localize = useLocalize();
|
||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
aria-label="Select row"
|
aria-label={localize('com_ui_select_row')}
|
||||||
className="flex"
|
className="flex"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -55,7 +72,35 @@ export const columns: ColumnDef<TFile>[] = [
|
||||||
accessorKey: 'filename',
|
accessorKey: 'filename',
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
return <SortFilterHeader column={column} title={localize('com_ui_name')} aria-hidden="true" />;
|
const sortState = column.getIsSorted();
|
||||||
|
let SortIcon = ArrowUpDown;
|
||||||
|
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||||
|
if (sortState === 'desc') {
|
||||||
|
SortIcon = ArrowDown;
|
||||||
|
ariaSort = 'descending';
|
||||||
|
} else if (sortState === 'asc') {
|
||||||
|
SortIcon = ArrowUp;
|
||||||
|
ariaSort = 'ascending';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_name_sort')}
|
||||||
|
side="top"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
aria-sort={ariaSort}
|
||||||
|
aria-label={localize('com_ui_name_sort')} aria-hidden="true"
|
||||||
|
aria-current={sortState ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
{localize('com_ui_name')}
|
||||||
|
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const file = row.original;
|
const file = row.original;
|
||||||
|
|
@ -85,7 +130,35 @@ export const columns: ColumnDef<TFile>[] = [
|
||||||
accessorKey: 'updatedAt',
|
accessorKey: 'updatedAt',
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
return <SortFilterHeader column={column} title={localize('com_ui_date')} aria-hidden="true" />;
|
const sortState = column.getIsSorted();
|
||||||
|
let SortIcon = ArrowUpDown;
|
||||||
|
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||||
|
if (sortState === 'desc') {
|
||||||
|
SortIcon = ArrowDown;
|
||||||
|
ariaSort = 'descending';
|
||||||
|
} else if (sortState === 'asc') {
|
||||||
|
SortIcon = ArrowUp;
|
||||||
|
ariaSort = 'ascending';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_date_sort')}
|
||||||
|
side="top"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||||
|
aria-sort={ariaSort}
|
||||||
|
aria-label={localize('com_ui_date_sort')} aria-hidden="true"
|
||||||
|
aria-current={sortState ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
{localize('com_ui_date')}
|
||||||
|
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
@ -100,6 +173,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||||
<SortFilterHeader
|
<SortFilterHeader
|
||||||
column={column}
|
column={column}
|
||||||
title={localize('com_ui_storage')}
|
title={localize('com_ui_storage')}
|
||||||
|
ariaLabel={localize('com_ui_storage_filter_sort')}
|
||||||
filters={{
|
filters={{
|
||||||
Storage: Object.values(FileSources).filter(
|
Storage: Object.values(FileSources).filter(
|
||||||
(value) =>
|
(value) =>
|
||||||
|
|
@ -150,6 +224,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||||
<SortFilterHeader
|
<SortFilterHeader
|
||||||
column={column}
|
column={column}
|
||||||
title={localize('com_ui_context')}
|
title={localize('com_ui_context')}
|
||||||
|
ariaLabel={localize('com_ui_context_filter_sort')}
|
||||||
filters={{
|
filters={{
|
||||||
Context: Object.values(FileContext).filter(
|
Context: Object.values(FileContext).filter(
|
||||||
(value) => value === FileContext[value ?? ''],
|
(value) => value === FileContext[value ?? ''],
|
||||||
|
|
@ -173,7 +248,35 @@ export const columns: ColumnDef<TFile>[] = [
|
||||||
accessorKey: 'bytes',
|
accessorKey: 'bytes',
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
return <SortFilterHeader column={column} title={localize('com_ui_size')} aria-hidden="true" />;
|
const sortState = column.getIsSorted();
|
||||||
|
let SortIcon = ArrowUpDown;
|
||||||
|
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||||
|
if (sortState === 'desc') {
|
||||||
|
SortIcon = ArrowDown;
|
||||||
|
ariaSort = 'descending';
|
||||||
|
} else if (sortState === 'asc') {
|
||||||
|
SortIcon = ArrowUp;
|
||||||
|
ariaSort = 'ascending';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_size_sort')}
|
||||||
|
side="top"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
aria-sort={ariaSort}
|
||||||
|
aria-label={localize('com_ui_size_sort')} aria-hidden="true"
|
||||||
|
aria-current={sortState ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
{localize('com_ui_size')}
|
||||||
|
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const suffix = ' MB';
|
const suffix = ' MB';
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ListFilter } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { useSetRecoilState } from 'recoil';
|
import { useSetRecoilState } from 'recoil';
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import type {
|
import type {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
|
|
@ -17,26 +17,22 @@ import type {
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { FileContext } from 'librechat-data-provider';
|
import { FileContext } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Input,
|
Input,
|
||||||
Table,
|
Table,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
TableRow,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
Spinner,
|
TableHeader,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
import type { TFile } from 'librechat-data-provider';
|
import type { TFile } from 'librechat-data-provider';
|
||||||
import type { AugmentedColumnDef } from '~/common';
|
import { ColumnVisibilityDropdown } from './ColumnVisibilityDropdown';
|
||||||
import { useDeleteFilesFromTable } from '~/hooks/Files';
|
import { useDeleteFilesFromTable } from '~/hooks/Files';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
|
@ -45,7 +41,7 @@ interface DataTableProps<TData, TValue> {
|
||||||
data: TData[];
|
data: TData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextMap = {
|
const contextMap: Record<string, TranslationKeys> = {
|
||||||
[FileContext.filename]: 'com_ui_name',
|
[FileContext.filename]: 'com_ui_name',
|
||||||
[FileContext.updatedAt]: 'com_ui_date',
|
[FileContext.updatedAt]: 'com_ui_date',
|
||||||
[FileContext.filterSource]: 'com_ui_storage',
|
[FileContext.filterSource]: 'com_ui_storage',
|
||||||
|
|
@ -75,6 +71,11 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
|
defaultColumn: {
|
||||||
|
minSize: 0,
|
||||||
|
size: Number.MAX_SAFE_INTEGER,
|
||||||
|
maxSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
},
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
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>}
|
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative flex-1">
|
<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
|
<Input
|
||||||
|
id="files-filter"
|
||||||
placeholder=" "
|
placeholder=" "
|
||||||
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
||||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
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')}
|
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')}
|
{localize('com_files_filter')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<div className="relative focus-within:z-[100]">
|
||||||
<DropdownMenuTrigger asChild>
|
<ColumnVisibilityDropdown
|
||||||
<Button
|
table={table}
|
||||||
variant="outline"
|
contextMap={contextMap}
|
||||||
aria-label={localize('com_files_filter_by')}
|
isSmallScreen={isSmallScreen}
|
||||||
className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}
|
/>
|
||||||
>
|
</div>
|
||||||
<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>
|
</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">
|
<Table className="w-full min-w-[300px] border-separate border-spacing-0">
|
||||||
<TableHeader className="sticky top-0 z-50">
|
<TableHeader className="sticky top-0 z-50">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||||
{headerGroup.headers.map((header, index) => {
|
{headerGroup.headers.map((header, _index) => {
|
||||||
const style: Style = {};
|
const size = header.getSize();
|
||||||
if (index === 0 && header.id === 'select') {
|
const style: Style = {
|
||||||
style.width = '36px';
|
width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size,
|
||||||
style.minWidth = '36px';
|
};
|
||||||
} else if (header.id === 'filename') {
|
|
||||||
style.width = isSmallScreen ? '60%' : '40%';
|
|
||||||
} else {
|
|
||||||
style.width = isSmallScreen ? '20%' : '15%';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
|
|
@ -195,17 +174,11 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
className="border-b border-border-light transition-colors hover:bg-surface-secondary [tr:last-child_&]:border-b-0"
|
className="border-b border-border-light transition-colors hover:bg-surface-secondary [tr:last-child_&]:border-b-0"
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell, index) => {
|
{row.getVisibleCells().map((cell, _index) => {
|
||||||
const maxWidth =
|
const size = cell.column.getSize();
|
||||||
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta?.size ??
|
const style: Style = {
|
||||||
'auto';
|
width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size,
|
||||||
|
};
|
||||||
const style: Style = {};
|
|
||||||
if (cell.column.id === 'filename') {
|
|
||||||
style.maxWidth = maxWidth;
|
|
||||||
} else if (index === 0) {
|
|
||||||
style.maxWidth = '20px';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<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 className="hidden sm:inline">{localize('com_ui_page')}</span>
|
||||||
<span>{table.getState().pagination.pageIndex + 1}</span>
|
<span>{table.getState().pagination.pageIndex + 1}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span>{table.getPageCount()}</span>
|
<span>{Math.max(table.getPageCount(), 1)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="select-none"
|
className="select-none"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
|
import { useState, useId, useMemo } from 'react';
|
||||||
|
import * as Menu from '@ariakit/react/menu';
|
||||||
import { Column } from '@tanstack/react-table';
|
import { Column } from '@tanstack/react-table';
|
||||||
import { ListFilter, FilterX } from 'lucide-react';
|
import { ListFilter, FilterX } from 'lucide-react';
|
||||||
|
import { DropdownPopup, TooltipAnchor } from '@librechat/client';
|
||||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon } from '@radix-ui/react-icons';
|
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon } from '@radix-ui/react-icons';
|
||||||
import {
|
import type { MenuItemProps } from '~/common';
|
||||||
Button,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@librechat/client';
|
|
||||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
|
@ -17,6 +13,7 @@ interface SortFilterHeaderProps<TData, TValue> extends React.HTMLAttributes<HTML
|
||||||
column: Column<TData, TValue>;
|
column: Column<TData, TValue>;
|
||||||
filters?: Record<string, string[] | number[]>;
|
filters?: Record<string, string[] | number[]>;
|
||||||
valueMap?: Record<any, TranslationKeys>;
|
valueMap?: Record<any, TranslationKeys>;
|
||||||
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortFilterHeader<TData, TValue>({
|
export function SortFilterHeader<TData, TValue>({
|
||||||
|
|
@ -25,102 +22,119 @@ export function SortFilterHeader<TData, TValue>({
|
||||||
className = '',
|
className = '',
|
||||||
filters,
|
filters,
|
||||||
valueMap,
|
valueMap,
|
||||||
|
ariaLabel,
|
||||||
}: SortFilterHeaderProps<TData, TValue>) {
|
}: SortFilterHeaderProps<TData, TValue>) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const menuId = useId();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const dropdownItems = useMemo(() => {
|
||||||
|
const items: MenuItemProps[] = [
|
||||||
|
{
|
||||||
|
label: localize('com_ui_ascending'),
|
||||||
|
onClick: () => column.toggleSorting(false),
|
||||||
|
icon: <ArrowUpIcon className="h-3.5 w-3.5 text-muted-foreground/70" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: localize('com_ui_descending'),
|
||||||
|
onClick: () => column.toggleSorting(true),
|
||||||
|
icon: <ArrowDownIcon className="h-3.5 w-3.5 text-muted-foreground/70" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
items.push({ separate: true } as any);
|
||||||
|
Object.entries(filters).forEach(([_key, values]) => {
|
||||||
|
values.forEach((value?: string | number) => {
|
||||||
|
const translationKey = valueMap?.[value ?? ''];
|
||||||
|
const filterValue =
|
||||||
|
translationKey != null && translationKey.length
|
||||||
|
? localize(translationKey)
|
||||||
|
: String(value);
|
||||||
|
if (filterValue) {
|
||||||
|
const isActive = column.getFilterValue() === value;
|
||||||
|
items.push({
|
||||||
|
label: filterValue,
|
||||||
|
onClick: () => column.setFilterValue(value),
|
||||||
|
icon: (
|
||||||
|
<ListFilter className="h-3.5 w-3.5 text-muted-foreground/70" aria-hidden="true" />
|
||||||
|
),
|
||||||
|
show: true,
|
||||||
|
className: isActive ? 'border-l-2 border-l-border-xheavy' : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({ separate: true } as any);
|
||||||
|
items.push({
|
||||||
|
label: localize('com_ui_show_all'),
|
||||||
|
onClick: () => column.setFilterValue(undefined),
|
||||||
|
icon: <FilterX className="h-3.5 w-3.5 text-muted-foreground/70" />,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [column, filters, valueMap, localize]);
|
||||||
|
|
||||||
if (!column.getCanSort()) {
|
if (!column.getCanSort()) {
|
||||||
return <div className={cn(className)}>{title}</div>;
|
return <div className={cn(className)}>{title}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortState = column.getIsSorted();
|
||||||
|
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||||
|
if (sortState === 'desc') {
|
||||||
|
ariaSort = 'descending';
|
||||||
|
} else if (sortState === 'asc') {
|
||||||
|
ariaSort = 'ascending';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center space-x-2', className)}>
|
<div className={cn('flex items-center space-x-2', className)}>
|
||||||
<DropdownMenu>
|
<DropdownPopup
|
||||||
<DropdownMenuTrigger asChild>
|
portal={false}
|
||||||
<Button
|
isOpen={isOpen}
|
||||||
variant="ghost"
|
setIsOpen={setIsOpen}
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover data-[state=open]:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
trigger={
|
||||||
>
|
<TooltipAnchor
|
||||||
<span>{title}</span>
|
description={ariaLabel || title}
|
||||||
{column.getIsFiltered() ? (
|
side="top"
|
||||||
<ListFilter className="icon-sm ml-2 text-muted-foreground/70" aria-hidden="true" />
|
render={
|
||||||
) : (
|
<Menu.MenuButton
|
||||||
<ListFilter className="icon-sm ml-2 opacity-30" aria-hidden="true" />
|
aria-sort={ariaSort}
|
||||||
)}
|
aria-label={ariaLabel}
|
||||||
{(() => {
|
aria-pressed={column.getIsFiltered() ? 'true' : 'false'}
|
||||||
const sortState = column.getIsSorted();
|
aria-current={sortState ? 'true' : 'false'}
|
||||||
if (sortState === 'desc') {
|
className={cn(
|
||||||
return <ArrowDownIcon className="icon-sm ml-2" />;
|
'inline-flex items-center gap-2 rounded-lg px-2 py-0 text-xs transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[open]:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm',
|
||||||
}
|
column.getIsFiltered() && 'border-b-2 border-b-border-xheavy',
|
||||||
if (sortState === 'asc') {
|
)}
|
||||||
return <ArrowUpIcon className="icon-sm ml-2" />;
|
>
|
||||||
}
|
<span>{title}</span>
|
||||||
return <CaretSortIcon className="icon-sm ml-2" />;
|
{column.getIsFiltered() ? (
|
||||||
})()}
|
<ListFilter className="icon-sm text-muted-foreground/70" aria-hidden="true" />
|
||||||
</Button>
|
) : (
|
||||||
</DropdownMenuTrigger>
|
<ListFilter className="icon-sm opacity-30" aria-hidden="true" />
|
||||||
<DropdownMenuContent
|
)}
|
||||||
align="start"
|
{(() => {
|
||||||
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
|
const sortState = column.getIsSorted();
|
||||||
>
|
if (sortState === 'desc') {
|
||||||
<DropdownMenuItem
|
return <ArrowDownIcon className="icon-sm" />;
|
||||||
onClick={() => column.toggleSorting(false)}
|
}
|
||||||
className="cursor-pointer text-text-primary"
|
if (sortState === 'asc') {
|
||||||
>
|
return <ArrowUpIcon className="icon-sm" />;
|
||||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
}
|
||||||
{localize('com_ui_ascending')}
|
return <CaretSortIcon className="icon-sm" />;
|
||||||
</DropdownMenuItem>
|
})()}
|
||||||
<DropdownMenuItem
|
</Menu.MenuButton>
|
||||||
onClick={() => column.toggleSorting(true)}
|
}
|
||||||
className="cursor-pointer text-text-primary"
|
/>
|
||||||
>
|
}
|
||||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
items={dropdownItems}
|
||||||
{localize('com_ui_descending')}
|
menuId={menuId}
|
||||||
</DropdownMenuItem>
|
className="z-[1001]"
|
||||||
<DropdownMenuSeparator className="dark:bg-gray-500" />
|
/>
|
||||||
{filters &&
|
|
||||||
Object.entries(filters).map(([key, values]) =>
|
|
||||||
values.map((value?: string | number) => {
|
|
||||||
const translationKey = valueMap?.[value ?? ''];
|
|
||||||
const filterValue =
|
|
||||||
translationKey != null && translationKey.length
|
|
||||||
? localize(translationKey)
|
|
||||||
: String(value);
|
|
||||||
if (!filterValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer text-text-primary"
|
|
||||||
key={`${key}-${value}`}
|
|
||||||
onClick={() => {
|
|
||||||
column.setFilterValue(value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListFilter
|
|
||||||
className="mr-2 h-3.5 w-3.5 text-muted-foreground/70"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{filterValue}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
{filters && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={
|
|
||||||
column.getIsFiltered()
|
|
||||||
? 'cursor-pointer dark:text-white dark:hover:bg-gray-800'
|
|
||||||
: 'pointer-events-none opacity-30'
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
column.setFilterValue(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FilterX className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" aria-hidden="true" />
|
|
||||||
{localize('com_ui_show_all')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -805,6 +805,7 @@
|
||||||
"com_ui_confirm_change": "Confirm Change",
|
"com_ui_confirm_change": "Confirm Change",
|
||||||
"com_ui_connecting": "Connecting",
|
"com_ui_connecting": "Connecting",
|
||||||
"com_ui_context": "Context",
|
"com_ui_context": "Context",
|
||||||
|
"com_ui_context_filter_sort": "Filter and Sort by Context",
|
||||||
"com_ui_continue": "Continue",
|
"com_ui_continue": "Continue",
|
||||||
"com_ui_continue_oauth": "Continue with OAuth",
|
"com_ui_continue_oauth": "Continue with OAuth",
|
||||||
"com_ui_control_bar": "Control bar",
|
"com_ui_control_bar": "Control bar",
|
||||||
|
|
@ -838,6 +839,7 @@
|
||||||
"com_ui_custom_prompt_mode": "Custom Prompt Mode",
|
"com_ui_custom_prompt_mode": "Custom Prompt Mode",
|
||||||
"com_ui_dashboard": "Dashboard",
|
"com_ui_dashboard": "Dashboard",
|
||||||
"com_ui_date": "Date",
|
"com_ui_date": "Date",
|
||||||
|
"com_ui_date_sort": "Sort by Date",
|
||||||
"com_ui_date_april": "April",
|
"com_ui_date_april": "April",
|
||||||
"com_ui_date_august": "August",
|
"com_ui_date_august": "August",
|
||||||
"com_ui_date_december": "December",
|
"com_ui_date_december": "December",
|
||||||
|
|
@ -1083,6 +1085,7 @@
|
||||||
"com_ui_more_info": "More info",
|
"com_ui_more_info": "More info",
|
||||||
"com_ui_my_prompts": "My Prompts",
|
"com_ui_my_prompts": "My Prompts",
|
||||||
"com_ui_name": "Name",
|
"com_ui_name": "Name",
|
||||||
|
"com_ui_name_sort": "Sort by Name",
|
||||||
"com_ui_new": "New",
|
"com_ui_new": "New",
|
||||||
"com_ui_new_chat": "New chat",
|
"com_ui_new_chat": "New chat",
|
||||||
"com_ui_new_conversation_title": "New Conversation Title",
|
"com_ui_new_conversation_title": "New Conversation Title",
|
||||||
|
|
@ -1248,6 +1251,7 @@
|
||||||
"com_ui_select_provider": "Select a provider",
|
"com_ui_select_provider": "Select a provider",
|
||||||
"com_ui_select_provider_first": "Select a provider first",
|
"com_ui_select_provider_first": "Select a provider first",
|
||||||
"com_ui_select_region": "Select a region",
|
"com_ui_select_region": "Select a region",
|
||||||
|
"com_ui_select_row": "Select row",
|
||||||
"com_ui_select_search_model": "Search model by name",
|
"com_ui_select_search_model": "Search model by name",
|
||||||
"com_ui_select_search_provider": "Search provider by name",
|
"com_ui_select_search_provider": "Search provider by name",
|
||||||
"com_ui_select_search_region": "Search region by name",
|
"com_ui_select_search_region": "Search region by name",
|
||||||
|
|
@ -1273,6 +1277,7 @@
|
||||||
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
|
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
|
||||||
"com_ui_simple": "Simple",
|
"com_ui_simple": "Simple",
|
||||||
"com_ui_size": "Size",
|
"com_ui_size": "Size",
|
||||||
|
"com_ui_size_sort": "Sort by Size",
|
||||||
"com_ui_special_var_current_date": "Current Date",
|
"com_ui_special_var_current_date": "Current Date",
|
||||||
"com_ui_special_var_current_datetime": "Current Date & Time",
|
"com_ui_special_var_current_datetime": "Current Date & Time",
|
||||||
"com_ui_special_var_current_user": "Current User",
|
"com_ui_special_var_current_user": "Current User",
|
||||||
|
|
@ -1286,6 +1291,7 @@
|
||||||
"com_ui_status_prefix": "Status:",
|
"com_ui_status_prefix": "Status:",
|
||||||
"com_ui_stop": "Stop",
|
"com_ui_stop": "Stop",
|
||||||
"com_ui_storage": "Storage",
|
"com_ui_storage": "Storage",
|
||||||
|
"com_ui_storage_filter_sort": "Filter and Sort by Storage",
|
||||||
"com_ui_submit": "Submit",
|
"com_ui_submit": "Submit",
|
||||||
"com_ui_support_contact": "Support Contact",
|
"com_ui_support_contact": "Support Contact",
|
||||||
"com_ui_support_contact_email": "Email",
|
"com_ui_support_contact_email": "Email",
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,10 @@ export interface MenuItemProps {
|
||||||
| undefined;
|
| undefined;
|
||||||
ariaControls?: string;
|
ariaControls?: string;
|
||||||
ref?: React.Ref<any>;
|
ref?: React.Ref<any>;
|
||||||
|
className?: string;
|
||||||
render?:
|
render?:
|
||||||
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>
|
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>
|
||||||
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
subItems?: MenuItemProps[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ const Menu: React.FC<MenuProps> = ({
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||||
itemClassName,
|
itemClassName,
|
||||||
|
item.className,
|
||||||
)}
|
)}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
render={item.render}
|
render={item.render}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue