diff --git a/packages/ui-services/src/Keyboard/KeyboardKey.ts b/packages/ui-services/src/Keyboard/KeyboardKey.ts index 270f7c6ee..ed44da552 100644 --- a/packages/ui-services/src/Keyboard/KeyboardKey.ts +++ b/packages/ui-services/src/Keyboard/KeyboardKey.ts @@ -10,4 +10,6 @@ export enum KeyboardKey { Home = 'Home', End = 'End', Space = ' ', + PageUp = 'PageUp', + PageDown = 'PageDown', } diff --git a/packages/web/src/javascripts/Components/FilesTableView/FilesTableView.tsx b/packages/web/src/javascripts/Components/FilesTableView/FilesTableView.tsx index e167be5bf..f6fd35495 100644 --- a/packages/web/src/javascripts/Components/FilesTableView/FilesTableView.tsx +++ b/packages/web/src/javascripts/Components/FilesTableView/FilesTableView.tsx @@ -310,7 +310,7 @@ const FilesTableView = ({ columns: columnDefs, enableRowSelection: true, enableMultipleRowSelection: true, - onRowDoubleClick(file) { + onRowActivate(file) { void filesController.handleFileAction({ type: FileItemActionType.PreviewFile, payload: { diff --git a/packages/web/src/javascripts/Components/Table/CommonTypes.ts b/packages/web/src/javascripts/Components/Table/CommonTypes.ts index 55d083630..634dcda7d 100644 --- a/packages/web/src/javascripts/Components/Table/CommonTypes.ts +++ b/packages/web/src/javascripts/Components/Table/CommonTypes.ts @@ -35,12 +35,15 @@ export type TableHeader = { } export type Table = { + id: string headers: TableHeader[] rows: TableRow[] rowCount: number colCount: number - handleRowClick: (id: string) => MouseEventHandler - handleRowDoubleClick: (id: string) => MouseEventHandler + selectRow: (id: string) => void + multiSelectRow: (id: string) => void + rangeSelectUpToRow: (id: string) => void + handleActivateRow: (id: string) => void handleRowContextMenu: (id: string) => MouseEventHandler canSelectRows: boolean canSelectMultipleRows: boolean diff --git a/packages/web/src/javascripts/Components/Table/Table.tsx b/packages/web/src/javascripts/Components/Table/Table.tsx index b9f54b480..35bf0b6df 100644 --- a/packages/web/src/javascripts/Components/Table/Table.tsx +++ b/packages/web/src/javascripts/Components/Table/Table.tsx @@ -1,5 +1,7 @@ import { classNames } from '@standardnotes/snjs' -import { useCallback, useState } from 'react' +import { KeyboardKey } from '@standardnotes/ui-services' +import { useCallback, useState, useRef } from 'react' +import { useApplication } from '../ApplicationProvider' import Icon from '../Icon/Icon' import { Table, TableRow } from './CommonTypes' @@ -9,22 +11,25 @@ function TableRow({ canSelectRows, handleRowClick, handleRowContextMenu, - handleRowDoubleClick, + handleActivateRow, }: { row: TableRow index: number canSelectRows: Table['canSelectRows'] - handleRowClick: Table['handleRowClick'] + handleRowClick: (event: React.MouseEvent, id: string) => void handleRowContextMenu: Table['handleRowContextMenu'] - handleRowDoubleClick: Table['handleRowDoubleClick'] + handleActivateRow: Table['handleActivateRow'] }) { const [isHovered, setIsHovered] = useState(false) + const [isFocused, setIsFocused] = useState(false) + const isHoveredOrFocused = isHovered || isFocused const visibleCells = row.cells.filter((cell) => !cell.hidden) return (
({ onMouseLeave={() => { setIsHovered(false) }} - onClick={handleRowClick(row.id)} - onDoubleClick={handleRowDoubleClick(row.id)} + onClick={(event) => handleRowClick(event, row.id)} + onDoubleClick={() => handleActivateRow(row.id)} onContextMenu={handleRowContextMenu(row.id)} + onFocus={() => { + setIsFocused(true) + }} + onBlur={(event) => { + if (!event.relatedTarget?.closest(`[id="${row.id}"]`)) { + setIsFocused(false) + } + }} > {visibleCells.map((cell, index, array) => { return ( @@ -46,25 +59,27 @@ function TableRow({ aria-colindex={cell.colIndex + 1} key={index} className={classNames( - 'relative flex items-center overflow-hidden border-b border-border py-3 px-3', + 'relative flex items-center overflow-hidden border-b border-border py-3 px-3 focus:border-info', row.isSelected && 'bg-info-backdrop', canSelectRows && 'cursor-pointer', - canSelectRows && isHovered && 'bg-contrast', + canSelectRows && isHoveredOrFocused && 'bg-contrast', )} + tabIndex={-1} > {cell.render} {row.rowActions && index === array.length - 1 && (
{row.rowActions}
@@ -82,6 +97,8 @@ const PageSize = Math.ceil(document.documentElement.clientHeight / MinTableRowHe const PageScrollThreshold = 200 function Table({ table }: { table: Table }) { + const application = useApplication() + const [rowsToDisplay, setRowsToDisplay] = useState(PageSize) const paginate = useCallback(() => { setRowsToDisplay((cellsToDisplay) => cellsToDisplay + PageSize) @@ -98,13 +115,16 @@ function Table({ table }: { table: Table }) { ) const { + id, headers, rows, colCount, rowCount, - handleRowClick, + selectRow, + multiSelectRow, + rangeSelectUpToRow, handleRowContextMenu, - handleRowDoubleClick, + handleActivateRow, selectedRows, selectionActions, canSelectRows, @@ -112,6 +132,209 @@ function Table({ table }: { table: Table }) { showSelectionActions, } = table + const focusedRowIndex = useRef(0) + const focusedCellIndex = useRef(0) + + const onFocus: React.FocusEventHandler = useCallback((event) => { + const target = event.target as HTMLElement + const row = target.closest('[role="row"]') as HTMLElement + const cell = target.closest('[role="gridcell"],[role="columnheader"]') as HTMLElement + if (row) { + focusedRowIndex.current = parseInt(row.getAttribute('aria-rowindex') || '0') + } + if (cell) { + focusedCellIndex.current = parseInt(cell.getAttribute('aria-colindex') || '0') + } + }, []) + + const onBlur: React.FocusEventHandler = useCallback((event) => { + const activeElement = document.activeElement as HTMLElement + if (activeElement.closest('[role="grid"]') !== event.target) { + focusedRowIndex.current = 0 + focusedCellIndex.current = 0 + } + }, []) + + const onKeyDown: React.KeyboardEventHandler = useCallback( + (event) => { + const gridElement = event.currentTarget + const allRenderedRows = gridElement.querySelectorAll('[role="row"]') + const currentRow = Array.from(allRenderedRows).find( + (row) => row.getAttribute('aria-rowindex') === focusedRowIndex.current.toString(), + ) + const allFocusableCells = currentRow?.querySelectorAll('[tabindex]') + + const focusCell = (rowIndex: number, colIndex: number) => { + const row = gridElement.querySelector(`[role="row"][aria-rowindex="${rowIndex}"]`) + if (!row) { + return + } + const cell = row.querySelector(`[aria-colindex="${colIndex}"]`) + if (cell) { + cell.focus() + } + } + + switch (event.key) { + case KeyboardKey.Up: + event.preventDefault() + if (focusedRowIndex.current > 1) { + const previousRow = focusedRowIndex.current - 1 + focusCell(previousRow, focusedCellIndex.current) + } + break + case KeyboardKey.Down: + event.preventDefault() + if (focusedRowIndex.current < rowCount) { + const nextRow = focusedRowIndex.current + 1 + focusCell(nextRow, focusedCellIndex.current) + } + break + case KeyboardKey.Left: + event.preventDefault() + if (focusedCellIndex.current > 1) { + const previousCell = focusedCellIndex.current - 1 + focusCell(focusedRowIndex.current, previousCell) + } + break + case KeyboardKey.Right: + event.preventDefault() + if (focusedCellIndex.current < colCount) { + const nextCell = focusedCellIndex.current + 1 + focusCell(focusedRowIndex.current, nextCell) + } + break + case KeyboardKey.Home: + event.preventDefault() + if (event.ctrlKey) { + focusCell(1, 1) + } else { + if (!allFocusableCells) { + return + } + const firstFocusableCell = allFocusableCells[0] + if (!firstFocusableCell) { + return + } + const firstCellIndex = parseInt(firstFocusableCell.getAttribute('aria-colindex') || '0') + if (firstCellIndex > 0) { + focusCell(focusedRowIndex.current, firstCellIndex) + } + } + break + case KeyboardKey.End: { + event.preventDefault() + if (event.ctrlKey) { + focusCell(allRenderedRows.length, headers.length || colCount) + return + } + if (!allFocusableCells) { + return + } + const lastFocusableCell = allFocusableCells[allFocusableCells.length - 1] + if (!lastFocusableCell) { + return + } + const lastCellIndex = parseInt(lastFocusableCell.getAttribute('aria-colindex') || '0') + if (lastCellIndex > 0) { + focusCell(focusedRowIndex.current, lastCellIndex) + } + break + } + case KeyboardKey.PageUp: { + event.preventDefault() + const previousRow = focusedRowIndex.current - 5 + if (previousRow > 0) { + focusCell(previousRow, focusedCellIndex.current) + } else { + focusCell(1, focusedCellIndex.current) + } + break + } + case KeyboardKey.PageDown: { + event.preventDefault() + const nextRow = focusedRowIndex.current + 5 + if (nextRow <= allRenderedRows.length) { + focusCell(nextRow, focusedCellIndex.current) + } else { + focusCell(allRenderedRows.length, focusedCellIndex.current) + } + break + } + case KeyboardKey.Enter: { + const target = event.target as HTMLElement + const closestColumnHeader = target.closest('[role="columnheader"]') + if (closestColumnHeader && closestColumnHeader.getAttribute('data-can-sort')) { + event.preventDefault() + closestColumnHeader.click() + return + } + const currentRowId = currentRow?.id + if (currentRowId) { + event.preventDefault() + handleActivateRow(currentRowId) + } + break + } + case KeyboardKey.Space: { + const target = event.target as HTMLElement + const currentRowId = currentRow?.id + if (!currentRowId) { + return + } + if (target.getAttribute('role') !== 'gridcell') { + return + } + event.preventDefault() + const isCmdOrCtrlPressed = application.keyboardService.isMac ? event.metaKey : event.ctrlKey + if (isCmdOrCtrlPressed && canSelectMultipleRows) { + multiSelectRow(currentRowId) + } else if (event.shiftKey && canSelectMultipleRows) { + rangeSelectUpToRow(currentRowId) + } else { + selectRow(currentRowId) + } + break + } + } + }, + [ + application.keyboardService.isMac, + canSelectMultipleRows, + colCount, + handleActivateRow, + headers.length, + multiSelectRow, + rangeSelectUpToRow, + rowCount, + selectRow, + ], + ) + + const handleRowClick = useCallback( + (event: React.MouseEvent, rowId: string) => { + if (!canSelectRows) { + return + } + const isCmdOrCtrlPressed = application.keyboardService.isMac ? event.metaKey : event.ctrlKey + if (isCmdOrCtrlPressed && canSelectMultipleRows) { + multiSelectRow(rowId) + } else if (event.shiftKey && canSelectMultipleRows) { + rangeSelectUpToRow(rowId) + } else { + selectRow(rowId) + } + }, + [ + application.keyboardService.isMac, + canSelectMultipleRows, + canSelectRows, + multiSelectRow, + rangeSelectUpToRow, + selectRow, + ], + ) + return (
{showSelectionActions && selectedRows.length >= 2 && ( @@ -126,6 +349,10 @@ function Table({ table }: { table: Table }) { aria-colcount={colCount} aria-rowcount={rowCount} aria-multiselectable={canSelectMultipleRows} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown} + id={`table-${id}`} >
{headers @@ -139,13 +366,16 @@ function Table({ table }: { table: Table }) { aria-sort={header.isSorting ? (header.sortReversed ? 'descending' : 'ascending') : 'none'} className={classNames( 'border-b border-border px-3 pt-3 pb-2 text-left text-sm font-medium text-passive-0', - header.sortBy && 'cursor-pointer hover:bg-info-backdrop hover:underline', + header.sortBy && + 'cursor-pointer hover:bg-info-backdrop hover:underline focus:border-info focus:bg-info-backdrop', )} style={{ gridColumn: index + 1, }} onClick={header.onSortChange} key={index.toString()} + data-can-sort={header.sortBy ? true : undefined} + {...(header.sortBy && { tabIndex: index === 0 ? 0 : -1 })} >
{header.name} @@ -170,7 +400,7 @@ function Table({ table }: { table: Table }) { canSelectRows={canSelectRows} handleRowClick={handleRowClick} handleRowContextMenu={handleRowContextMenu} - handleRowDoubleClick={handleRowDoubleClick} + handleActivateRow={handleActivateRow} /> ))}
diff --git a/packages/web/src/javascripts/Components/Table/useTable.tsx b/packages/web/src/javascripts/Components/Table/useTable.tsx index efde8d271..7632cfaba 100644 --- a/packages/web/src/javascripts/Components/Table/useTable.tsx +++ b/packages/web/src/javascripts/Components/Table/useTable.tsx @@ -1,5 +1,5 @@ -import { MouseEventHandler, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' -import { useApplication } from '../ApplicationProvider' +import { UuidGenerator } from '@standardnotes/snjs' +import { MouseEventHandler, ReactNode, useCallback, useEffect, useMemo, useState, useRef } from 'react' import { Table, TableColumn, TableHeader, TableRow, TableSortBy } from './CommonTypes' type TableSortOptions = @@ -34,7 +34,7 @@ type TableSelectionOptions = type TableRowOptions = { getRowId?: (data: Data) => string - onRowDoubleClick?: (data: Data) => void + onRowActivate?: (data: Data) => void onRowContextMenu?: (x: number, y: number, data: Data) => void rowActions?: (data: Data) => ReactNode } @@ -57,14 +57,14 @@ export function useTable({ enableMultipleRowSelection, selectedRowIds, onRowSelectionChange, - onRowDoubleClick, + onRowActivate, onRowContextMenu, rowActions, selectionActions, showSelectionActions, }: UseTableOptions): Table { - const application = useApplication() const [selectedRows, setSelectedRows] = useState(selectedRowIds || []) + const id = useRef(UuidGenerator.GenerateUuid()) useEffect(() => { if (selectedRowIds) { @@ -122,45 +122,55 @@ export function useTable({ [columns, data, enableRowSelection, getRowId, rowActions, selectedRows], ) - const handleRowClick = useCallback( + const selectRow = useCallback( (id: string) => { - const handler: MouseEventHandler = (event) => { - if (!enableRowSelection) { - return - } - const isCmdOrCtrlPressed = application.keyboardService.isMac ? event.metaKey : event.ctrlKey - if (isCmdOrCtrlPressed && enableMultipleRowSelection) { - setSelectedRows((prev) => (prev.includes(id) ? prev.filter((rowId) => rowId !== id) : [...prev, id])) - } else if (event.shiftKey && enableMultipleRowSelection) { - const lastSelectedIndex = rows.findIndex((row) => row.id === selectedRows[selectedRows.length - 1]) - const currentIndex = rows.findIndex((row) => row.id === id) - const start = Math.min(lastSelectedIndex, currentIndex) - const end = Math.max(lastSelectedIndex, currentIndex) - const newSelectedRows = rows.slice(start, end + 1).map((row) => row.id) - setSelectedRows(newSelectedRows) - } else { - setSelectedRows([id]) - } + if (!enableRowSelection) { + return } - return handler + + setSelectedRows([id]) }, - [application.keyboardService.isMac, enableMultipleRowSelection, enableRowSelection, rows, selectedRows], + [enableRowSelection], ) - const handleRowDoubleClick = useCallback( + const multiSelectRow = useCallback( (id: string) => { - const handler: MouseEventHandler = () => { - if (!onRowDoubleClick) { - return - } - const rowData = rows.find((row) => row.id === id)?.rowData - if (rowData) { - onRowDoubleClick(rowData) - } + if (!enableRowSelection || !enableMultipleRowSelection) { + return } - return handler + + setSelectedRows((prev) => (prev.includes(id) ? prev.filter((rowId) => rowId !== id) : [...prev, id])) }, - [onRowDoubleClick, rows], + [enableMultipleRowSelection, enableRowSelection], + ) + + const rangeSelectUpToRow = useCallback( + (id: string) => { + if (!enableRowSelection || !enableMultipleRowSelection) { + return + } + + const lastSelectedIndex = rows.findIndex((row) => row.id === selectedRows[selectedRows.length - 1]) + const currentIndex = rows.findIndex((row) => row.id === id) + const start = Math.min(lastSelectedIndex, currentIndex) + const end = Math.max(lastSelectedIndex, currentIndex) + const newSelectedRows = rows.slice(start, end + 1).map((row) => row.id) + setSelectedRows(newSelectedRows) + }, + [enableMultipleRowSelection, enableRowSelection, rows, selectedRows], + ) + + const handleActivateRow = useCallback( + (id: string) => { + if (!onRowActivate) { + return + } + const rowData = rows.find((row) => row.id === id)?.rowData + if (rowData) { + onRowActivate(rowData) + } + }, + [onRowActivate, rows], ) const handleRowContextMenu = useCallback( @@ -186,12 +196,15 @@ export function useTable({ const table: Table = useMemo( () => ({ + id: id.current, headers, rows, colCount, rowCount, - handleRowClick, - handleRowDoubleClick, + selectRow, + multiSelectRow, + rangeSelectUpToRow, + handleActivateRow, handleRowContextMenu, selectedRows, canSelectRows: enableRowSelection || false, @@ -200,16 +213,18 @@ export function useTable({ showSelectionActions: showSelectionActions || false, }), [ - colCount, - enableMultipleRowSelection, - enableRowSelection, - handleRowClick, - handleRowContextMenu, - handleRowDoubleClick, headers, - rowCount, rows, + colCount, + rowCount, + selectRow, + multiSelectRow, + rangeSelectUpToRow, + handleActivateRow, + handleRowContextMenu, selectedRows, + enableRowSelection, + enableMultipleRowSelection, selectionActions, showSelectionActions, ],