feat: Added keyboard navigation to Files table view (#2131)

This commit is contained in:
Aman Harwara
2023-01-05 23:45:10 +05:30
committed by GitHub
parent bd3df71dd4
commit ba017d42f5
5 changed files with 312 additions and 62 deletions

View File

@@ -10,4 +10,6 @@ export enum KeyboardKey {
Home = 'Home', Home = 'Home',
End = 'End', End = 'End',
Space = ' ', Space = ' ',
PageUp = 'PageUp',
PageDown = 'PageDown',
} }

View File

@@ -310,7 +310,7 @@ const FilesTableView = ({
columns: columnDefs, columns: columnDefs,
enableRowSelection: true, enableRowSelection: true,
enableMultipleRowSelection: true, enableMultipleRowSelection: true,
onRowDoubleClick(file) { onRowActivate(file) {
void filesController.handleFileAction({ void filesController.handleFileAction({
type: FileItemActionType.PreviewFile, type: FileItemActionType.PreviewFile,
payload: { payload: {

View File

@@ -35,12 +35,15 @@ export type TableHeader = {
} }
export type Table<Data> = { export type Table<Data> = {
id: string
headers: TableHeader[] headers: TableHeader[]
rows: TableRow<Data>[] rows: TableRow<Data>[]
rowCount: number rowCount: number
colCount: number colCount: number
handleRowClick: (id: string) => MouseEventHandler<HTMLTableRowElement> selectRow: (id: string) => void
handleRowDoubleClick: (id: string) => MouseEventHandler<HTMLTableRowElement> multiSelectRow: (id: string) => void
rangeSelectUpToRow: (id: string) => void
handleActivateRow: (id: string) => void
handleRowContextMenu: (id: string) => MouseEventHandler<HTMLTableRowElement> handleRowContextMenu: (id: string) => MouseEventHandler<HTMLTableRowElement>
canSelectRows: boolean canSelectRows: boolean
canSelectMultipleRows: boolean canSelectMultipleRows: boolean

View File

@@ -1,5 +1,7 @@
import { classNames } from '@standardnotes/snjs' 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 Icon from '../Icon/Icon'
import { Table, TableRow } from './CommonTypes' import { Table, TableRow } from './CommonTypes'
@@ -9,22 +11,25 @@ function TableRow<Data>({
canSelectRows, canSelectRows,
handleRowClick, handleRowClick,
handleRowContextMenu, handleRowContextMenu,
handleRowDoubleClick, handleActivateRow,
}: { }: {
row: TableRow<Data> row: TableRow<Data>
index: number index: number
canSelectRows: Table<Data>['canSelectRows'] canSelectRows: Table<Data>['canSelectRows']
handleRowClick: Table<Data>['handleRowClick'] handleRowClick: (event: React.MouseEvent<HTMLDivElement, MouseEvent>, id: string) => void
handleRowContextMenu: Table<Data>['handleRowContextMenu'] handleRowContextMenu: Table<Data>['handleRowContextMenu']
handleRowDoubleClick: Table<Data>['handleRowDoubleClick'] handleActivateRow: Table<Data>['handleActivateRow']
}) { }) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [isFocused, setIsFocused] = useState(false)
const isHoveredOrFocused = isHovered || isFocused
const visibleCells = row.cells.filter((cell) => !cell.hidden) const visibleCells = row.cells.filter((cell) => !cell.hidden)
return ( return (
<div <div
role="row" role="row"
id={row.id}
aria-rowindex={rowIndex + 2} aria-rowindex={rowIndex + 2}
{...(canSelectRows ? { 'aria-selected': row.isSelected } : {})} {...(canSelectRows ? { 'aria-selected': row.isSelected } : {})}
className="group relative contents" className="group relative contents"
@@ -34,9 +39,17 @@ function TableRow<Data>({
onMouseLeave={() => { onMouseLeave={() => {
setIsHovered(false) setIsHovered(false)
}} }}
onClick={handleRowClick(row.id)} onClick={(event) => handleRowClick(event, row.id)}
onDoubleClick={handleRowDoubleClick(row.id)} onDoubleClick={() => handleActivateRow(row.id)}
onContextMenu={handleRowContextMenu(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) => { {visibleCells.map((cell, index, array) => {
return ( return (
@@ -46,25 +59,27 @@ function TableRow<Data>({
aria-colindex={cell.colIndex + 1} aria-colindex={cell.colIndex + 1}
key={index} key={index}
className={classNames( 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', row.isSelected && 'bg-info-backdrop',
canSelectRows && 'cursor-pointer', canSelectRows && 'cursor-pointer',
canSelectRows && isHovered && 'bg-contrast', canSelectRows && isHoveredOrFocused && 'bg-contrast',
)} )}
tabIndex={-1}
> >
{cell.render} {cell.render}
{row.rowActions && index === array.length - 1 && ( {row.rowActions && index === array.length - 1 && (
<div <div
className={classNames( className={classNames(
'absolute right-0 top-0 flex h-full items-center p-2', 'absolute right-0 top-0 flex h-full items-center p-2',
row.isSelected ? '' : isHovered ? '' : 'invisible', row.isSelected ? '' : isHoveredOrFocused ? '' : 'invisible',
isFocused && 'visible',
)} )}
> >
<div className="z-[1]">{row.rowActions}</div> <div className="z-[1]">{row.rowActions}</div>
<div <div
className={classNames( className={classNames(
'absolute top-0 right-0 z-0 h-full w-full backdrop-blur-[2px]', 'absolute top-0 right-0 z-0 h-full w-full backdrop-blur-[2px]',
row.isSelected ? '' : isHovered ? '' : 'invisible', row.isSelected ? '' : isHoveredOrFocused ? '' : 'invisible',
)} )}
/> />
</div> </div>
@@ -82,6 +97,8 @@ const PageSize = Math.ceil(document.documentElement.clientHeight / MinTableRowHe
const PageScrollThreshold = 200 const PageScrollThreshold = 200
function Table<Data>({ table }: { table: Table<Data> }) { function Table<Data>({ table }: { table: Table<Data> }) {
const application = useApplication()
const [rowsToDisplay, setRowsToDisplay] = useState<number>(PageSize) const [rowsToDisplay, setRowsToDisplay] = useState<number>(PageSize)
const paginate = useCallback(() => { const paginate = useCallback(() => {
setRowsToDisplay((cellsToDisplay) => cellsToDisplay + PageSize) setRowsToDisplay((cellsToDisplay) => cellsToDisplay + PageSize)
@@ -98,13 +115,16 @@ function Table<Data>({ table }: { table: Table<Data> }) {
) )
const { const {
id,
headers, headers,
rows, rows,
colCount, colCount,
rowCount, rowCount,
handleRowClick, selectRow,
multiSelectRow,
rangeSelectUpToRow,
handleRowContextMenu, handleRowContextMenu,
handleRowDoubleClick, handleActivateRow,
selectedRows, selectedRows,
selectionActions, selectionActions,
canSelectRows, canSelectRows,
@@ -112,6 +132,209 @@ function Table<Data>({ table }: { table: Table<Data> }) {
showSelectionActions, showSelectionActions,
} = table } = table
const focusedRowIndex = useRef<number>(0)
const focusedCellIndex = useRef<number>(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<HTMLElement>('[role="row"]')
const currentRow = Array.from(allRenderedRows).find(
(row) => row.getAttribute('aria-rowindex') === focusedRowIndex.current.toString(),
)
const allFocusableCells = currentRow?.querySelectorAll<HTMLElement>('[tabindex]')
const focusCell = (rowIndex: number, colIndex: number) => {
const row = gridElement.querySelector(`[role="row"][aria-rowindex="${rowIndex}"]`)
if (!row) {
return
}
const cell = row.querySelector<HTMLElement>(`[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<HTMLElement>('[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 ( return (
<div className="block min-h-0 overflow-auto" onScroll={onScroll}> <div className="block min-h-0 overflow-auto" onScroll={onScroll}>
{showSelectionActions && selectedRows.length >= 2 && ( {showSelectionActions && selectedRows.length >= 2 && (
@@ -126,6 +349,10 @@ function Table<Data>({ table }: { table: Table<Data> }) {
aria-colcount={colCount} aria-colcount={colCount}
aria-rowcount={rowCount} aria-rowcount={rowCount}
aria-multiselectable={canSelectMultipleRows} aria-multiselectable={canSelectMultipleRows}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
id={`table-${id}`}
> >
<div role="row" aria-rowindex={1} className="contents"> <div role="row" aria-rowindex={1} className="contents">
{headers {headers
@@ -139,13 +366,16 @@ function Table<Data>({ table }: { table: Table<Data> }) {
aria-sort={header.isSorting ? (header.sortReversed ? 'descending' : 'ascending') : 'none'} aria-sort={header.isSorting ? (header.sortReversed ? 'descending' : 'ascending') : 'none'}
className={classNames( className={classNames(
'border-b border-border px-3 pt-3 pb-2 text-left text-sm font-medium text-passive-0', '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={{ style={{
gridColumn: index + 1, gridColumn: index + 1,
}} }}
onClick={header.onSortChange} onClick={header.onSortChange}
key={index.toString()} key={index.toString()}
data-can-sort={header.sortBy ? true : undefined}
{...(header.sortBy && { tabIndex: index === 0 ? 0 : -1 })}
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{header.name} {header.name}
@@ -170,7 +400,7 @@ function Table<Data>({ table }: { table: Table<Data> }) {
canSelectRows={canSelectRows} canSelectRows={canSelectRows}
handleRowClick={handleRowClick} handleRowClick={handleRowClick}
handleRowContextMenu={handleRowContextMenu} handleRowContextMenu={handleRowContextMenu}
handleRowDoubleClick={handleRowDoubleClick} handleActivateRow={handleActivateRow}
/> />
))} ))}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { MouseEventHandler, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { UuidGenerator } from '@standardnotes/snjs'
import { useApplication } from '../ApplicationProvider' import { MouseEventHandler, ReactNode, useCallback, useEffect, useMemo, useState, useRef } from 'react'
import { Table, TableColumn, TableHeader, TableRow, TableSortBy } from './CommonTypes' import { Table, TableColumn, TableHeader, TableRow, TableSortBy } from './CommonTypes'
type TableSortOptions = type TableSortOptions =
@@ -34,7 +34,7 @@ type TableSelectionOptions =
type TableRowOptions<Data> = { type TableRowOptions<Data> = {
getRowId?: (data: Data) => string getRowId?: (data: Data) => string
onRowDoubleClick?: (data: Data) => void onRowActivate?: (data: Data) => void
onRowContextMenu?: (x: number, y: number, data: Data) => void onRowContextMenu?: (x: number, y: number, data: Data) => void
rowActions?: (data: Data) => ReactNode rowActions?: (data: Data) => ReactNode
} }
@@ -57,14 +57,14 @@ export function useTable<Data>({
enableMultipleRowSelection, enableMultipleRowSelection,
selectedRowIds, selectedRowIds,
onRowSelectionChange, onRowSelectionChange,
onRowDoubleClick, onRowActivate,
onRowContextMenu, onRowContextMenu,
rowActions, rowActions,
selectionActions, selectionActions,
showSelectionActions, showSelectionActions,
}: UseTableOptions<Data>): Table<Data> { }: UseTableOptions<Data>): Table<Data> {
const application = useApplication()
const [selectedRows, setSelectedRows] = useState<string[]>(selectedRowIds || []) const [selectedRows, setSelectedRows] = useState<string[]>(selectedRowIds || [])
const id = useRef(UuidGenerator.GenerateUuid())
useEffect(() => { useEffect(() => {
if (selectedRowIds) { if (selectedRowIds) {
@@ -122,45 +122,55 @@ export function useTable<Data>({
[columns, data, enableRowSelection, getRowId, rowActions, selectedRows], [columns, data, enableRowSelection, getRowId, rowActions, selectedRows],
) )
const handleRowClick = useCallback( const selectRow = useCallback(
(id: string) => { (id: string) => {
const handler: MouseEventHandler<HTMLTableRowElement> = (event) => { if (!enableRowSelection) {
if (!enableRowSelection) { return
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])
}
} }
return handler
setSelectedRows([id])
}, },
[application.keyboardService.isMac, enableMultipleRowSelection, enableRowSelection, rows, selectedRows], [enableRowSelection],
) )
const handleRowDoubleClick = useCallback( const multiSelectRow = useCallback(
(id: string) => { (id: string) => {
const handler: MouseEventHandler<HTMLTableRowElement> = () => { if (!enableRowSelection || !enableMultipleRowSelection) {
if (!onRowDoubleClick) { return
return
}
const rowData = rows.find((row) => row.id === id)?.rowData
if (rowData) {
onRowDoubleClick(rowData)
}
} }
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( const handleRowContextMenu = useCallback(
@@ -186,12 +196,15 @@ export function useTable<Data>({
const table: Table<Data> = useMemo( const table: Table<Data> = useMemo(
() => ({ () => ({
id: id.current,
headers, headers,
rows, rows,
colCount, colCount,
rowCount, rowCount,
handleRowClick, selectRow,
handleRowDoubleClick, multiSelectRow,
rangeSelectUpToRow,
handleActivateRow,
handleRowContextMenu, handleRowContextMenu,
selectedRows, selectedRows,
canSelectRows: enableRowSelection || false, canSelectRows: enableRowSelection || false,
@@ -200,16 +213,18 @@ export function useTable<Data>({
showSelectionActions: showSelectionActions || false, showSelectionActions: showSelectionActions || false,
}), }),
[ [
colCount,
enableMultipleRowSelection,
enableRowSelection,
handleRowClick,
handleRowContextMenu,
handleRowDoubleClick,
headers, headers,
rowCount,
rows, rows,
colCount,
rowCount,
selectRow,
multiSelectRow,
rangeSelectUpToRow,
handleActivateRow,
handleRowContextMenu,
selectedRows, selectedRows,
enableRowSelection,
enableMultipleRowSelection,
selectionActions, selectionActions,
showSelectionActions, showSelectionActions,
], ],