Repository navigation
🧰 style: Hover-Revealed Tool Output Copy, Full Output, and a Virtualized My Files Table #16640
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
daee829
abb4f22
2100ae5
2c3f1f0
3f92756
8aecd2e
bf9b54a
6f6c230
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,13 @@ | ||
| import { useState } from 'react'; | ||
| import { useRef, useState, useEffect, useCallback } from 'react'; | ||
| import { useSetRecoilState } from 'recoil'; | ||
| import { FileContext } from 'librechat-data-provider'; | ||
| import { useVirtualizer } from '@tanstack/react-virtual'; | ||
| import { | ||
| flexRender, | ||
| useReactTable, | ||
| getCoreRowModel, | ||
| getSortedRowModel, | ||
| getFilteredRowModel, | ||
| getPaginationRowModel, | ||
| } from '@tanstack/react-table'; | ||
| import { | ||
| Table, | ||
|
|
@@ -40,6 +40,8 @@ interface DataTableProps<TData, TValue> { | |
| data: TData[]; | ||
| } | ||
|
|
||
| const ESTIMATED_ROW_HEIGHT = 52; | ||
|
|
||
| const contextMap: Record<string, TranslationKeys> = { | ||
| [FileContext.filename]: 'com_ui_name', | ||
| [FileContext.updatedAt]: 'com_ui_date', | ||
|
|
@@ -66,6 +68,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro | |
| const isSmallScreen = useMediaQuery('(max-width: 768px)'); | ||
| const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); | ||
| const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); | ||
| const scrollRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| const table = useReactTable({ | ||
| data, | ||
|
|
@@ -81,7 +84,6 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro | |
| onColumnFiltersChange: setColumnFilters, | ||
| getFilteredRowModel: getFilteredRowModel(), | ||
| onColumnVisibilityChange: setColumnVisibility, | ||
| getPaginationRowModel: getPaginationRowModel(), | ||
| onRowSelectionChange: setRowSelection, | ||
| state: { | ||
| sorting, | ||
|
|
@@ -91,6 +93,31 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro | |
| }, | ||
| }); | ||
|
|
||
| const { rows } = table.getRowModel(); | ||
| const estimateSize = useCallback(() => ESTIMATED_ROW_HEIGHT, []); | ||
| const getItemKey = useCallback((index: number) => rows[index]?.id ?? index, [rows]); | ||
| const rowVirtualizer = useVirtualizer({ | ||
| count: rows.length, | ||
| getScrollElement: () => scrollRef.current, | ||
| estimateSize, | ||
| getItemKey, | ||
| overscan: 8, | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (scrollRef.current) { | ||
| scrollRef.current.scrollTop = 0; | ||
| } | ||
| }, [sorting, columnFilters]); | ||
|
|
||
| const virtualRows = rowVirtualizer.getVirtualItems(); | ||
| const paddingTop = virtualRows[0]?.start ?? 0; | ||
| const paddingBottom = | ||
| virtualRows.length > 0 | ||
| ? rowVirtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0) | ||
| : 0; | ||
| const visibleColumnCount = table.getVisibleLeafColumns().length; | ||
|
|
||
| return ( | ||
| <div className="flex h-full flex-col gap-4"> | ||
| <div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4"> | ||
|
|
@@ -130,11 +157,20 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro | |
| /> | ||
| </div> | ||
| </div> | ||
| <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"> | ||
| <Table className="w-full min-w-[300px] border-separate border-spacing-0"> | ||
| <div | ||
| ref={scrollRef} | ||
| 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" | ||
| > | ||
| {/* Unwrapped: this div is the scroller the virtualizer observes, so the table | ||
| must not add its own scrolling wrapper inside it. */} | ||
| <Table | ||
| unwrapped | ||
| aria-rowcount={rows.length > 0 ? rows.length + 1 : undefined} | ||
| className="w-full min-w-[300px] border-separate border-spacing-0" | ||
| > | ||
| <TableHeader sticky> | ||
| {table.getHeaderGroups().map((headerGroup) => ( | ||
| <TableRow key={headerGroup.id}> | ||
| <TableRow key={headerGroup.id} aria-rowindex={1}> | ||
| {headerGroup.headers.map((header, _index) => { | ||
| const size = header.getSize(); | ||
| const style: Style = { | ||
|
|
@@ -158,31 +194,55 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro | |
| ))} | ||
| </TableHeader> | ||
| <TableBody className="w-full"> | ||
| {table.getRowModel().rows.length ? ( | ||
| table.getRowModel().rows.map((row) => ( | ||
| <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}> | ||
| {row.getVisibleCells().map((cell, _index) => { | ||
| const size = cell.column.getSize(); | ||
| const style: Style = { | ||
| width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size, | ||
| }; | ||
| {rows.length ? ( | ||
| <> | ||
| {paddingTop > 0 && ( | ||
| <tr aria-hidden="true"> | ||
| <td colSpan={visibleColumnCount} style={{ height: paddingTop }} /> | ||
| </tr> | ||
| )} | ||
| {virtualRows.map((virtualRow) => { | ||
| const row = rows[virtualRow.index]; | ||
| if (!row) { | ||
| return null; | ||
| } | ||
| return ( | ||
| <TableRow | ||
| key={virtualRow.key} | ||
| ref={rowVirtualizer.measureElement} | ||
| data-index={virtualRow.index} | ||
|
Comment on lines
+210
to
+213
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For file lists longer than the rendered virtual window, the table now removes most rows from the accessibility tree but provides neither the total filtered row count nor each mounted row's logical index. Screen readers therefore announce only the small mounted subset and report later windows as though they were the first rows, making selection state and position misleading. Add AGENTS.md reference: AGENTS.md:L199-L200 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 2c3f1f0: aria-rowcount on the table, aria-rowindex on the header and each mounted row, spacer rows aria-hidden. Verified by scenario my-files-rows-report-position. |
||
| aria-rowindex={virtualRow.index + 2} | ||
| data-state={row.getIsSelected() && 'selected'} | ||
| > | ||
| {row.getVisibleCells().map((cell) => { | ||
| const size = cell.column.getSize(); | ||
| const style: Style = { | ||
| width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size, | ||
| }; | ||
|
|
||
| return ( | ||
| <TableCell | ||
| key={cell.id} | ||
| size="compact" | ||
| className={cn( | ||
| 'align-start px-2 text-xs sm:px-4 sm:text-sm [tr[data-disabled=true]_&]:opacity-50', | ||
| cell.column.id === 'select' ? 'overflow-visible' : 'overflow-x-auto', | ||
| )} | ||
| style={style} | ||
| > | ||
| {flexRender(cell.column.columnDef.cell, cell.getContext())} | ||
| </TableCell> | ||
| ); | ||
| })} | ||
| </TableRow> | ||
| )) | ||
| return ( | ||
| <TableCell | ||
| key={cell.id} | ||
| size="compact" | ||
| className={cn( | ||
| 'align-start px-2 text-xs sm:px-4 sm:text-sm [tr[data-disabled=true]_&]:opacity-50', | ||
| cell.column.id === 'select' ? 'overflow-visible' : 'overflow-x-auto', | ||
| )} | ||
| style={style} | ||
| > | ||
| {flexRender(cell.column.columnDef.cell, cell.getContext())} | ||
| </TableCell> | ||
| ); | ||
| })} | ||
| </TableRow> | ||
| ); | ||
| })} | ||
| {paddingBottom > 0 && ( | ||
| <tr aria-hidden="true"> | ||
| <td colSpan={visibleColumnCount} style={{ height: paddingBottom }} /> | ||
| </tr> | ||
| )} | ||
| </> | ||
| ) : ( | ||
| <TableRow> | ||
| <TableCell colSpan={columns.length} className="h-24 text-center"> | ||
|
|
@@ -208,30 +268,6 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro | |
| }`} | ||
| </span> | ||
| </div> | ||
| <div className="text-text-primary flex items-center space-x-1 pr-2 text-xs font-bold sm:text-sm"> | ||
| <span className="hidden sm:inline">{localize('com_ui_page')}</span> | ||
| <span>{table.getState().pagination.pageIndex + 1}</span> | ||
| <span>/</span> | ||
| <span>{Math.max(table.getPageCount(), 1)}</span> | ||
| </div> | ||
| <Button | ||
| className="select-none" | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={() => table.previousPage()} | ||
| disabled={!table.getCanPreviousPage()} | ||
| > | ||
| {localize('com_ui_prev')} | ||
| </Button> | ||
| <Button | ||
| className="select-none" | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={() => table.nextPage()} | ||
| disabled={!table.getCanNextPage()} | ||
| > | ||
| {localize('com_ui_next')} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the file list exceeds the viewport, the shared
<Table>still creates its defaultoverflow-autowrapper (packages/client/src/components/Table.tsx), and as the sole child of this fixed-height grid that inner wrapper receives the scrolling. The virtualizer instead observes this outerscrollRef, whosescrollTopremains zero, so it never advances beyond the initially rendered rows; users scroll into the bottom spacer and cannot access later files. Render the table withunwrappedor attach the virtualizer to the inner scrolling element.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 2c3f1f0: the table renders unwrapped, so the virtualizer's own div is the scroller. Verified by scenario my-files-scroll-reaches-last-file (200 files, last row reached) on desktop light, dark and mobile.