Skip to content

Commit 08a65fd

Browse files
authored
Split scroll context (#467)
* move useFetchCells to ScrollProvider * remove obsolete field * be explicit about the fields * split ScrollContext * remove useMemo where it's not needed * fix condition * fix type * add intermediate Slice component and rename Slice to Table This way, Scroller is more stable, it should not change often. * rename
1 parent 30ea3f6 commit 08a65fd

7 files changed

Lines changed: 345 additions & 320 deletions

File tree

src/components/HighTable.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ViewportSizeProvider } from '../providers/ViewportSizeProvider.js'
1515
import type { HighTableProps } from '../types.js'
1616
import Scroller from './Scroller.js'
1717
import Slice from './Slice.js'
18+
import Table from './Table.js'
1819
import Wrapper from './Wrapper.js'
1920

2021
export default function HighTable({ data, ...props }: HighTableProps) {
@@ -30,7 +31,7 @@ export default function HighTable({ data, ...props }: HighTableProps) {
3031
)
3132
}
3233

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

3637
function State({
@@ -42,6 +43,7 @@ function State({
4243
focus,
4344
numRowsPerPage,
4445
orderBy,
46+
overscan,
4547
padding,
4648
selection,
4749
onCellPositionChange,
@@ -80,7 +82,7 @@ function State({
8082
numRowsPerPage={numRowsPerPage}
8183
onCellPositionChange={onCellPositionChange}
8284
>
83-
<ScrollProvider padding={padding}>
85+
<ScrollProvider padding={padding} onError={onError} overscan={overscan}>
8486
{children}
8587
</ScrollProvider>
8688
</CellNavigationProvider>
@@ -94,15 +96,13 @@ function State({
9496
)
9597
}
9698

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

99101
function DOM({
100102
className = '',
101103
maxRowNumber,
102-
overscan,
103104
styled = true,
104105
onDoubleClickCell,
105-
onError,
106106
onKeyDownCell,
107107
onMouseDownCell,
108108
renderCellContent,
@@ -113,15 +113,15 @@ function DOM({
113113
<div className={styles.topBorder} role="presentation" />
114114

115115
<Scroller>
116-
<Slice
117-
overscan={overscan}
118-
onDoubleClickCell={onDoubleClickCell}
119-
onError={onError}
120-
onKeyDownCell={onKeyDownCell}
121-
onMouseDownCell={onMouseDownCell}
122-
renderCellContent={renderCellContent}
123-
stringify={stringify}
124-
/>
116+
<Slice>
117+
<Table
118+
onDoubleClickCell={onDoubleClickCell}
119+
onKeyDownCell={onKeyDownCell}
120+
onMouseDownCell={onMouseDownCell}
121+
renderCellContent={renderCellContent}
122+
stringify={stringify}
123+
/>
124+
</Slice>
125125
</Scroller>
126126

127127
{/* puts a background behind the row labels column */}

src/components/Scroller.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { KeyboardEvent } from 'react'
22
import { useCallback, useContext, useMemo } from 'react'
33

44
import { CellNavigationContext } from '../contexts/CellNavigationContext.js'
5-
import { ScrollContext } from '../contexts/ScrollContext.js'
5+
import { CanvasHeightContext, SetScrollToContext, SetScrollTopContext } from '../contexts/ScrollContext.js'
66
import { SetViewportSizeContext } from '../contexts/ViewportSizeContext.js'
77
import styles from '../HighTable.module.css'
88

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

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

95-
const sliceTopStyle = useMemo(() => {
96-
return sliceTop !== undefined ? { top: `${sliceTop}px` } : {}
97-
}, [sliceTop])
98-
9998
return (
10099
<div className={styles.tableScroll} ref={viewportRef} role="group" aria-labelledby="caption" onKeyDown={onKeyDown} tabIndex={0}>
101100
<div style={canvasHeightStyle}>
102-
<div style={sliceTopStyle}>
103-
{children}
104-
</div>
101+
{children}
105102
</div>
106103
</div>
107104
)

src/components/Slice.tsx

Lines changed: 14 additions & 238 deletions
Original file line numberDiff line numberDiff line change
@@ -1,246 +1,22 @@
1-
import type { KeyboardEvent } from 'react'
2-
import { useCallback, useContext, useMemo } from 'react'
1+
import { useContext, useMemo } from 'react'
32

4-
import { CellNavigationContext } from '../contexts/CellNavigationContext.js'
5-
import { ColumnsVisibilityContext } from '../contexts/ColumnsVisibilityContext.js'
6-
import { DataFrameMethodsContext, DataVersionContext, NumRowsContext } from '../contexts/DataContext.js'
7-
import { OrderByContext } from '../contexts/OrderByContext.js'
8-
import { ScrollContext } from '../contexts/ScrollContext.js'
9-
import { SelectionContext } from '../contexts/SelectionContext.js'
10-
import { ariaOffset } from '../helpers/constants.js'
11-
import { useFetchCells } from '../hooks/useFetchCells.js'
12-
import type { HighTableProps } from '../types.js'
13-
import { stringify as stringifyDefault } from '../utils/stringify.js'
14-
import Cell from './Cell.js'
15-
import Row from './Row.js'
16-
import RowHeader from './RowHeader.js'
17-
import TableCorner from './TableCorner.js'
18-
import TableHeader from './TableHeader.js'
3+
import { SliceTopContext } from '../contexts/ScrollContext.js'
194

20-
type SliceProps = Pick<HighTableProps, 'onDoubleClickCell' | 'onError' | 'onKeyDownCell' | 'onMouseDownCell' | 'overscan' | 'renderCellContent' | 'stringify'>
21-
22-
export default function Slice({
23-
overscan,
24-
onDoubleClickCell,
25-
onError,
26-
onKeyDownCell,
27-
onMouseDownCell,
28-
renderCellContent,
29-
stringify = stringifyDefault,
30-
}: SliceProps) {
31-
const { moveCell } = useContext(CellNavigationContext)
32-
const orderBy = useContext(OrderByContext)
33-
const { selectable, toggleAllRows, pendingSelectionGesture, onTableKeyDown: onSelectionTableKeyDown, allRowsSelected, isRowSelected, toggleRowNumber, toggleRangeToRowNumber } = useContext(SelectionContext)
34-
const { visibleColumnsParameters: columnsParameters } = useContext(ColumnsVisibilityContext)
35-
const { renderedRowsStart, renderedRowsEnd } = useContext(ScrollContext)
36-
/** A version number that increments whenever a data frame is updated or resolved (the key remains the same). */
37-
const version = useContext(DataVersionContext)
38-
/** The actual number of rows in the data frame */
39-
const numRows = useContext(NumRowsContext)
40-
const dataFrameMethods = useContext(DataFrameMethodsContext)
41-
42-
// Fetch the required cells if needed (visible + overscan)
43-
// it's a side-effect.
44-
useFetchCells({ overscan, onError })
45-
46-
const onNavigationTableKeyDown = useMemo(() => {
47-
if (!moveCell) {
48-
// disable keyboard navigation if moveCell is not provided
49-
return
50-
}
51-
return (event: KeyboardEvent) => {
52-
const { key, altKey, ctrlKey, metaKey, shiftKey } = event
53-
// if the user is pressing Alt, Meta or Shift, do not handle the event
54-
if (altKey || metaKey || shiftKey) {
55-
return
56-
}
57-
if (key === 'ArrowRight') {
58-
if (ctrlKey) {
59-
moveCell({ type: 'LAST_COLUMN' })
60-
} else {
61-
moveCell({ type: 'NEXT_COLUMN' })
62-
}
63-
} else if (key === 'ArrowLeft') {
64-
if (ctrlKey) {
65-
moveCell({ type: 'FIRST_COLUMN' })
66-
} else {
67-
moveCell({ type: 'PREVIOUS_COLUMN' })
68-
}
69-
} else if (key === 'ArrowDown') {
70-
if (ctrlKey) {
71-
moveCell({ type: 'LAST_ROW' })
72-
} else {
73-
moveCell({ type: 'NEXT_ROW' })
74-
}
75-
} else if (key === 'ArrowUp') {
76-
if (ctrlKey) {
77-
moveCell({ type: 'FIRST_ROW' })
78-
} else {
79-
moveCell({ type: 'PREVIOUS_ROW' })
80-
}
81-
} else if (key === 'Home') {
82-
if (ctrlKey) {
83-
moveCell({ type: 'FIRST_CELL' })
84-
} else {
85-
moveCell({ type: 'FIRST_COLUMN' })
86-
}
87-
} else if (key === 'End') {
88-
if (ctrlKey) {
89-
moveCell({ type: 'LAST_CELL' })
90-
} else {
91-
moveCell({ type: 'LAST_COLUMN' })
92-
}
93-
} else if (key === 'PageDown') {
94-
moveCell({ type: 'NEXT_ROWS_PAGE' })
95-
// TODO(SL): same for horizontal scrolling with Alt+PageDown?
96-
} else if (key === 'PageUp') {
97-
moveCell({ type: 'PREVIOUS_ROWS_PAGE' })
98-
// TODO(SL): same for horizontal scrolling with Alt+PageUp?
99-
} else if (key !== ' ') {
100-
// if the key is not one of the above, do not handle it
101-
// special case: no action is associated with the Space key, but it's captured
102-
// anyway to prevent the default action (scrolling the page) and stay in navigation mode
103-
return
104-
}
105-
// avoid scrolling the table when the user is navigating with the keyboard
106-
event.stopPropagation()
107-
event.preventDefault()
108-
}
109-
}, [moveCell])
110-
111-
const onTableKeyDown = useMemo(() => {
112-
if (onNavigationTableKeyDown || onSelectionTableKeyDown) {
113-
return (event: KeyboardEvent) => {
114-
onNavigationTableKeyDown?.(event)
115-
onSelectionTableKeyDown?.(event)
116-
}
117-
}
118-
}, [onNavigationTableKeyDown, onSelectionTableKeyDown])
119-
120-
const getOnCheckboxPress = useCallback(({ row, rowNumber }: { row: number, rowNumber?: number }) => {
121-
if (rowNumber === undefined || !toggleRowNumber || !toggleRangeToRowNumber) {
122-
return undefined
123-
}
124-
return ({ shiftKey }: { shiftKey: boolean }) => {
125-
if (shiftKey) {
126-
toggleRangeToRowNumber({ row, rowNumber })
127-
} else {
128-
toggleRowNumber({ rowNumber })
129-
}
130-
}
131-
}, [toggleRowNumber, toggleRangeToRowNumber])
132-
133-
// Prepare the slice of data to render
134-
// TODO(SL): also compute progress percentage here, to show a loading indicator
135-
const slice = useMemo(() => {
136-
if (renderedRowsStart === undefined || renderedRowsEnd === undefined) {
137-
return {
138-
rowContents: [],
139-
canMeasureColumn: {},
140-
version,
141-
}
142-
}
143-
const rows = Array.from({ length: renderedRowsEnd - renderedRowsStart }, (_, i) => renderedRowsStart + i)
5+
interface Props {
6+
/** Child components */
7+
children?: React.ReactNode
8+
}
1449

145-
const canMeasureColumn: Record<string, boolean> = {}
146-
const rowContents = rows.map((row) => {
147-
const rowNumber = dataFrameMethods.getRowNumber({ row, orderBy })?.value
148-
const cells = (columnsParameters ?? []).map(({ name: column, index: originalColumnIndex, className }) => {
149-
const cell = dataFrameMethods.getCell({ row, column, orderBy })
150-
canMeasureColumn[column] ||= cell !== undefined
151-
return { columnIndex: originalColumnIndex, cell, className }
152-
})
153-
return {
154-
row,
155-
rowNumber,
156-
cells,
157-
}
158-
})
159-
return {
160-
rowContents,
161-
canMeasureColumn,
162-
version,
163-
}
164-
}, [dataFrameMethods, columnsParameters, renderedRowsStart, renderedRowsEnd, orderBy, version])
10+
export default function Slice({ children }: Props) {
11+
const sliceTop = useContext(SliceTopContext)
16512

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

170-
const ariaColCount = columnsParameters.length + 1 // don't forget the selection column
171-
const ariaRowCount = numRows + 1 // don't forget the header row
17217
return (
173-
<table
174-
aria-readonly={true}
175-
aria-colcount={ariaColCount}
176-
aria-rowcount={ariaRowCount}
177-
aria-multiselectable={selectable}
178-
aria-busy={pendingSelectionGesture /* TODO(SL): add other busy states? Used only for tests right now */}
179-
role="grid"
180-
onKeyDown={onTableKeyDown}
181-
>
182-
<caption id="caption" hidden>Virtual-scroll table</caption>
183-
<thead role="rowgroup">
184-
<Row ariaRowIndex={1}>
185-
<TableCorner
186-
onCheckboxPress={toggleAllRows}
187-
checked={allRowsSelected}
188-
pendingSelectionGesture={pendingSelectionGesture}
189-
ariaColIndex={1}
190-
ariaRowIndex={1}
191-
/>
192-
<TableHeader
193-
canMeasureColumn={slice.canMeasureColumn}
194-
columnsParameters={columnsParameters}
195-
ariaRowIndex={1}
196-
/>
197-
</Row>
198-
</thead>
199-
<tbody role="rowgroup">
200-
{slice.rowContents.map(({ row, rowNumber, cells }) => {
201-
const ariaRowIndex = row + ariaOffset
202-
const selected = isRowSelected?.({ rowNumber })
203-
const rowKey = `${row}`
204-
return (
205-
<Row
206-
key={rowKey}
207-
ariaRowIndex={ariaRowIndex}
208-
selected={selected}
209-
rowNumber={rowNumber}
210-
// title={rowError(row, columns.length)} // TODO(SL): re-enable later?
211-
>
212-
<RowHeader
213-
selected={selected}
214-
rowNumber={rowNumber}
215-
onCheckboxPress={getOnCheckboxPress({ rowNumber, row })}
216-
pendingSelectionGesture={pendingSelectionGesture}
217-
ariaColIndex={1}
218-
ariaRowIndex={ariaRowIndex}
219-
/>
220-
{cells.map(({ columnIndex, cell, className }, visibleColumnIndex) => {
221-
return (
222-
<Cell
223-
key={columnIndex}
224-
onDoubleClickCell={onDoubleClickCell}
225-
onMouseDownCell={onMouseDownCell}
226-
onKeyDownCell={onKeyDownCell}
227-
stringify={stringify}
228-
columnIndex={columnIndex}
229-
visibleColumnIndex={visibleColumnIndex}
230-
className={className}
231-
ariaColIndex={visibleColumnIndex + ariaOffset}
232-
ariaRowIndex={ariaRowIndex}
233-
cellValue={cell?.value}
234-
hasResolved={cell !== undefined}
235-
rowNumber={rowNumber}
236-
renderCellContent={renderCellContent}
237-
/>
238-
)
239-
})}
240-
</Row>
241-
)
242-
})}
243-
</tbody>
244-
</table>
18+
<div style={sliceTopStyle}>
19+
{children}
20+
</div>
24521
)
24622
}

0 commit comments

Comments
 (0)