diff --git a/eslint.config.js b/eslint.config.js index a5c28955..567c55d7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -103,6 +103,7 @@ export default defineConfig([ '@typescript-eslint/prefer-promise-reject-errors': 'off', '@typescript-eslint/require-await': 'error', '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/switch-exhaustiveness-check': ['error', { allowDefaultCaseForExhaustiveSwitch: false }], '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', }, settings: { react: { version: 'detect' } }, diff --git a/src/components/ColumnHeader/ColumnHeader.tsx b/src/components/ColumnHeader/ColumnHeader.tsx index 252e7bef..34fd8251 100644 --- a/src/components/ColumnHeader/ColumnHeader.tsx +++ b/src/components/ColumnHeader/ColumnHeader.tsx @@ -37,9 +37,9 @@ export default function ColumnHeader({ columnIndex, columnName, columnConfig, ca const { getHideColumn, showAllColumns } = useContext(ColumnVisibilityStatesContext) const refCallback = useCallback((node: HTMLTableCellElement | null) => { - focusCellIfNeeded(node) // set the current ref, it will be used to position the menu in handleMenuClick ref.current = node + focusCellIfNeeded(node) }, [focusCellIfNeeded]) const handleClick = useCallback(() => { diff --git a/src/components/ColumnMenu/ColumnMenu.tsx b/src/components/ColumnMenu/ColumnMenu.tsx index ca725db8..4e6b0365 100644 --- a/src/components/ColumnMenu/ColumnMenu.tsx +++ b/src/components/ColumnMenu/ColumnMenu.tsx @@ -13,7 +13,7 @@ function getSortDirection(direction?: Direction) { return 'Ascending' case 'descending': return 'Descending' - default: + case undefined: return 'No sort' } } diff --git a/src/components/HighTable/HighTable.stories.tsx b/src/components/HighTable/HighTable.stories.tsx index 90eea80c..05b9bdd0 100644 --- a/src/components/HighTable/HighTable.stories.tsx +++ b/src/components/HighTable/HighTable.stories.tsx @@ -208,7 +208,8 @@ function createFilteredData(): DataFrame { } function createLargeData(): DataFrame { - const numRows = 777_000_000 + // 1 peta rows (1 million billion, 10^15) + const numRows = 1_000_000_000_000_000 const columnDescriptors = ['ID1', 'LongString1', 'Value1', 'ID2', 'LongString2', 'Value2', 'ID3', 'LongString3', 'Value3', 'ID4', 'LongString4', 'Value4'].map(name => ({ name })) function getCell({ row, column }: { row: number, column: string }): ResolvedValue | undefined { return { diff --git a/src/components/HighTable/Slice.tsx b/src/components/HighTable/Slice.tsx index 08261318..b227043f 100644 --- a/src/components/HighTable/Slice.tsx +++ b/src/components/HighTable/Slice.tsx @@ -108,8 +108,10 @@ export default function Slice({ event.preventDefault() if (newRowIndex !== undefined) { setRowIndex(newRowIndex) - scrollRowIntoView?.({ rowIndex: newRowIndex }) // ensure the cell is visible } + // ensure the cell is visible (even if only horizontal scrolling is needed) + // TODO(SL): improve the name of scrollRowIntoView, because it can also (indirectly) scroll columns into view + scrollRowIntoView?.({ rowIndex: newRowIndex ?? rowIndex }) setShouldFocus(true) }, [rowIndex, colCount, rowCount, setColIndex, setRowIndex, setShouldFocus, scrollRowIntoView]) diff --git a/src/contexts/ScrollModeContext.ts b/src/contexts/ScrollModeContext.ts index 9843fe60..25de855c 100644 --- a/src/contexts/ScrollModeContext.ts +++ b/src/contexts/ScrollModeContext.ts @@ -1,8 +1,9 @@ import { createContext } from 'react' export interface ScrollModeContextType { - scrollMode?: 'native' | 'virtual' + scrollMode?: 'native' | 'virtual' // it's only informative for now canvasHeight?: number // total scrollable height + isScrolling?: boolean sliceTop?: number // offset of the top of the slice from the top of the canvas visibleRowsStart?: number // index of the first row visible in the viewport (inclusive). Indexes refer to the virtual table domain. visibleRowsEnd?: number // index of the last row visible in the viewport (exclusive). diff --git a/src/helpers/constants.ts b/src/helpers/constants.ts index 123d2313..1b893eba 100644 --- a/src/helpers/constants.ts +++ b/src/helpers/constants.ts @@ -13,5 +13,12 @@ export const ariaOffset = 2 // 1-based index, +1 for the header // reference: https://meyerweb.com/eric/thoughts/2025/08/07/infinite-pixels/ // it seems to be 17,895,700 in Firefox, 33,554,400 in Chrome and 33,554,428 in Safari -// Disabled for now -export const maxElementHeight = Infinity +export const maxElementHeight = 8_000_000 // a safe maximum height for an element in the DOM + +// 16,500px is ~0.2% of the canvas height for 8M px, it corresponds to 500 rows at 33px height. +// -> when scrolling with the mouse wheel, the change is local (< 16,500px) +// -> when scrolling with the scrollbar (drag/drop), or with the mouse wheel for a long time (> 500 rows), the change is global (> 0.2% of the scrollbar height) +// -> on mobile, swapping will also produce big jumps. +// TODO(SL): should we detect touch events and adapt the thresholds on mobile? +// TODO(SL): decrease/increase the threshold? make it configurable? or dependent on the number of rows, ie: a % of the scroll bar height? +export const largeScrollPx = 500 * rowHeight // px threshold to consider a scroll as "large" diff --git a/src/helpers/virtualScroll.ts b/src/helpers/virtualScroll.ts new file mode 100644 index 00000000..e44c5b29 --- /dev/null +++ b/src/helpers/virtualScroll.ts @@ -0,0 +1,340 @@ +import { ariaOffset, largeScrollPx } from '../helpers/constants.js' + +export interface Scale { + toVirtual: (scrollTop: number) => number + fromVirtual: (virtualScrollTop: number) => number + factor: number + virtualCanvasHeight: number + parameters: { + canvasHeight: number + clientHeight: number + headerHeight: number + numRows: number + rowHeight: number + } +} + +export interface ScrollState { + isScrolling: boolean + scale: Scale | undefined + scrollTop: number | undefined + virtualScrollBase: number | undefined + virtualScrollDelta: number +} + +type ScrollAction + = | { type: 'SET_SCALE', scale: Scale } + | { type: 'ON_SCROLL', scrollTop: number } + | { type: 'SCROLL_TO', scrollTop: number } + | { type: 'ADD_DELTA', delta: number } + +export function initializeScrollState(): ScrollState { + return { + isScrolling: false, + scale: undefined, + scrollTop: undefined, + virtualScrollBase: undefined, + virtualScrollDelta: 0, + } +} + +export function scrollReducer(state: ScrollState, action: ScrollAction) { + switch (action.type) { + case 'SCROLL_TO': + return { + ...state, + isScrolling: true, + scrollTop: action.scrollTop, + virtualScrollBase: state.scale?.toVirtual(action.scrollTop), + virtualScrollDelta: 0, + } + case 'ON_SCROLL': { + const isScrolling = false + const { scrollTop } = action + + // if virtualScrollBase is undefined, we initialize it here + const { scale, virtualScrollDelta, virtualScrollBase, scrollTop: oldScrollTop } = state + + if (virtualScrollBase === undefined) { + if (!scale) { + // cannot compute virtualScrollBase without scale + return { + ...state, + isScrolling, + scrollTop, + } + } + + // initialize virtualScrollBase + return { + ...state, + isScrolling, + scrollTop, + // we assume that virtualScrollDelta is valid (surely 0 at this point) + virtualScrollBase: scale.toVirtual(scrollTop) - virtualScrollDelta, + } + } + + if (oldScrollTop === undefined) { + // cannot compute a delta without oldScrollTop + return { + ...state, + isScrolling, + scrollTop, + } + } + + const delta = scrollTop - oldScrollTop + + // Do a global jump (reset local scroll based on the new scrollTop value) if + if ( + // we can compute virtualScrollBase and one of the following conditions is met + scale !== undefined && ( + // the last move is big + Math.abs(delta) > largeScrollPx + // the accumulated virtualScrollDelta is big + || Math.abs(virtualScrollDelta + delta) > largeScrollPx + // scrollTop is 0 - cannot scroll back up, we need to reset to the first row + || scrollTop <= 0 + // scrollTop is at the maximum - cannot scroll further down, we need to reset to the last row + || scrollTop >= scale.parameters.canvasHeight - scale.parameters.clientHeight + ) + ) { + // reset virtualScrollBase and virtualScrollDelta + return { + ...state, + isScrolling, + scrollTop, + // TODO(SL): maybe a bug for the maximum value, due to canvasHeight being larger due to the absolute positioning of the table? + virtualScrollBase: scale.toVirtual(Math.max(0, Math.min(scrollTop, scale.parameters.canvasHeight - scale.parameters.clientHeight))), + virtualScrollDelta: 0, + } + } + + // Adjust virtualScrollDelta + return { + ...state, + isScrolling, + scrollTop, + virtualScrollDelta: virtualScrollDelta + delta, + } + } + case 'ADD_DELTA': + return { + ...state, + virtualScrollDelta: state.virtualScrollDelta + action.delta, + } + case 'SET_SCALE': { + const { scale } = action + const { virtualScrollBase, virtualScrollDelta, scrollTop } = state + + // initialize virtualScrollBase if needed and possible + if (virtualScrollBase === undefined && scrollTop !== undefined) { + return { + ...state, + scale, + // we assume that virtualScrollDelta is valid (surely 0 at this point) + virtualScrollBase: scale.toVirtual(scrollTop) - virtualScrollDelta, + } + } + + // TODO(SL): if state.scale already existed, i.e. if some dimensions changed, update the state accordingly (virtualScrollBase, virtualScrollDelta) + // trying to keep the same view? + // The most impactful change could be if the number of rows changed drastically. + + return { + ...state, + scale, + } + } + } +} + +/* Compute the derived values */ +export function computeDerivedValues({ scale, scrollTop, virtualScrollBase, virtualScrollDelta, padding }: Omit & { padding: number }): { + sliceTop?: number | undefined + visibleRowsStart?: number | undefined + visibleRowsEnd?: number | undefined + renderedRowsStart?: number | undefined + renderedRowsEnd?: number | undefined +} { + if (virtualScrollBase === undefined || scale === undefined) { + return {} + } + const virtualScrollTop = virtualScrollBase + virtualScrollDelta + const { clientHeight, headerHeight, rowHeight, numRows } = scale.parameters + + // special case: is the virtual scroll position in the header? + const isInHeader = numRows === 0 || virtualScrollTop < headerHeight + + // First visible row. It can be the header row (0). + const visibleRowsStart = isInHeader + ? 0 + : Math.max(0, + Math.min(numRows - 1, + Math.floor((virtualScrollTop - headerHeight) / rowHeight) + ) + ) + if (isNaN(visibleRowsStart)) throw new Error(`invalid start row ${visibleRowsStart}`) + const renderedRowsStart = Math.max(0, visibleRowsStart - padding) + + // hidden pixels in the first visible row, or header + const hiddenPixelsBefore = isInHeader + ? virtualScrollTop + : virtualScrollTop - headerHeight - visibleRowsStart * rowHeight + + // Last visible row + const visibleRowsEnd = Math.max(visibleRowsStart, + Math.min(numRows - 1, + Math.floor((virtualScrollTop + clientHeight - headerHeight) / rowHeight) + ) + ) + 1 // end is exclusive + if (isNaN(visibleRowsEnd)) throw new Error(`invalid end row ${visibleRowsEnd}`) + const renderedRowsEnd = Math.min(numRows, visibleRowsEnd + padding) + if (renderedRowsEnd - renderedRowsStart > 1000) throw new Error(`attempted to render too many rows ${renderedRowsEnd - renderedRowsStart}`) + + // Uncomment if needed + // const hiddenPixelsAfter = headerHeight + visibleRowsEnd * rowHeight - (virtualScrollTop + clientHeight) + + if (scrollTop === undefined) { + return { + visibleRowsStart, + visibleRowsEnd, + renderedRowsStart, + renderedRowsEnd, + } + } + + // Offset of the first row in the canvas (sliceTop) + + // Y-offset of the first visible data row in the full scrollable canvas, + // i.e. the scroll position minus the number of hidden pixels for that row. + const firstVisibleRowTop = scrollTop - hiddenPixelsBefore + // Number of "padding" rows that we render above the first visible row + const previousPaddingRows = visibleRowsStart - renderedRowsStart + // When the scroll position is still within the header, the first visible + // data row starts right after the header. Encode that as 0/1 so we can + // subtract a single headerHeight when we are in the body. + const headerRow = isInHeader ? 0 : 1 + // The top of the rendered slice in canvas coordinates: + // - start from the top of the first visible row + // - subtract the header height (once) when we are below the header + // - shift up by the number of padding rows times the row height so that + // those extra rows are also included in the rendered slice. + const sliceTop = firstVisibleRowTop - headerRow * headerHeight - previousPaddingRows * rowHeight + + return { + sliceTop, + visibleRowsStart, + visibleRowsEnd, + renderedRowsStart, + renderedRowsEnd, + } +} + +export function createScale(parameters: { + clientHeight: number + canvasHeight: number + headerHeight: number + rowHeight: number + numRows: number +}): Scale { + const { clientHeight, canvasHeight, headerHeight, rowHeight, numRows } = parameters + const virtualCanvasHeight = headerHeight + numRows * rowHeight + + // safety checks + if (headerHeight <= 0) { + throw new Error(`Invalid headerHeight: ${headerHeight}. It should be a positive number.`) + } + if (rowHeight <= 0) { + throw new Error(`Invalid rowHeight: ${rowHeight}. It should be a positive number.`) + } + if (canvasHeight <= 0) { + throw new Error(`Invalid canvasHeight: ${canvasHeight}. It should be a positive number.`) + } + if (numRows < 0 || !Number.isInteger(numRows)) { + throw new Error(`Invalid numRows: ${numRows}. It should be a non-negative integer.`) + } + if (canvasHeight <= clientHeight) { + throw new Error(`Invalid canvasHeight: ${canvasHeight} when clientHeight is ${clientHeight}. canvasHeight should be greater than clientHeight for virtual scrolling.`) + } + if (virtualCanvasHeight <= clientHeight) { + throw new Error(`Invalid virtualCanvasHeight: ${virtualCanvasHeight} when clientHeight is ${clientHeight}. virtualCanvasHeight should be greater than clientHeight for virtual scrolling.`) + } + + const factor = (virtualCanvasHeight - clientHeight) / (canvasHeight - clientHeight) + return { + toVirtual: (scrollTop: number) => { + return scrollTop * factor + }, + fromVirtual: (virtualScrollTop: number) => { + return virtualScrollTop / factor + }, + factor, + virtualCanvasHeight, + parameters: { + canvasHeight, + clientHeight, + headerHeight, + numRows, + rowHeight, + }, + } +} + +// TODO(SL): this logic should be shared with the 'ON_SCROLL' action in the reducer, to avoid code duplication +// and to ensure consistent behavior +export function getScrollActionForRow({ + rowIndex, + scale, + virtualScrollBase, + virtualScrollDelta, +}: { + rowIndex: number + scale: Scale + virtualScrollBase: number + virtualScrollDelta: number +}): { delta: number } | { scrollTop: number } | undefined { + const { headerHeight, rowHeight, numRows } = scale.parameters + + if (rowIndex < 1 || rowIndex > numRows + 1 || !Number.isInteger(rowIndex)) { + throw new Error(`Invalid row index: ${rowIndex}. It should be an integer between 1 and ${numRows + 1}.`) + } + + if (rowIndex === 1) { + // header row + return + } + + const row = rowIndex - ariaOffset // convert to 0-based data row index + + // Three cases: + // - the row is fully visible: do nothing + // - the row start is before virtualScrollTop + headerHeight: scroll to snap its start with that value + // - the row end is after virtualScrollTop + viewportHeight: scroll to snap its end with that value + const virtualScrollTop = virtualScrollBase + virtualScrollDelta + const hiddenPixelsBefore = virtualScrollTop + headerHeight - (headerHeight + row * rowHeight) + const hiddenPixelsAfter = headerHeight + row * rowHeight + rowHeight - virtualScrollTop - scale.parameters.clientHeight + + if (hiddenPixelsBefore <= 0 && hiddenPixelsAfter <= 0) { + // fully visible, do nothing + return + } + // else, it's partly or totally hidden: update the scroll position + + const delta = hiddenPixelsBefore > 0 ? -hiddenPixelsBefore : hiddenPixelsAfter + if ( + // big jump + Math.abs(delta) > largeScrollPx + // or accumulated delta is big + || Math.abs(virtualScrollDelta + delta) > largeScrollPx + ) { + // scroll to the new position, and update the state optimistically + const newVirtualScrollTop = virtualScrollTop + delta + const newScrollTop = scale.fromVirtual(newVirtualScrollTop) + return { scrollTop: newScrollTop } + } else { + // move slightly: keep scrollTop and virtualScrollTop untouched, compensate with virtualScrollDelta + return { delta } + } +} diff --git a/src/hooks/useCellFocus.ts b/src/hooks/useCellFocus.ts index ea56f22a..438131d8 100644 --- a/src/hooks/useCellFocus.ts +++ b/src/hooks/useCellFocus.ts @@ -16,28 +16,25 @@ interface CellFocus { export function useCellFocus({ ariaColIndex, ariaRowIndex }: CellData): CellFocus { const { colIndex, rowIndex, setColIndex, setRowIndex, shouldFocus, setShouldFocus } = useContext(CellNavigationContext) - const { scrollMode } = useContext(ScrollModeContext) + const { isScrolling } = useContext(ScrollModeContext) // Check if the cell is the current navigation cell const isCurrentCell = ariaColIndex === colIndex && ariaRowIndex === rowIndex const focusCellIfNeeded = useCallback((element: HTMLElement | null) => { - if (!element || !isCurrentCell || !shouldFocus) { + if (!element || !isCurrentCell || !shouldFocus || isScrolling) { return } - // focus on the cell when needed - if (scrollMode === 'virtual') { - // TODO(SL): to be implemented - } else { - // scroll the cell into view - // - // scroll-padding-inline-start and scroll-padding-block-start are set in the CSS - // to avoid the cell being hidden by the row and column headers - element.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' }) - element.focus() - setShouldFocus(false) - } - }, [isCurrentCell, shouldFocus, setShouldFocus, scrollMode]) + // scroll the cell into view + // + // scroll-padding-inline-start and scroll-padding-block-start are set in the CSS + // to avoid the cell being hidden by the row and column headers + // + // Note that it might scroll both vertically and horizontally. + element.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' }) + element.focus() + setShouldFocus(false) + }, [isCurrentCell, shouldFocus, setShouldFocus, isScrolling]) // Roving tabindex: only the current navigation cell is focusable with Tab (tabindex = 0) // All other cells are focusable only with javascript .focus() (tabindex = -1) diff --git a/src/providers/ScrollModeProvider.tsx b/src/providers/ScrollModeProvider.tsx index 15f81acf..e1333938 100644 --- a/src/providers/ScrollModeProvider.tsx +++ b/src/providers/ScrollModeProvider.tsx @@ -29,7 +29,7 @@ export function ScrollModeProvider({ children, headerHeight, numRows, padding = ) } else { return ( - + {children} ) diff --git a/src/providers/ScrollModeVirtualProvider.tsx b/src/providers/ScrollModeVirtualProvider.tsx index 13c78120..510e965f 100644 --- a/src/providers/ScrollModeVirtualProvider.tsx +++ b/src/providers/ScrollModeVirtualProvider.tsx @@ -1,28 +1,88 @@ -import { type ReactNode, useContext, useEffect, useMemo } from 'react' +import { type ReactNode, useCallback, useMemo, useReducer, useState } from 'react' -import { ErrorContext } from '../contexts/ErrorContext.js' import { ScrollModeContext } from '../contexts/ScrollModeContext.js' +import { rowHeight } from '../helpers/constants.js' +import { computeDerivedValues, createScale, getScrollActionForRow, initializeScrollState, scrollReducer } from '../helpers/virtualScroll.js' interface ScrollModeVirtualProviderProps { children: ReactNode canvasHeight: number // total scrollable height. It must be strictly positive. + headerHeight: number + numRows: number + padding: number } -export function ScrollModeVirtualProvider({ children, canvasHeight }: ScrollModeVirtualProviderProps) { - const { onError } = useContext(ErrorContext) +export function ScrollModeVirtualProvider({ children, canvasHeight, headerHeight, numRows, padding }: ScrollModeVirtualProviderProps) { + const [{ scale, scrollTop, virtualScrollBase, isScrolling, virtualScrollDelta }, dispatch] = useReducer(scrollReducer, undefined, initializeScrollState) + const [scrollTo, setScrollTo] = useState(undefined) + const setScrollTop = useCallback((scrollTop: number) => { + dispatch({ type: 'ON_SCROLL', scrollTop }) + }, []) + const [clientHeight, _setClientHeight] = useState(undefined) + const setClientHeight = useCallback((clientHeight: number) => { + // TODO(SL): remove this fallback? It's only for the tests in Node.js, where the elements have zero height + // instead, it should return without updating the visible rows range, or set it to undefined. + // TODO(SL): test in the browser (playwright) + _setClientHeight(clientHeight === 0 ? 100 : clientHeight) + }, []) - useEffect(() => { - onError?.(new Error('Virtual scroll mode is not implemented yet.')) - }, [onError]) + const currentScale = useMemo(() => { + if (clientHeight === undefined) { + return undefined + } + return createScale({ clientHeight, canvasHeight, headerHeight, rowHeight, numRows }) + }, [clientHeight, canvasHeight, headerHeight, numRows]) + + // ideally: call SET_SCALE from an event listener (if num_rows changes, or on resize if clientHeight or headerHeight change) + // not during rendering + if (currentScale && currentScale !== scale) { + dispatch({ type: 'SET_SCALE', scale: currentScale }) + } + + /** + * Programmatically scroll to a specific row if needed. + * Beware: + * - row 1: header + * - row 2: first data row + * - row numRows + 1: last data row + * @param rowIndex The row to scroll to (same semantic as aria-rowindex: 1-based, includes header) + */ + const scrollRowIntoView = useCallback(({ rowIndex }: { rowIndex: number }) => { + if (!scale || virtualScrollBase === undefined) { + return + } + const result = getScrollActionForRow({ rowIndex, scale, virtualScrollBase, virtualScrollDelta }) + if (!result) { + return + } + if ('delta' in result) { + dispatch({ type: 'ADD_DELTA', delta: result.delta }) + } else if ('scrollTop' in result && scrollTo) { + // side effect: scroll the viewport + scrollTo({ top: result.scrollTop, behavior: 'instant' }) + // anticipate the scroll position change + dispatch({ type: 'SCROLL_TO', scrollTop: result.scrollTop }) + } + }, [scrollTo, virtualScrollBase, virtualScrollDelta, scale]) const value = useMemo(() => { return { scrollMode: 'virtual' as const, canvasHeight, - // TODO(SL): provide the rest of the context + isScrolling, + setClientHeight, + setScrollTop, + scrollRowIntoView, + setScrollTo, + ...computeDerivedValues({ + scale, + scrollTop, + virtualScrollBase, + virtualScrollDelta, + padding, + }), } - }, [canvasHeight]) - + }, [scale, scrollTop, virtualScrollBase, virtualScrollDelta, padding, canvasHeight, isScrolling, setClientHeight, setScrollTop, scrollRowIntoView]) return ( {children} diff --git a/test/helpers/virtualScroll.test.ts b/test/helpers/virtualScroll.test.ts new file mode 100644 index 00000000..99310892 --- /dev/null +++ b/test/helpers/virtualScroll.test.ts @@ -0,0 +1,520 @@ +import { describe, expect, it } from 'vitest' + +import type { Scale, ScrollState } from '../../src/helpers/virtualScroll.js' +import { computeDerivedValues, createScale, getScrollActionForRow, initializeScrollState, scrollReducer } from '../../src/helpers/virtualScroll.js' + +function createFakeScale(factor: number): Scale { + return { + toVirtual: (scrollTop: number) => scrollTop * factor, + fromVirtual: (virtualScrollTop: number) => virtualScrollTop / factor, + factor, + // the following values are arbitrary for the tests + virtualCanvasHeight: 200_000, + parameters: { + rowHeight: 20, + headerHeight: 50, + numRows: 10_000, + canvasHeight: 30_000, + clientHeight: 1000, + }, + } +} + +describe('initializeScrollState', () => { + it('returns the initial scroll state', () => { + const state = initializeScrollState() + expect(state).toEqual({ + isScrolling: false, + scale: undefined, + scrollTop: undefined, + virtualScrollBase: undefined, + virtualScrollDelta: 0, + }) + }) +}) + +describe('scrollReducer', () => { + describe('SET_SCALE action', () => { + it('sets the scale in the state', () => { + const initialState = initializeScrollState() + const scale = createFakeScale(2) + const newState = scrollReducer(initialState, { type: 'SET_SCALE', scale }) + expect(newState.scale).toBe(scale) + expect(newState.isScrolling).toBe(false) + expect(newState.scrollTop).toBeUndefined() + expect(newState.virtualScrollBase).toBeUndefined() + expect(newState.virtualScrollDelta).toBe(0) + }) + + it('does not change other state properties when setting scale', () => { + const initialState: ScrollState = { + isScrolling: true, + scale: undefined, + scrollTop: 100, + virtualScrollBase: 200, + virtualScrollDelta: 10, + } + const scale = createFakeScale(2) + const newState = scrollReducer(initialState, { type: 'SET_SCALE', scale }) + expect(newState.scale).toBe(scale) + expect(newState.isScrolling).toBe(true) + expect(newState.scrollTop).toBe(100) + expect(newState.virtualScrollBase).toBe(200) + expect(newState.virtualScrollDelta).toBe(10) + }) + + it('does not change other state properties when updating scale', () => { + const initialState: ScrollState = { + isScrolling: false, + scale: createFakeScale(2), + scrollTop: 100, + virtualScrollBase: 200, + virtualScrollDelta: 0, + } + const newScale = createFakeScale(3) + const newState = scrollReducer(initialState, { type: 'SET_SCALE', scale: newScale }) + expect(newState.scale).toBe(newScale) + expect(newState.isScrolling).toBe(false) + expect(newState.scrollTop).toBe(100) + expect(newState.virtualScrollBase).toBe(200) + expect(newState.virtualScrollDelta).toBe(0) + }) + + it('computes virtualScrollBase if needed and scrollTop is defined', () => { + const initialState = { + ...initializeScrollState(), + scrollTop: 150, + } + const scale = createFakeScale(2) + const newState = scrollReducer(initialState, { type: 'SET_SCALE', scale }) + expect(newState.scale).toBe(scale) + expect(newState.isScrolling).toBe(false) + expect(newState.scrollTop).toBe(150) + expect(newState.virtualScrollBase).toBe(300) // 150 * 2 + expect(newState.virtualScrollDelta).toBe(0) + }) + }) + + describe('ADD_DELTA action', () => { + it('adds delta to virtualScrollDelta', () => { + const initialState = initializeScrollState() + const newState = scrollReducer(initialState, { type: 'ADD_DELTA', delta: 50 }) + expect(newState.virtualScrollDelta).toBe(50) + }) + + it('accumulates delta on multiple ADD_DELTA actions', () => { + const initialState = initializeScrollState() + const stateAfterFirstDelta = scrollReducer(initialState, { type: 'ADD_DELTA', delta: 30 }) + const stateAfterSecondDelta = scrollReducer(stateAfterFirstDelta, { type: 'ADD_DELTA', delta: 20 }) + expect(stateAfterSecondDelta.virtualScrollDelta).toBe(50) + }) + + it('does not modify other state properties when adding delta', () => { + const initialState: ScrollState = { + isScrolling: true, + scale: createFakeScale(2), + scrollTop: 100, + virtualScrollBase: 200, + virtualScrollDelta: 10, + } + const newState = scrollReducer(initialState, { type: 'ADD_DELTA', delta: 15 }) + expect(newState.virtualScrollDelta).toBe(25) + expect(newState.isScrolling).toBe(true) + expect(newState.scale).toBe(initialState.scale) + expect(newState.scrollTop).toBe(100) + expect(newState.virtualScrollBase).toBe(200) + }) + }) + + describe('SCROLL_TO action', () => { + it('sets scrollTop, sets isScrolling to true, resets virtualScrollDelta and compute virtualScrollBase if scale is defined', () => { + const initialState = { + isScrolling: false, + scale: createFakeScale(2), + scrollTop: 150, + virtualScrollBase: 800, + virtualScrollDelta: 120, + } + const newState = scrollReducer(initialState, { type: 'SCROLL_TO', scrollTop: 250 }) + expect(newState.scrollTop).toBe(250) + expect(newState.virtualScrollBase).toBe(500) + expect(newState.virtualScrollDelta).toBe(0) + expect(newState.isScrolling).toBe(true) + }) + + it('sets scrollTop, sets isScrolling to true, resets virtualScrollDelta and undefine virtualScrollBase if scale is undefined', () => { + const initialState = { + isScrolling: false, + scale: undefined, + scrollTop: 150, + virtualScrollBase: 800, + virtualScrollDelta: 120, + } + const newState = scrollReducer(initialState, { type: 'SCROLL_TO', scrollTop: 250 }) + expect(newState.scrollTop).toBe(250) + expect(newState.virtualScrollBase).toBeUndefined() + expect(newState.virtualScrollDelta).toBe(0) + expect(newState.isScrolling).toBe(true) + }) + }) + + describe('ON_SCROLL action', () => { + it('only sets scrollTop and isScrolling when virtualScrollBase and scale are undefined', () => { + const initialState = { + isScrolling: true, + scale: undefined, + scrollTop: undefined, + virtualScrollBase: undefined, + virtualScrollDelta: 0, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop: 300 }) + expect(newState.scrollTop).toBe(300) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBeUndefined() + expect(newState.virtualScrollDelta).toBe(0) + }) + + it('sets scrollTop and isScrolling, even if isScrolling was already false', () => { + const initialState: ScrollState = { + isScrolling: false, + scale: undefined, + scrollTop: 100, + virtualScrollBase: undefined, + virtualScrollDelta: 0, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop: 300 }) + expect(newState.scrollTop).toBe(300) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBeUndefined() + expect(newState.virtualScrollDelta).toBe(0) + }) + + it('only sets scrollTop and isScrolling when virtualScrollBase is defined and scrollTop was undefined', () => { + const initialState: ScrollState = { + isScrolling: true, + scale: createFakeScale(2), + scrollTop: undefined, + virtualScrollBase: 400, + virtualScrollDelta: 0, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop: 300 }) + expect(newState.scrollTop).toBe(300) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBe(400) + expect(newState.virtualScrollDelta).toBe(0) + }) + + it('sets scrollTop and isScrolling, keeps virtualScrollDelta, and computes virtualScrollBase when virtualScrollBase is undefined but scale is defined', () => { + const initialState: ScrollState = { + isScrolling: true, + scale: createFakeScale(2), + scrollTop: 100, + virtualScrollBase: undefined, + virtualScrollDelta: 30, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop: 300 }) + expect(newState.scrollTop).toBe(300) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBe(570) // 300 * 2 - 30 + expect(newState.virtualScrollDelta).toBe(30) + }) + + it('computes a new virtualScrollBase and resets virtualScrollDelta when scrollTop change is significant', () => { + const initialState: ScrollState = { + isScrolling: true, + scale: createFakeScale(2), + scrollTop: 100, + virtualScrollBase: 200, + virtualScrollDelta: 20, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop: 20_000 }) + expect(newState.scrollTop).toBe(20_000) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBe(40_000) // 20_000 * 2 + expect(newState.virtualScrollDelta).toBe(0) + }) + + it('computes a new virtualScrollBase and resets virtualScrollDelta when accumulated virtual scroll delta is significant', () => { + const initialState: ScrollState = { + isScrolling: true, + scale: createFakeScale(2), + scrollTop: 100, + virtualScrollBase: 200, + virtualScrollDelta: 20_000, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop: 150 }) + expect(newState.scrollTop).toBe(150) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBe(300) // 150 * 2 + expect(newState.virtualScrollDelta).toBe(0) + }) + + it.each([-10_000, -1, 0])('sets a virtualScrollBase to 0 and resets virtualScrollDelta when scrollTop is non-positive (%i)', (scrollTop) => { + const initialState: ScrollState = { + isScrolling: true, + scale: createFakeScale(2), + scrollTop: 100, + virtualScrollBase: 200, + virtualScrollDelta: 50, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop }) + expect(newState.scrollTop).toBe(scrollTop) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBe(0) + expect(newState.virtualScrollDelta).toBe(0) + }) + + it.each([0, 1, 10_000])('sets a virtualScrollBase to max and resets virtualScrollDelta when scrollTop is too large (max + %i)', (excess) => { + const scale = createFakeScale(2) + const maxScrollTop = scale.parameters.canvasHeight - scale.parameters.clientHeight + const initialState: ScrollState = { + isScrolling: true, + scale, + scrollTop: 100, + virtualScrollBase: 200, + virtualScrollDelta: 50, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop: maxScrollTop + excess }) + expect(newState.scrollTop).toBe(maxScrollTop + excess) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBe(scale.toVirtual(maxScrollTop)) + expect(newState.virtualScrollDelta).toBe(0) + }) + + it('keeps virtualScrollBase and only updates virtualScrollDelta (+ sets scrollTop and isScrolling) when scrollTop change is minor', () => { + const initialState: ScrollState = { + isScrolling: true, + scale: createFakeScale(2), + scrollTop: 100, + virtualScrollBase: 200, + virtualScrollDelta: 20, + } + const newState = scrollReducer(initialState, { type: 'ON_SCROLL', scrollTop: 150 }) + expect(newState.scrollTop).toBe(150) + expect(newState.isScrolling).toBe(false) + expect(newState.virtualScrollBase).toBe(200) + expect(newState.virtualScrollDelta).toBe(70) // 20 + (150 - 100) <- the delta is added in the virtual space, to get a local scroll behavior + }) + }) +}) + +describe('createScale', () => { + it('creates a scale with the correct factor and dimensions', () => { + const parameters = { + canvasHeight: 10_000, + clientHeight: 500, + headerHeight: 50, + numRows: 1_000, + rowHeight: 20, + } + const scale = createScale(parameters) + expect(scale.parameters).toEqual(parameters) + expect(scale.virtualCanvasHeight).toBe(20_050) // headerHeight + numRows * rowHeight + expect(scale.factor).toBeCloseTo(2.057) + expect(scale.toVirtual(0)).toBe(0) + expect(scale.toVirtual(100)).toBeCloseTo(205.789) + expect(scale.fromVirtual(0)).toBe(0) + expect(scale.fromVirtual(2_000)).toBeCloseTo(971.867) + }) + + it.each([ + { headerHeight: 0, rowHeight: 20, canvasHeight: 10_000 }, + { headerHeight: 50, rowHeight: 0, canvasHeight: 10_000 }, + { headerHeight: 50, rowHeight: 20, canvasHeight: 0 }, + { headerHeight: -10, rowHeight: 20, canvasHeight: 10_000 }, + { headerHeight: 50, rowHeight: -5, canvasHeight: 10_000 }, + { headerHeight: 50, rowHeight: 20, canvasHeight: -100 }, + ])('throws if headerHeight, rowHeight or canvasHeight are non-positive (%o)', (params) => { + const parameters = { + clientHeight: 500, + numRows: 1_000, + ...params, + } + expect(() => createScale(parameters)).toThrow() + }) + + it.each([-1, 1.5])('throws if numRows is negative or non-integer', (numRows) => { + const parameters = { + canvasHeight: 10_000, + clientHeight: 500, + headerHeight: 50, + rowHeight: 20, + numRows, + } + expect(() => createScale(parameters)).toThrow() + }) + + it('handles the case with zero rows', () => { + const parameters = { + canvasHeight: 1_000, + clientHeight: 20, + headerHeight: 50, + rowHeight: 20, + numRows: 0, + } + const scale = createScale(parameters) + expect(scale.parameters).toEqual(parameters) + expect(scale.virtualCanvasHeight).toBe(50) // headerHeight + numRows * rowHeight + expect(scale.factor).toBeCloseTo(0.03) + }) + + it.each([499, 500])('throws if canvasHeight is less than or equal to clientHeight', (canvasHeight) => { + const parameters = { + canvasHeight, + clientHeight: 500, + headerHeight: 50, + rowHeight: 20, + numRows: 1_000, + } + expect(() => createScale(parameters)).toThrow() + }) + + it('throws if virtualCanvasHeight is less than or equal to clientHeight', () => { + const parameters = { + canvasHeight: 10_000, + clientHeight: 5_000, + headerHeight: 50, + rowHeight: 4, + numRows: 10, + } + expect(() => createScale(parameters)).toThrow() + }) +}) + +describe('computeDerivedValues', () => { + it('computes derived values correctly', () => { + const scale = createScale({ + clientHeight: 1_000, + canvasHeight: 10_000, + headerHeight: 50, + rowHeight: 30, + numRows: 20_000, + }) + const { sliceTop, visibleRowsStart, visibleRowsEnd, renderedRowsStart, renderedRowsEnd } = computeDerivedValues({ + scale, + // these values are arbitrary for the test + scrollTop: 200, + virtualScrollBase: 600, + virtualScrollDelta: 150, + padding: 5, + }) + expect(sliceTop).toBe(-10) + expect(visibleRowsStart).toBe(23) + expect(visibleRowsEnd).toBe(57) + expect(renderedRowsStart).toBe(18) + expect(renderedRowsEnd).toBe(62) + }) + + it('adds padding only when possible', () => { + const scale = createScale({ + clientHeight: 1_000, + canvasHeight: 10_000, + headerHeight: 50, + rowHeight: 30, + numRows: 100, + }) + const { sliceTop, visibleRowsStart, visibleRowsEnd, renderedRowsStart, renderedRowsEnd } = computeDerivedValues({ + scale, + // these values are arbitrary for the test + scrollTop: 0, + virtualScrollBase: 0, + virtualScrollDelta: 0, + padding: 10, + }) + expect(sliceTop).toBe(0) + expect(visibleRowsStart).toBe(0) + expect(visibleRowsEnd).toBe(32) + expect(renderedRowsStart).toBe(0) // cannot go below 0 + expect(renderedRowsEnd).toBe(42) // can add full padding at the end + }) +}) + +describe('getScrollActionForRow', () => { + it.each([ + { rowIndex: 1, virtualScrollBase: 0, virtualScrollDelta: 0 }, + { rowIndex: 2, virtualScrollBase: 0, virtualScrollDelta: 0 }, + { rowIndex: 10, virtualScrollBase: 100, virtualScrollDelta: 0 }, + { rowIndex: 10, virtualScrollBase: 100, virtualScrollDelta: -100 }, + { rowIndex: 50, virtualScrollBase: 1_000, virtualScrollDelta: 200 }, + ])('returns undefined if the row is already in view', ({ rowIndex, virtualScrollBase, virtualScrollDelta }) => { + const scale = createScale({ + clientHeight: 1_000, + canvasHeight: 10_000, + headerHeight: 50, + rowHeight: 33, + numRows: 1_000, + }) + const action = getScrollActionForRow({ scale, rowIndex, virtualScrollBase, virtualScrollDelta }) + expect(action).toBeUndefined() + }) + + it.each([ + { rowIndex: 5, virtualScrollBase: 500, virtualScrollDelta: 0, expectedDelta: -401 }, + { rowIndex: 500, virtualScrollBase: 0, virtualScrollDelta: 0, expectedDelta: 15_517 }, + { rowIndex: 500, virtualScrollBase: 1_000, virtualScrollDelta: 1_000, expectedDelta: 13_517 }, + ])('returns a delta action (positive or negative) to scroll to the row when a small scroll is needed (%o)', ({ rowIndex, virtualScrollBase, virtualScrollDelta, expectedDelta }) => { + const scale = createScale({ + clientHeight: 1_000, + canvasHeight: 10_000, + headerHeight: 50, + rowHeight: 33, + numRows: 1_000, + }) + const action = getScrollActionForRow({ scale, rowIndex, virtualScrollBase, virtualScrollDelta }) + expect(action).toEqual({ delta: expectedDelta }) + }) + + it.each([ + { rowIndex: 2, virtualScrollBase: 50_000, virtualScrollDelta: 0, expectedScrollTop: 0 }, + { rowIndex: 800, virtualScrollBase: 0, virtualScrollDelta: 0, expectedScrollTop: 7_137 }, + ])('returns a scrollTop action to scroll to the row when a large scroll is needed (%o)', ({ rowIndex, virtualScrollBase, virtualScrollDelta, expectedScrollTop }) => { + const scale = createScale({ + clientHeight: 1_000, + canvasHeight: 10_000, + headerHeight: 50, + rowHeight: 33, + numRows: 1_000, + }) + const action = getScrollActionForRow({ scale, rowIndex, virtualScrollBase, virtualScrollDelta }) + if (!action || !('scrollTop' in action)) { + throw new Error('Expected a scrollTop action') + } + expect(action.scrollTop).toBeCloseTo(expectedScrollTop, 0) + }) + + it('returns a scrollTop action when the accumulated delta would exceed largeScrollPx threshold', () => { + const scale = createScale({ + clientHeight: 1_000, + canvasHeight: 10_000, + headerHeight: 50, + rowHeight: 33, + numRows: 1_000, + }) + const virtualScrollDelta = 16_500 // below the largeScrollPx threshold (16,500) + const rowIndex = 600 + const virtualScrollBase = 0 + // should add a small delta (2_317), but the accumulated delta (18_817) exceeds largeScrollPx, so: scrollTop is returned to synchronize properly + const action = getScrollActionForRow({ scale, rowIndex, virtualScrollBase, virtualScrollDelta }) + if (!action || !('scrollTop' in action)) { + throw new Error('Expected a scrollTop action') + } + expect(action.scrollTop).toBeCloseTo(5_284, 0) + }) + + it.each([ + // would be a delta, but rowIndex 1 is the header + { rowIndex: 1, virtualScrollBase: 500, virtualScrollDelta: 0, expectedDelta: -401 }, + // would be a scrollTop, but rowIndex 1 is the header + { rowIndex: 1, virtualScrollBase: 50_000, virtualScrollDelta: 0 }, + ])('returns undefined if the rowIndex is the header, because it is always in view (%o)', ({ rowIndex, virtualScrollBase, virtualScrollDelta }) => { + const scale = createScale({ + clientHeight: 1_000, + canvasHeight: 10_000, + headerHeight: 50, + rowHeight: 33, + numRows: 1_000, + }) + const action = getScrollActionForRow({ scale, rowIndex, virtualScrollBase, virtualScrollDelta }) + expect(action).toBeUndefined() + }) +})