diff --git a/package.json b/package.json index e743edda..baf6cda0 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "react-router-hash-link": "^2.4.3", "react-scripts": "^5.0.0", "react-usestateref": "^1.0.8", + "react-virtual": "^2.10.4", "remark-gfm": "^3.0.1", "seedrandom": "^3.0.5", "stream-browserify": "^3.0.0", diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index ecca81de..153b5576 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, Suspense, useRef } from "react"; +import { useMemo, useRef, useCallback, Suspense, useEffect } from "react"; import { useAtom, useSetAtom } from "jotai"; import { useDebouncedCallback, useThrottledCallback } from "use-debounce"; import { DndProvider } from "react-dnd"; @@ -11,6 +11,7 @@ import { getCoreRowModel, useReactTable, } from "@tanstack/react-table"; +import { useVirtual } from "react-virtual"; import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar"; @@ -58,6 +59,7 @@ import { FieldType } from "@src/constants/fields"; import { formatSubTableName } from "@src/utils/table"; import { TableRow, ColumnConfig } from "@src/types/table"; import { StyledCell } from "./Styled/StyledCell"; +import { useKeyboardNavigation } from "./useKeyboardNavigation"; export const DEFAULT_ROW_HEIGHT = 41; export const DEFAULT_COL_WIDTH = 150; @@ -86,8 +88,8 @@ export default function TableComponent() { const updateColumn = useSetAtom(updateColumnAtom, tableScope); const updateField = useSetAtom(updateFieldAtom, tableScope); + const containerRef = useRef(null); const gridRef = useRef(null); - const [focusInsideCell, setFocusInsideCell] = useState(false); const canAddColumn = userRoles.includes("ADMIN"); const canEditColumn = userRoles.includes("ADMIN"); @@ -167,149 +169,111 @@ export default function TableComponent() { columnResizeMode: "onChange", // debugRows: true, }); + const { rows } = table.getRowModel(); // console.log(table, selectedCell); - const handleKeyDown = (e: React.KeyboardEvent) => { - console.log( - "keydown", - // e.target, - e.key, - e.ctrlKey ? "ctrl" : "", - e.altKey ? "alt" : "", - e.metaKey ? "meta" : "", - e.shiftKey ? "shift" : "" - ); - const LISTENED_KEYS = [ - "ArrowUp", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - "Enter", - "Escape", - "Home", - "End", - "PageUp", - "PageDown", - ]; - if (LISTENED_KEYS.includes(e.key)) e.preventDefault(); + const { + virtualItems: virtualRows, + totalSize: totalHeight, + scrollToIndex: scrollToRowIndex, + } = useVirtual({ + parentRef: containerRef, + size: tableRows.length, + overscan: 10, + estimateSize: useCallback( + () => tableSchema.rowHeight || DEFAULT_ROW_HEIGHT, + [tableSchema.rowHeight] + ), + }); - if (e.key === "Escape") { - setFocusInsideCell(false); - ( - gridRef.current?.querySelector("[aria-selected=true]") as HTMLDivElement - )?.focus(); - return; + const { + virtualItems: virtualCols, + totalSize: totalWidth, + scrollToIndex: scrollToColIndex, + } = useVirtual({ + parentRef: containerRef, + horizontal: true, + size: columns.length, + overscan: 1, + estimateSize: useCallback( + (index: number) => columns[index].size || DEFAULT_COL_WIDTH, + [columns] + ), + }); + + console.log(totalHeight); + + useEffect(() => { + if (!selectedCell) return; + if (selectedCell.path) { + const rowIndex = tableRows.findIndex( + (row) => row._rowy_ref.path === selectedCell.path + ); + if (rowIndex === -1) return; + scrollToRowIndex(rowIndex); } - - const target = e.target as HTMLDivElement; - if ( - target.getAttribute("role") !== "columnheader" && - target.getAttribute("role") !== "gridcell" - ) - return; - - if (e.key === "Enter") { - setFocusInsideCell(true); - (target.querySelector("[tabindex]") as HTMLElement)?.focus(); - return; + if (selectedCell.columnKey) { + const colIndex = columns.findIndex( + (col) => col.id === selectedCell.columnKey + ); + if (colIndex === -1) return; + scrollToColIndex(colIndex); } + }, [selectedCell, tableRows, columns, scrollToRowIndex, scrollToColIndex]); - const colIndex = Number(target.getAttribute("aria-colindex")) - 1; - const rowIndex = - Number(target.parentElement!.getAttribute("aria-rowindex")) - 2; + const { handleKeyDown, focusInsideCell } = useKeyboardNavigation({ + gridRef, + tableRows, + columns, + }); - const rowId = target.getAttribute("data-rowId")!; - const colId = target.getAttribute("data-colId")!; + const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? totalHeight - (virtualRows?.[virtualRows.length - 1]?.end || 0) + : 0; - const isHeader = rowId === "_rowy_header"; + const paddingLeft = virtualCols.length > 0 ? virtualCols?.[0]?.start || 0 : 0; + const paddingRight = + virtualCols.length > 0 + ? totalWidth - (virtualCols?.[virtualCols.length - 1]?.end || 0) + : 0; - let newColIndex = colIndex; - let newRowIndex = rowIndex; + const fetchMoreOnBottomReached = useThrottledCallback( + (containerElement?: HTMLDivElement | null) => { + console.log("fetchMoreOnBottomReached", containerElement); + if (!containerElement) return; - // const newSelectedCell: SelectedCell = selectedCell - // ? { ...selectedCell } - // : { path: rowId, columnKey: colId }; - - switch (e.key) { - case "ArrowUp": - if (e.ctrlKey || e.metaKey) newRowIndex = -1; - else if (rowIndex > -1) newRowIndex = rowIndex - 1; - break; - - case "ArrowDown": - if (e.ctrlKey || e.metaKey) newRowIndex = tableRows.length - 1; - else if (rowIndex < tableRows.length - 1) newRowIndex = rowIndex + 1; - break; - - case "ArrowLeft": - if (e.ctrlKey || e.metaKey) newColIndex = 0; - else if (colIndex > 0) newColIndex = colIndex - 1; - break; - - case "ArrowRight": - if (e.ctrlKey || e.metaKey) newColIndex = columns.length - 1; - else if (colIndex < columns.length - 1) newColIndex = colIndex + 1; - break; - - case "PageUp": - newRowIndex = Math.max(0, rowIndex - COLLECTION_PAGE_SIZE); - break; - - case "PageDown": - newRowIndex = Math.min( - tableRows.length - 1, - rowIndex + COLLECTION_PAGE_SIZE - ); - break; - - case "Home": - newColIndex = 0; - if (e.ctrlKey || e.metaKey) newRowIndex = -1; - break; - - case "End": - newColIndex = columns.length - 1; - if (e.ctrlKey || e.metaKey) newRowIndex = tableRows.length - 1; - break; - } - - const newSelectedCell = { - path: - newRowIndex > -1 - ? tableRows[newRowIndex]._rowy_ref.path - : "_rowy_header", - columnKey: columns[newColIndex].id! || columns[0].id!, - }; - console.log(newRowIndex, newColIndex, newSelectedCell); - - setSelectedCell(newSelectedCell); - - // Find matching DOM element for the cell - const newCellEl = gridRef.current?.querySelector( - `[aria-rowindex="${newRowIndex + 2}"] [aria-colindex="${ - newColIndex + 1 - }"]` - ); - // Focus the cell - if (newCellEl) (newCellEl as HTMLDivElement).focus(); - setFocusInsideCell(false); - }; + const { scrollHeight, scrollTop, clientHeight } = containerElement; + if (scrollHeight - scrollTop - clientHeight < 300) { + setTablePage((p) => p + 1); + } + }, + 250 + ); return ( - <> +
fetchMoreOnBottomReached(e.target as HTMLDivElement)} + style={{ overflow: "auto", width: "100%", height: "100%" }} + >
{table.getHeaderGroups().map((headerGroup) => ( @@ -322,8 +286,8 @@ export default function TableComponent() { return (
- {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell, cellIndex) => { - const isSelectedCell = - selectedCell?.path === row.original._rowy_ref.path && - selectedCell?.columnKey === cell.column.id; + {paddingTop > 0 && ( +
+ )} - return ( - { - setSelectedCell({ - path: row.original._rowy_ref.path, - columnKey: cell.column.id, - }); - (e.target as HTMLDivElement).focus(); - }} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - - ); - })} - - ))} + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + + ); + })} + + {paddingRight > 0 && ( +
+ )} + + ); + })} + + {paddingBottom > 0 && ( +
+ )}
- +
); } diff --git a/src/components/Table/useKeyboardNavigation.tsx b/src/components/Table/useKeyboardNavigation.tsx new file mode 100644 index 00000000..be41e37f --- /dev/null +++ b/src/components/Table/useKeyboardNavigation.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { useSetAtom } from "jotai"; +import { ColumnDef } from "@tanstack/react-table"; + +import { tableScope, selectedCellAtom } from "@src/atoms/tableScope"; +import { TableRow } from "@src/types/table"; +import { COLLECTION_PAGE_SIZE } from "@src/config/db"; + +export interface IUseKeyboardNavigationProps { + gridRef: React.RefObject; + tableRows: TableRow[]; + columns: ColumnDef[]; +} + +export function useKeyboardNavigation({ + gridRef, + tableRows, + columns, +}: IUseKeyboardNavigationProps) { + const setSelectedCell = useSetAtom(selectedCellAtom, tableScope); + const [focusInsideCell, setFocusInsideCell] = useState(false); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Block default browser behavior for arrow keys (scroll) and other keys + const LISTENED_KEYS = [ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Enter", + "Escape", + "Home", + "End", + "PageUp", + "PageDown", + ]; + if (LISTENED_KEYS.includes(e.key)) e.preventDefault(); + + // Esc: exit cell + if (e.key === "Escape") { + setFocusInsideCell(false); + ( + gridRef.current?.querySelector("[aria-selected=true]") as HTMLDivElement + )?.focus(); + return; + } + + // If event target is not a cell, ignore + const target = e.target as HTMLDivElement; + if ( + target.getAttribute("role") !== "columnheader" && + target.getAttribute("role") !== "gridcell" + ) + return; + + // Enter: enter cell + if (e.key === "Enter") { + setFocusInsideCell(true); + (target.querySelector("[tabindex]") as HTMLElement)?.focus(); + return; + } + + const colIndex = Number(target.getAttribute("aria-colindex")) - 1; + const rowIndex = + Number(target.parentElement!.getAttribute("aria-rowindex")) - 2; + + let newColIndex = colIndex; + let newRowIndex = rowIndex; + + switch (e.key) { + case "ArrowUp": + if (e.ctrlKey || e.metaKey) newRowIndex = -1; + else if (rowIndex > -1) newRowIndex = rowIndex - 1; + break; + + case "ArrowDown": + if (e.ctrlKey || e.metaKey) newRowIndex = tableRows.length - 1; + else if (rowIndex < tableRows.length - 1) newRowIndex = rowIndex + 1; + break; + + case "ArrowLeft": + if (e.ctrlKey || e.metaKey) newColIndex = 0; + else if (colIndex > 0) newColIndex = colIndex - 1; + break; + + case "ArrowRight": + if (e.ctrlKey || e.metaKey) newColIndex = columns.length - 1; + else if (colIndex < columns.length - 1) newColIndex = colIndex + 1; + break; + + case "PageUp": + newRowIndex = Math.max(0, rowIndex - COLLECTION_PAGE_SIZE); + break; + + case "PageDown": + newRowIndex = Math.min( + tableRows.length - 1, + rowIndex + COLLECTION_PAGE_SIZE + ); + break; + + case "Home": + newColIndex = 0; + if (e.ctrlKey || e.metaKey) newRowIndex = -1; + break; + + case "End": + newColIndex = columns.length - 1; + if (e.ctrlKey || e.metaKey) newRowIndex = tableRows.length - 1; + break; + } + + // Get `path` and `columnKey` from `tableRows` and `columns` respectively + const newSelectedCell = { + path: + newRowIndex > -1 + ? tableRows[newRowIndex]._rowy_ref.path + : "_rowy_header", + columnKey: columns[newColIndex].id! || columns[0].id!, + }; + + // Store in selectedCellAtom + setSelectedCell(newSelectedCell); + + // Find matching DOM element for the cell + const newCellEl = gridRef.current?.querySelector( + `[aria-rowindex="${newRowIndex + 2}"] [aria-colindex="${ + newColIndex + 1 + }"]` + ); + + // Focus the cell + if (newCellEl) setTimeout(() => (newCellEl as HTMLDivElement).focus()); + + // When selected cell changes, exit current cell + setFocusInsideCell(false); + }; + + return { handleKeyDown, focusInsideCell } as const; +} diff --git a/src/pages/Table/TablePage.tsx b/src/pages/Table/TablePage.tsx index 17e8213a..b82a411f 100644 --- a/src/pages/Table/TablePage.tsx +++ b/src/pages/Table/TablePage.tsx @@ -101,12 +101,12 @@ export default function TablePage({ }> diff --git a/yarn.lock b/yarn.lock index b6e7cfa3..aa25b7ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2363,6 +2363,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@reach/observe-rect@^1.1.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2" + integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ== + "@react-dnd/asap@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab" @@ -10277,6 +10282,13 @@ react-usestateref@^1.0.8: resolved "https://registry.yarnpkg.com/react-usestateref/-/react-usestateref-1.0.8.tgz#b40519af0d6f3b3822c70eb5db80f7d47f1b1ff5" integrity sha512-whaE6H0XGarFKwZ3EYbpHBsRRCLZqdochzg/C7e+b6VFMTA3LS3K4ZfpI4NT40iy83jG89rGXrw70P9iDfOdsA== +react-virtual@^2.10.4: + version "2.10.4" + resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704" + integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ== + dependencies: + "@reach/observe-rect" "^1.1.0" + react@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96"