Files
standardnotes-app-web/packages/web/src/javascripts/Components/Table/Table.tsx
2026-03-06 15:17:55 -03:00

436 lines
14 KiB
TypeScript

import { classNames } from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services'
import { useCallback, useState, useRef } from 'react'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import { Table as TableType, TableRow as TableRowType } from './CommonTypes'
function TableRow<Data>({
row,
index: rowIndex,
canSelectRows,
handleRowClick,
handleRowContextMenu,
handleActivateRow,
}: {
row: TableRowType<Data>
index: number
canSelectRows: TableType<Data>['canSelectRows']
handleRowClick: (event: React.MouseEvent<HTMLDivElement, MouseEvent>, id: string) => void
handleRowContextMenu: TableType<Data>['handleRowContextMenu']
handleActivateRow: TableType<Data>['handleActivateRow']
}) {
const [isHovered, setIsHovered] = useState(false)
const [isFocused, setIsFocused] = useState(false)
const isHoveredOrFocused = isHovered || isFocused
const visibleCells = row.cells.filter((cell) => !cell.hidden)
return (
<div
role="row"
id={row.id}
aria-rowindex={rowIndex + 2}
{...(canSelectRows ? { 'aria-selected': row.isSelected } : {})}
className="group relative contents"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
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 (
<div
role="gridcell"
aria-rowindex={rowIndex + 2}
aria-colindex={cell.colIndex + 1}
key={index}
className={classNames(
'relative flex items-center overflow-hidden border-b border-border px-3 py-4 focus:border-info',
row.isSelected && 'bg-info-backdrop',
canSelectRows && 'cursor-pointer',
canSelectRows && isHoveredOrFocused && 'bg-contrast',
)}
tabIndex={-1}
>
{cell.render}
{row.rowActions && index === array.length - 1 && (
<div
className={classNames(
'absolute right-0 top-0 flex h-full items-center p-2',
row.isSelected ? '' : isHoveredOrFocused ? '' : 'invisible',
isFocused && 'visible',
)}
>
<div className="z-[1]">{row.rowActions}</div>
<div
className={classNames(
'absolute right-0 top-0 z-0 h-full w-full backdrop-blur-[2px]',
row.isSelected ? '' : isHoveredOrFocused ? '' : 'invisible',
)}
/>
</div>
)}
</div>
)
})}
</div>
)
}
const MinTableRowHeight = 50
const MinRowsToDisplay = 20
const PageSize = Math.ceil(document.documentElement.clientHeight / MinTableRowHeight) || MinRowsToDisplay
const PageScrollThreshold = 200
function Table<Data>({ table }: { table: TableType<Data> }) {
const application = useApplication()
const [rowsToDisplay, setRowsToDisplay] = useState<number>(PageSize)
const paginate = useCallback(() => {
setRowsToDisplay((cellsToDisplay) => cellsToDisplay + PageSize)
}, [])
const onScroll = useCallback(
(event: React.UIEvent<HTMLDivElement, UIEvent>) => {
const offset = PageScrollThreshold
const element = event.target as HTMLElement
if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) {
paginate()
}
},
[paginate],
)
const {
id,
headers,
rows,
colCount,
rowCount,
selectRow,
multiSelectRow,
rangeSelectUpToRow,
handleRowContextMenu,
handleActivateRow,
selectedRows,
selectionActions,
canSelectRows,
canSelectMultipleRows,
showSelectionActions,
} = 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 = Array.from(currentRow ? currentRow.querySelectorAll<HTMLElement>('[tabindex]') : [])
const allRenderedColumnsLength = headers.length
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 (!allFocusableCells) {
return
}
const currentCellIndex = allFocusableCells.findIndex(
(cell) => parseInt(cell.getAttribute('aria-colindex') || '0') === focusedCellIndex.current,
)
if (currentCellIndex === 0) {
return
}
const previousCell = allFocusableCells[currentCellIndex - 1]
if (!previousCell) {
return
}
previousCell.focus()
break
}
case KeyboardKey.Right: {
event.preventDefault()
if (!allFocusableCells) {
return
}
const currentCellIndex = allFocusableCells.findIndex(
(cell) => parseInt(cell.getAttribute('aria-colindex') || '0') === focusedCellIndex.current,
)
if (currentCellIndex === allFocusableCells.length - 1) {
return
}
const nextCell = allFocusableCells[currentCellIndex + 1]
if (!nextCell) {
return
}
nextCell.focus()
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, allRenderedColumnsLength || 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 (
<div className="block min-h-0 overflow-auto" onScroll={onScroll}>
{showSelectionActions && selectedRows.length >= 2 && (
<div className="sticky top-0 z-[2] flex items-center justify-between border-b border-border bg-default px-3 py-2">
<span className="text-info-0 text-sm font-medium">{selectedRows.length} selected</span>
{selectedRows.length > 0 && selectionActions}
</div>
)}
<div
className="relative grid w-full overflow-x-hidden px-3"
role="grid"
aria-colcount={colCount}
aria-rowcount={rowCount}
aria-multiselectable={canSelectMultipleRows}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
id={`table-${id}`}
>
<div role="row" aria-rowindex={1} className="contents">
{headers
.filter((header) => !header.hidden)
.map((header, index) => {
return (
<div
role="columnheader"
aria-rowindex={1}
aria-colindex={header.colIndex + 1}
aria-sort={header.isSorting ? (header.sortReversed ? 'descending' : 'ascending') : 'none'}
className={classNames(
'border-b border-border px-3 pb-2 pt-3 text-left text-sm font-medium text-passive-0',
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 })}
>
<div className="flex items-center gap-1">
{header.name}
{header.isSorting && (
<Icon
type={header.sortReversed ? 'arrow-up' : 'arrow-down'}
size="custom"
className="h-4.5 w-4.5 text-passive-1"
/>
)}
</div>
</div>
)
})}
</div>
<div className="contents whitespace-nowrap">
{rows.slice(0, rowsToDisplay).map((row, index) => (
<TableRow
row={row}
key={row.id}
index={index}
canSelectRows={canSelectRows}
handleRowClick={handleRowClick}
handleRowContextMenu={handleRowContextMenu}
handleActivateRow={handleActivateRow}
/>
))}
</div>
</div>
</div>
)
}
export default Table