Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions src/components/HighTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ViewportSizeProvider } from '../providers/ViewportSizeProvider.js'
import type { HighTableProps } from '../types.js'
import Scroller from './Scroller.js'
import Slice from './Slice.js'
import Table from './Table.js'
import Wrapper from './Wrapper.js'

export default function HighTable({ data, ...props }: HighTableProps) {
Expand All @@ -30,7 +31,7 @@ export default function HighTable({ data, ...props }: HighTableProps) {
)
}

type StateProps = Pick<HighTableProps, 'columnConfiguration' | 'cacheKey' | 'cellPosition' | 'columnsVisibility' | 'focus' | 'numRowsPerPage' | 'orderBy' | 'padding' | 'selection' | 'onCellPositionChange' | 'onColumnsVisibilityChange' | 'onError' | 'onOrderByChange' | 'onSelectionChange'>
type StateProps = Pick<HighTableProps, 'columnConfiguration' | 'cacheKey' | 'cellPosition' | 'columnsVisibility' | 'focus' | 'numRowsPerPage' | 'orderBy' | 'overscan' | 'padding' | 'selection' | 'onCellPositionChange' | 'onColumnsVisibilityChange' | 'onError' | 'onOrderByChange' | 'onSelectionChange'>
& { children: ReactNode }

