📂 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:
Dustin Healy 2025-12-10 15:28:45 -08:00 committed by Danny Avila
parent 70e854eb59
commit abcf606328
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
8 changed files with 336 additions and 177 deletions

View file

@ -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>>

View file

@ -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"
/>
);
}

View file

@ -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';

View file

@ -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"

View file

@ -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>
); );
} }

View file

@ -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",

View file

@ -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[];
} }

View file

@ -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}