Skip to content

Commit bc41e71

Browse files
authored
[refactor] simplify logic (less useEffect, useRef, and move code) (#368)
* [refactor] extract onScollKeyDown to Scroller * [refactor] remove unnecessary nesting in cell navigation context * don't scroll when focusing the first row, and update comments * [refactor] remove shouldScroll and complex useEffect * [refactor] move code to focus the first cell to CellNavigationProvider, without useEffect, and add tests * [refactor] remove reference to viewportRef * [refactor] use ref callback and remove useEffect * [refactor] replace useRef + useEffect with ref callbacks * apply relevant copilot comments
1 parent 972f110 commit bc41e71

File tree

14 files changed

+469
-305
lines changed

14 files changed

+469
-305
lines changed

src/components/Cell/Cell.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
2-
import { useCallback, useContext, useMemo, useRef } from 'react'
2+
import { useCallback, useContext, useMemo } from 'react'
33

44
import { ColumnWidthsContext } from '../../contexts/ColumnWidthsContext.js'
55
import type { ResolvedValue } from '../../helpers/dataframe/index.js'
@@ -44,8 +44,7 @@ interface Props {
4444
* @param {number} [props.rowNumber] the row index in the original data, undefined if the value has not been fetched yet
4545
*/
4646
export default function Cell({ cell, onDoubleClickCell, onMouseDownCell, onKeyDownCell, stringify, columnIndex, visibleColumnIndex, className, ariaColIndex, ariaRowIndex, rowNumber, renderCellContent }: Props) {
47-
const ref = useRef<HTMLTableCellElement | null>(null)
48-
const { tabIndex, navigateToCell } = useCellFocus({ ref, ariaColIndex, ariaRowIndex })
47+
const { tabIndex, navigateToCell, focusCellIfNeeded } = useCellFocus({ ariaColIndex, ariaRowIndex })
4948

5049
// Get the column width from the context (use visibleColumnIndex for styling)
5150
const columnStyle = useContext(ColumnWidthsContext).getStyle?.(visibleColumnIndex)
@@ -90,7 +89,7 @@ export default function Cell({ cell, onDoubleClickCell, onMouseDownCell, onKeyDo
9089

9190
return (
9291
<td
93-
ref={ref}
92+
ref={focusCellIfNeeded}
9493
role="cell"
9594
aria-busy={cell === undefined}
9695
aria-rowindex={ariaRowIndex}

src/components/ColumnHeader/ColumnHeader.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,17 @@ interface Props {
3131

3232
export default function ColumnHeader({ columnIndex, columnName, columnConfig, canMeasureWidth, direction, toggleOrderBy, orderByIndex, orderBySize, ariaColIndex, ariaRowIndex, className, children }: Props) {
3333
const ref = useRef<HTMLTableCellElement | null>(null)
34-
const { tabIndex, navigateToCell } = useCellFocus({ ref, ariaColIndex, ariaRowIndex })
34+
const { tabIndex, navigateToCell, focusCellIfNeeded } = useCellFocus({ ariaColIndex, ariaRowIndex })
3535
const { sortable } = columnConfig
3636
const { isOpen, position, menuId, close, handleMenuClick } = useColumnMenu(ref, navigateToCell)
3737
const { getHideColumn, showAllColumns } = useContext(ColumnVisibilityStatesContext)
3838

39+
const refCallback = useCallback((node: HTMLTableCellElement | null) => {
40+
focusCellIfNeeded(node)
41+
// set the current ref, it will be used to position the menu in handleMenuClick
42+
ref.current = node
43+
}, [focusCellIfNeeded])
44+
3945
const handleClick = useCallback(() => {
4046
navigateToCell()
4147
if (sortable) toggleOrderBy?.()
@@ -150,7 +156,7 @@ export default function ColumnHeader({ columnIndex, columnName, columnConfig, ca
150156

151157
return (
152158
<th
153-
ref={ref}
159+
ref={refCallback}
154160
scope="col"
155161
role="columnheader"
156162
aria-sort={direction ?? (sortable ? 'none' : undefined)}

src/components/ColumnMenu/ColumnMenu.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export default function ColumnMenu({
126126
break
127127
case 'Enter':
128128
case ' ':
129-
// Handled by the menu item
129+
// Handled by the menu item
130130
break
131131
case 'ArrowUp':
132132
case 'ArrowLeft':
@@ -170,12 +170,14 @@ export default function ColumnMenu({
170170
}
171171
return () => {
172172
hideColumn()
173-
// We focus the top left cell, which will always exist, because this column will disappear
174-
focusFirstCell?.()
173+
174+
// The header cell was focused when opening the menu.
175+
// Move the focus to the left cell to keep ability to navigate with keyboard.
176+
focusFirstCell()
177+
175178
close()
176179
}
177180
}, [hideColumn, close, focusFirstCell])
178-
179181
const showAllColumnsAndClose = useMemo(() => {
180182
if (!showAllColumns) {
181183
return undefined

src/components/HighTable/Scroller.tsx

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { KeyboardEvent } from 'react'
2-
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
2+
import { useCallback, useContext, useMemo, useState } from 'react'
33

44
import { CellNavigationContext } from '../../contexts/CellNavigationContext.js'
55
import { DataContext } from '../../contexts/DataContext.js'
66
import { RowsAndColumnsContext } from '../../contexts/RowsAndColumnsContext.js'
7+
import { ScrollerContext } from '../../contexts/ScrollerContext.js'
78
import styles from '../../HighTable.module.css'
89
import { ariaOffset, rowHeight } from './constants.js'
910

@@ -18,15 +19,10 @@ export default function Scroller({
1819
setViewportWidth,
1920
children,
2021
}: Props) {
21-
// TODO(SL): replace with a callback function (https://react.dev/reference/react-dom/components/common#ref-callback)
22-
const viewportRef = useRef<HTMLDivElement>(null)
23-
24-
const [scrollTop, setScrollTop] = useState<number | undefined>(undefined)
2522
const [scrollToTop, setScrollToTop] = useState<((top: number) => void) | undefined>(undefined)
2623

2724
const { numRows } = useContext(DataContext)
28-
const { onScrollKeyDown } = useContext(CellNavigationContext)
29-
const { shouldScroll, setShouldScroll, cellPosition } = useContext(CellNavigationContext)
25+
const { setShouldFocus, rowIndex } = useContext(CellNavigationContext)
3026
const { fetchedRowsRange, renderedRowsRange, setVisibleRowsRange } = useContext(RowsAndColumnsContext)
3127

3228
/**
@@ -61,52 +57,55 @@ export default function Scroller({
6157
}, [numRows, scrollHeight, setVisibleRowsRange])
6258

6359
/**
64-
* Handle keyboard events for scrolling
60+
* Vertically scroll to bring a specific row into view
6561
*/
66-
const onKeyDown = useCallback((event: KeyboardEvent) => {
67-
if (event.target !== viewportRef.current) {
68-
// don't handle the event if the target is not the scroller
62+
const scrollRowIntoView = useCallback(({ rowIndex }: { rowIndex: number }) => {
63+
if (scrollToTop === undefined || fetchedRowsRange === undefined) {
6964
return
7065
}
71-
onScrollKeyDown?.(event)
72-
}, [onScrollKeyDown, viewportRef])
73-
74-
/**
75-
* React to cell navigation changes to scroll to the focused cell
76-
*
77-
* scroll if the navigation cell changed, or if entering navigation mode
78-
* this excludes the case where the whole table is focused (not in cell navigation mode), the user
79-
* is scrolling with the mouse or the arrow keys, and the cell exits the viewport: don't want to scroll
80-
* back to it
81-
*/
82-
useEffect(() => {
83-
if (!shouldScroll || scrollTop === undefined || scrollToTop === undefined || fetchedRowsRange === undefined) {
66+
if (rowIndex < 1) {
67+
throw new Error(`invalid rowIndex ${rowIndex}`)
68+
}
69+
if (rowIndex === 1) {
70+
// always visible
8471
return
8572
}
86-
setShouldScroll?.(false)
87-
const row = cellPosition.rowIndex - ariaOffset
88-
let nextScrollTop = scrollTop
73+
// should be zero-based
74+
const row = rowIndex - ariaOffset
8975
// if the row is outside of the fetched rows range, scroll to the estimated position of the cell,
9076
// to wait for the cell to be fetched and rendered
9177
// TODO(SL): should fetchedRowsRange be replaced with visibleRowsRange?
9278
if (row < fetchedRowsRange.start || row >= fetchedRowsRange.end) {
93-
nextScrollTop = row * rowHeight
94-
}
95-
if (nextScrollTop !== scrollTop) {
96-
// scroll to the cell
97-
scrollToTop(nextScrollTop)
79+
scrollToTop(row * rowHeight)
9880
}
99-
}, [cellPosition, shouldScroll, fetchedRowsRange, setShouldScroll, scrollToTop, scrollTop])
81+
}, [fetchedRowsRange, scrollToTop])
10082

10183
/**
102-
* Track viewport size and scroll position
84+
* Handle keyboard events for scrolling
10385
*/
104-
useEffect(() => {
105-
const viewport = viewportRef.current
106-
if (!viewport) {
107-
throw new Error('Viewport element is not available. Viewport size will not be tracked accurately.')
86+
const onKeyDown = useCallback((event: KeyboardEvent) => {
87+
if (event.target !== event.currentTarget) {
88+
// don't handle the event if the target is not the scroller
89+
return
10890
}
91+
const { key } = event
92+
// the user can scroll with the keyboard using the arrow keys. Here we only handle the Tab,
93+
// Enter and Space keys, to enter the cell navigation mode instead of scrolling the table
94+
// TODO(SL): exclude other meta keys (return without handling if shiftKey, ctrlKey, altKey, metaKey)
95+
if ((key === 'Tab' && !event.shiftKey) || key === 'Enter' || key === ' ') {
96+
event.stopPropagation()
97+
event.preventDefault()
98+
// scroll to the active cell
99+
scrollRowIntoView({ rowIndex })
100+
// focus the cell (once it exists)
101+
setShouldFocus(true)
102+
}
103+
}, [rowIndex, setShouldFocus, scrollRowIntoView])
109104

105+
/**
106+
* Track viewport size and scroll position
107+
*/
108+
const viewportRef = useCallback((viewport: HTMLDivElement) => {
110109
// Use arrow functions to get correct viewport type (not null)
111110
// eslint-disable-next-line func-style
112111
const updateViewportSize = () => {
@@ -117,7 +116,7 @@ export default function Scroller({
117116
// eslint-disable-next-line func-style
118117
const handleScroll = () => {
119118
// TODO(SL): throttle? see https://github.com/hyparam/hightable/pull/347
120-
setScrollTop(viewport.scrollTop)
119+
// recompute the rows range if the scroll position changed
121120
computeAndSetRowsRange(viewport)
122121
}
123122

@@ -168,7 +167,9 @@ export default function Scroller({
168167
<div className={styles.tableScroll} ref={viewportRef} role="group" aria-labelledby="caption" onKeyDown={onKeyDown} tabIndex={0}>
169168
<div style={{ height: `${scrollHeight}px` }}>
170169
<div style={{ top: `${top}px` }}>
171-
{children}
170+
<ScrollerContext.Provider value={{ scrollRowIntoView }}>
171+
{children}
172+
</ScrollerContext.Provider>
172173
</div>
173174
</div>
174175
</div>

src/components/HighTable/Slice.tsx

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
2-
import { useCallback, useContext, useEffect, useMemo } from 'react'
2+
import { useCallback, useContext, useMemo } from 'react'
33

44
import { CellNavigationContext } from '../../contexts/CellNavigationContext.js'
55
import { DataContext } from '../../contexts/DataContext.js'
66
import { OrderByContext } from '../../contexts/OrderByContext.js'
77
import { RowsAndColumnsContext } from '../../contexts/RowsAndColumnsContext.js'
8+
import { ScrollerContext } from '../../contexts/ScrollerContext.js'
89
import { SelectionContext } from '../../contexts/SelectionContext.js'
910
import { stringify as stringifyDefault } from '../../utils/stringify.js'
1011
import Cell, { type CellContentProps } from '../Cell/Cell.js'
@@ -15,7 +16,6 @@ import TableHeader from '../TableHeader/TableHeader.js'
1516
import { ariaOffset, defaultNumRowsPerPage } from './constants.js'
1617

1718
export interface SliceProps {
18-
focus?: boolean // focus table on mount? (default true)
1919
numRowsPerPage?: number // number of rows per page for keyboard navigation (default 20)
2020
// TODO(SL): replace col: number with col: string?
2121
onDoubleClickCell?: (event: MouseEvent, col: number, row: number) => void
@@ -30,7 +30,6 @@ type Props = {
3030
} & SliceProps
3131

3232
export default function Slice({
33-
focus = true,
3433
numRowsPerPage = defaultNumRowsPerPage,
3534
onDoubleClickCell,
3635
onKeyDownCell,
@@ -40,13 +39,82 @@ export default function Slice({
4039
stringify = stringifyDefault,
4140
}: Props) {
4241
const { data, version, numRows } = useContext(DataContext)
43-
const { onTableKeyDown: onNavigationTableKeyDown, focusFirstCell } = useContext(CellNavigationContext)
42+
const { rowIndex, colCount, rowCount, setColIndex, setRowIndex, setShouldFocus } = useContext(CellNavigationContext)
4443
const { orderBy, onOrderByChange } = useContext(OrderByContext)
4544
const { selectable, toggleAllRows, pendingSelectionGesture, onTableKeyDown: onSelectionTableKeyDown, allRowsSelected, isRowSelected, toggleRowNumber, toggleRangeToRowNumber } = useContext(SelectionContext)
4645
const { columnsParameters, renderedRowsRange, fetchedRowsRange } = useContext(RowsAndColumnsContext)
46+
const { scrollRowIntoView } = useContext(ScrollerContext)
47+
48+
// TODO(SL): we depend on rowIndex to trigger the scroll effect, which means we recreate the
49+
// callback every time the rowIndex changes. Can we avoid that?
50+
// For now, we don't need to depend on colIndex, we can set the state using the update function form.
51+
const onNavigationTableKeyDown = useCallback((event: KeyboardEvent, { numRowsPerPage }: {
52+
numRowsPerPage: number // number of rows to skip when navigating with the keyboard (PageUp/PageDown)
53+
}) => {
54+
const { key, altKey, ctrlKey, metaKey, shiftKey } = event
55+
// if the user is pressing Alt, Meta or Shift, do not handle the event
56+
if (altKey || metaKey || shiftKey) {
57+
return
58+
}
59+
let newRowIndex: number | undefined = undefined
60+
if (key === 'ArrowRight') {
61+
if (ctrlKey) {
62+
setColIndex(colCount)
63+
} else {
64+
setColIndex(prev => prev < colCount ? prev + 1 : prev)
65+
}
66+
} else if (key === 'ArrowLeft') {
67+
if (ctrlKey) {
68+
setColIndex(1)
69+
} else {
70+
setColIndex(prev => prev > 1 ? prev - 1 : prev)
71+
}
72+
} else if (key === 'ArrowDown') {
73+
if (ctrlKey) {
74+
newRowIndex = rowCount
75+
} else {
76+
newRowIndex = rowIndex < rowCount ? rowIndex + 1 : rowIndex
77+
}
78+
} else if (key === 'ArrowUp') {
79+
if (ctrlKey) {
80+
newRowIndex = 1
81+
} else {
82+
newRowIndex = rowIndex > 1 ? rowIndex - 1 : rowIndex
83+
}
84+
} else if (key === 'Home') {
85+
if (ctrlKey) {
86+
newRowIndex = 1
87+
}
88+
setColIndex(1)
89+
} else if (key === 'End') {
90+
if (ctrlKey) {
91+
newRowIndex = rowCount
92+
}
93+
setColIndex(colCount)
94+
} else if (key === 'PageDown') {
95+
newRowIndex = rowIndex + numRowsPerPage <= rowCount ? rowIndex + numRowsPerPage : rowCount
96+
// TODO(SL): same for horizontal scrolling with Alt+PageDown?
97+
} else if (key === 'PageUp') {
98+
newRowIndex = rowIndex - numRowsPerPage >= 1 ? rowIndex - numRowsPerPage : 1
99+
// TODO(SL): same for horizontal scrolling with Alt+PageUp?
100+
} else if (key !== ' ') {
101+
// if the key is not one of the above, do not handle it
102+
// special case: no action is associated with the Space key, but it's captured
103+
// anyway to prevent the default action (scrolling the page) and stay in navigation mode
104+
return
105+
}
106+
// avoid scrolling the table when the user is navigating with the keyboard
107+
event.stopPropagation()
108+
event.preventDefault()
109+
if (newRowIndex !== undefined) {
110+
setRowIndex(newRowIndex)
111+
scrollRowIntoView?.({ rowIndex: newRowIndex }) // ensure the cell is visible
112+
}
113+
setShouldFocus(true)
114+
}, [rowIndex, colCount, rowCount, setColIndex, setRowIndex, setShouldFocus, scrollRowIntoView])
47115

48116
const onTableKeyDown = useCallback((event: KeyboardEvent) => {
49-
onNavigationTableKeyDown?.(event, { numRowsPerPage })
117+
onNavigationTableKeyDown(event, { numRowsPerPage })
50118
onSelectionTableKeyDown?.(event)
51119
}, [onNavigationTableKeyDown, onSelectionTableKeyDown, numRowsPerPage])
52120

@@ -63,16 +131,6 @@ export default function Slice({
63131
}
64132
}, [toggleRowNumber, toggleRangeToRowNumber])
65133

66-
// focus table on mount, or on later changes, so arrow keys work
67-
// Note that the dependency upon data and numRows was removed, because focusFirstCell should depend on them
68-
// TODO(SL): move to CellNavigationProvider?
69-
useEffect(() => {
70-
if (focus) {
71-
// Try focusing the first cell
72-
focusFirstCell?.()
73-
}
74-
}, [focus, focusFirstCell])
75-
76134
// Prepare the slice of data to render
77135
// TODO(SL): also compute progress percentage here, to show a loading indicator
78136
const slice = useMemo(() => {

src/components/HighTable/Wrapper.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ColumnConfiguration } from '../../helpers/columnConfiguration.js'
77
import type { Selection } from '../../helpers/selection.js'
88
import type { OrderBy } from '../../helpers/sort.js'
99
import styles from '../../HighTable.module.css'
10+
import type { CellNavigationProviderProps } from '../../providers/CellNavigationProvider.js'
1011
import { CellNavigationProvider } from '../../providers/CellNavigationProvider.js'
1112
import { ColumnParametersProvider } from '../../providers/ColumnParametersProvider.js'
1213
import { ColumnVisibilityStatesProvider } from '../../providers/ColumnVisibilityStatesProvider.js'
@@ -32,12 +33,13 @@ export type WrapperProps = {
3233
onColumnsVisibilityChange?: (columns: Record<string, MaybeHiddenColumn>) => void // callback which is called whenever the set of hidden columns changes.
3334
onOrderByChange?: (orderBy: OrderBy) => void // callback to call when a user interaction changes the order. The interactions are disabled if undefined.
3435
onSelectionChange?: (selection: Selection) => void // callback to call when a user interaction changes the selection. The selection is expressed as data indexes (not as indexes in the table). The interactions are disabled if undefined.
35-
} & RowsAndColumnsProviderProps & SliceProps
36+
} & RowsAndColumnsProviderProps & CellNavigationProviderProps & SliceProps
3637

3738
export default function Wrapper({
3839
columnConfiguration,
3940
cacheKey,
4041
className = '',
42+
focus,
4143
orderBy,
4244
padding,
4345
overscan,
@@ -98,7 +100,7 @@ export default function Wrapper({
98100
{/* Create a new selection context if the dataframe has changed */}
99101
<SelectionProvider key={key} selection={selection} onSelectionChange={onSelectionChange} data={data} numRows={numRows}>
100102
{/* Create a new navigation context if the dataframe has changed, because the focused cell might not exist anymore */}
101-
<CellNavigationProvider key={key}>
103+
<CellNavigationProvider key={key} focus={focus}>
102104
<RowsAndColumnsProvider key={key} padding={padding} overscan={overscan}>
103105

104106
<Scroller setViewportWidth={setViewportWidth} headerHeight={headerHeight}>

0 commit comments

Comments
 (0)