function State({
Expand All @@ -42,6 +43,7 @@ function State({
focus,
numRowsPerPage,
orderBy,
overscan,
padding,
selection,
onCellPositionChange,
Expand Down Expand Up @@ -80,7 +82,7 @@ function State({
numRowsPerPage={numRowsPerPage}
onCellPositionChange={onCellPositionChange}
>
<ScrollProvider padding={padding}>
<ScrollProvider padding={padding} onError={onError} overscan={overscan}>
{children}
</ScrollProvider>
</CellNavigationProvider>
Expand All @@ -94,15 +96,13 @@ function State({
)
}

type DOMProps = Pick<HighTableProps, 'className' | 'maxRowNumber' | 'onError' | 'styled' | 'onDoubleClickCell' | 'onKeyDownCell' | 'onMouseDownCell' | 'overscan' | 'renderCellContent' | 'stringify'>
type DOMProps = Pick<HighTableProps, 'className' | 'maxRowNumber' | 'styled' | 'onDoubleClickCell' | 'onKeyDownCell' | 'onMouseDownCell' | 'renderCellContent' | 'stringify'>

function DOM({
className = '',
maxRowNumber,
overscan,
styled = true,
onDoubleClickCell,
onError,
onKeyDownCell,
onMouseDownCell,
renderCellContent,
Expand All @@ -113,15 +113,15 @@ function DOM({
<div className={styles.topBorder} role="presentation" />

<Scroller>
<Slice
overscan={overscan}
onDoubleClickCell={onDoubleClickCell}
onError={onError}
onKeyDownCell={onKeyDownCell}
onMouseDownCell={onMouseDownCell}
renderCellContent={renderCellContent}
stringify={stringify}
/>
<Slice>
<Table
onDoubleClickCell={onDoubleClickCell}
onKeyDownCell={onKeyDownCell}
onMouseDownCell={onMouseDownCell}
renderCellContent={renderCellContent}
stringify={stringify}
/>
</Slice>
</Scroller>

{/* puts a background behind the row labels column */}
Expand Down
15 changes: 6 additions & 9 deletions src/components/Scroller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { KeyboardEvent } from 'react'
import { useCallback, useContext, useMemo } from 'react'

import { CellNavigationContext } from '../contexts/CellNavigationContext.js'
import { ScrollContext } from '../contexts/ScrollContext.js'
import { CanvasHeightContext, SetScrollToContext, SetScrollTopContext } from '../contexts/ScrollContext.js'
import { SetViewportSizeContext } from '../contexts/ViewportSizeContext.js'
import styles from '../HighTable.module.css'

Expand All @@ -14,8 +14,11 @@ interface Props {
export default function Scroller({ children }: Props) {
/** Callback to set the current viewport size */
const setViewportSize = useContext(SetViewportSizeContext)
// TODO(SL): get a stable function to go to current cell (maybe dispatch('ENTER_CELL_NAVIGATION_MODE'), or setMode('cells'))
const { goToCurrentCell } = useContext(CellNavigationContext)
const { canvasHeight, sliceTop, setScrollTop, setScrollTo } = useContext(ScrollContext)
const setScrollTop = useContext(SetScrollTopContext)
const setScrollTo = useContext(SetScrollToContext)
const canvasHeight = useContext(CanvasHeightContext)

/**
* Handle keyboard events for scrolling
Expand Down Expand Up @@ -92,16 +95,10 @@ export default function Scroller({ children }: Props) {
return canvasHeight !== undefined ? { height: `${canvasHeight}px` } : {}
}, [canvasHeight])

const sliceTopStyle = useMemo(() => {
return sliceTop !== undefined ? { top: `${sliceTop}px` } : {}
}, [sliceTop])

return (
<div className={styles.tableScroll} ref={viewportRef} role="group" aria-labelledby="caption" onKeyDown={onKeyDown} tabIndex={0}>
<div style={canvasHeightStyle}>
<div style={sliceTopStyle}>
{children}
</div>
{children}
</div>
</div>
)
Expand Down
252 changes: 14 additions & 238 deletions src/components/Slice.tsx
Original file line number Diff line number Diff line change
@@ -1,246 +1,22 @@
import type { KeyboardEvent } from 'react'
import { useCallback, useContext, useMemo } from 'react'
import { useContext, useMemo } from 'react'

import { CellNavigationContext } from '../contexts/CellNavigationContext.js'
import { ColumnsVisibilityContext } from '../contexts/ColumnsVisibilityContext.js'
import { DataFrameMethodsContext, DataVersionContext, NumRowsContext } from '../contexts/DataContext.js'
import { OrderByContext } from '../contexts/OrderByContext.js'
import { ScrollContext } from '../contexts/ScrollContext.js'
import { SelectionContext } from '../contexts/SelectionContext.js'
import { ariaOffset } from '../helpers/constants.js'
import { useFetchCells } from '../hooks/useFetchCells.js'
import type { HighTableProps } from '../types.js'
import { stringify as stringifyDefault } from '../utils/stringify.js'
import Cell from './Cell.js'
import Row from './Row.js'
import RowHeader from './RowHeader.js'
import TableCorner from './TableCorner.js'
import TableHeader from './TableHeader.js'
import { SliceTopContext } from '../contexts/ScrollContext.js'

type SliceProps = Pick<HighTableProps, 'onDoubleClickCell' | 'onError' | 'onKeyDownCell' | 'onMouseDownCell' | 'overscan' | 'renderCellContent' | 'stringify'>

export default function Slice({
overscan,
onDoubleClickCell,
onError,
onKeyDownCell,
onMouseDownCell,
renderCellContent,
stringify = stringifyDefault,
}: SliceProps) {
const { moveCell } = useContext(CellNavigationContext)
const orderBy = useContext(OrderByContext)
const { selectable, toggleAllRows, pendingSelectionGesture, onTableKeyDown: onSelectionTableKeyDown, allRowsSelected, isRowSelected, toggleRowNumber, toggleRangeToRowNumber } = useContext(SelectionContext)
const { visibleColumnsParameters: columnsParameters } = useContext(ColumnsVisibilityContext)
const { renderedRowsStart, renderedRowsEnd } = useContext(ScrollContext)
/** A version number that increments whenever a data frame is updated or resolved (the key remains the same). */
const version = useContext(DataVersionContext)
/** The actual number of rows in the data frame */
const numRows = useContext(NumRowsContext)
const dataFrameMethods = useContext(DataFrameMethodsContext)

// Fetch the required cells if needed (visible + overscan)
// it's a side-effect.
useFetchCells({ overscan, onError })

const onNavigationTableKeyDown = useMemo(() => {
if (!moveCell) {
// disable keyboard navigation if moveCell is not provided
return
}
return (event: KeyboardEvent) => {
const { key, altKey, ctrlKey, metaKey, shiftKey } = event
// if the user is pressing Alt, Meta or Shift, do not handle the event
if (altKey || metaKey || shiftKey) {
return
}
if (key === 'ArrowRight') {
if (ctrlKey) {
moveCell({ type: 'LAST_COLUMN' })
} else {
moveCell({ type: 'NEXT_COLUMN' })
}
} else if (key === 'ArrowLeft') {
if (ctrlKey) {
moveCell({ type: 'FIRST_COLUMN' })
} else {
moveCell({ type: 'PREVIOUS_COLUMN' })
}
} else if (key === 'ArrowDown') {
if (ctrlKey) {
moveCell({ type: 'LAST_ROW' })
} else {
moveCell({ type: 'NEXT_ROW' })
}
} else if (key === 'ArrowUp') {
if (ctrlKey) {
moveCell({ type: 'FIRST_ROW' })
} else {
moveCell({ type: 'PREVIOUS_ROW' })
}
} else if (key === 'Home') {
if (ctrlKey) {
moveCell({ type: 'FIRST_CELL' })
} else {
moveCell({ type: 'FIRST_COLUMN' })
}
} else if (key === 'End') {
if (ctrlKey) {
moveCell({ type: 'LAST_CELL' })
} else {
moveCell({ type: 'LAST_COLUMN' })
}
} else if (key === 'PageDown') {
moveCell({ type: 'NEXT_ROWS_PAGE' })
// TODO(SL): same for horizontal scrolling with Alt+PageDown?
} else if (key === 'PageUp') {
moveCell({ type: 'PREVIOUS_ROWS_PAGE' })
// TODO(SL): same for horizontal scrolling with Alt+PageUp?
} else if (key !== ' ') {
// if the key is not one of the above, do not handle it
// special case: no action is associated with the Space key, but it's captured
// anyway to prevent the default action (scrolling the page) and stay in navigation mode
return
}
// avoid scrolling the table when the user is navigating with the keyboard
event.stopPropagation()
event.preventDefault()
}
}, [moveCell])

const onTableKeyDown = useMemo(() => {
if (onNavigationTableKeyDown || onSelectionTableKeyDown) {
return (event: KeyboardEvent) => {
onNavigationTableKeyDown?.(event)
onSelectionTableKeyDown?.(event)
}
}
}, [onNavigationTableKeyDown, onSelectionTableKeyDown])

const getOnCheckboxPress = useCallback(({ row, rowNumber }: { row: number, rowNumber?: number }) => {
if (rowNumber === undefined || !toggleRowNumber || !toggleRangeToRowNumber) {
return undefined
}
return ({ shiftKey }: { shiftKey: boolean }) => {
if (shiftKey) {
toggleRangeToRowNumber({ row, rowNumber })
} else {
toggleRowNumber({ rowNumber })
}
}
}, [toggleRowNumber, toggleRangeToRowNumber])

// Prepare the slice of data to render
// TODO(SL): also compute progress percentage here, to show a loading indicator
const slice = useMemo(() => {
if (renderedRowsStart === undefined || renderedRowsEnd === undefined) {
return {
rowContents: [],
canMeasureColumn: {},
version,
}
}
const rows = Array.from({ length: renderedRowsEnd - renderedRowsStart }, (_, i) => renderedRowsStart + i)
interface Props {
/** Child components */
children?: React.ReactNode
}

const canMeasureColumn: Record<string, boolean> = {}
const rowContents = rows.map((row) => {
const rowNumber = dataFrameMethods.getRowNumber({ row, orderBy })?.value
const cells = (columnsParameters ?? []).map(({ name: column, index: originalColumnIndex, className }) => {
const cell = dataFrameMethods.getCell({ row, column, orderBy })
canMeasureColumn[column] ||= cell !== undefined
return { columnIndex: originalColumnIndex, cell, className }
})
return {
row,
rowNumber,
cells,
}
})
return {
rowContents,
canMeasureColumn,
version,
}
}, [dataFrameMethods, columnsParameters, renderedRowsStart, renderedRowsEnd, orderBy, version])
export default function Slice({ children }: Props) {
const sliceTop = useContext(SliceTopContext)

// don't render table if the data frame has no visible columns
// (it can have zero rows, but must have at least one visible column)
if (!columnsParameters) return
const sliceTopStyle = useMemo(() => {
return sliceTop !== undefined ? { top: `${sliceTop}px` } : {}
}, [sliceTop])

const ariaColCount = columnsParameters.length + 1 // don't forget the selection column
const ariaRowCount = numRows + 1 // don't forget the header row
return (
<table
aria-readonly={true}
aria-colcount={ariaColCount}
aria-rowcount={ariaRowCount}
aria-multiselectable={selectable}
aria-busy={pendingSelectionGesture /* TODO(SL): add other busy states? Used only for tests right now */}
role="grid"
onKeyDown={onTableKeyDown}
>
<caption id="caption" hidden>Virtual-scroll table</caption>
<thead role="rowgroup">
<Row ariaRowIndex={1}>
<TableCorner
onCheckboxPress={toggleAllRows}
checked={allRowsSelected}
pendingSelectionGesture={pendingSelectionGesture}
ariaColIndex={1}
ariaRowIndex={1}
/>
<TableHeader
canMeasureColumn={slice.canMeasureColumn}
columnsParameters={columnsParameters}
ariaRowIndex={1}
/>
</Row>
</thead>
<tbody role="rowgroup">
{slice.rowContents.map(({ row, rowNumber, cells }) => {
const ariaRowIndex = row + ariaOffset
const selected = isRowSelected?.({ rowNumber })
const rowKey = `${row}`
return (
<Row
key={rowKey}
ariaRowIndex={ariaRowIndex}
selected={selected}
rowNumber={rowNumber}
// title={rowError(row, columns.length)} // TODO(SL): re-enable later?
>
<RowHeader
selected={selected}
rowNumber={rowNumber}
onCheckboxPress={getOnCheckboxPress({ rowNumber, row })}
pendingSelectionGesture={pendingSelectionGesture}
ariaColIndex={1}
ariaRowIndex={ariaRowIndex}
/>
{cells.map(({ columnIndex, cell, className }, visibleColumnIndex) => {
return (
<Cell
key={columnIndex}
onDoubleClickCell={onDoubleClickCell}
onMouseDownCell={onMouseDownCell}
onKeyDownCell={onKeyDownCell}
stringify={stringify}
columnIndex={columnIndex}
visibleColumnIndex={visibleColumnIndex}
className={className}
ariaColIndex={visibleColumnIndex + ariaOffset}
ariaRowIndex={ariaRowIndex}
cellValue={cell?.value}
hasResolved={cell !== undefined}
rowNumber={rowNumber}
renderCellContent={renderCellContent}
/>
)
})}
</Row>
)
})}
</tbody>
</table>
<div style={sliceTopStyle}>
{children}
</div>
)
}
Loading