Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ interface TableProps {
maxRowNumber?: number // maximum row number to display (for row headers). Useful for filtered data. If undefined, the number of rows in the data frame is applied.
orderBy?: OrderBy // order by column (if defined, the component order is controlled by the parent)
overscan?: number // number of rows to fetch outside of the viewport (default 20)
padding?: number // number of extra rows to render outside of the viewport (default 20)
padding?: number // number of empty placeholder rows to render beyond the fetched data range (default 20)
numRowsPerPage?: number // number of rows per page for keyboard navigation (default 20)
selection?: Selection // selection state (if defined, the component selection is controlled by the parent)
styled?: boolean // use styled component? (default true)
Expand Down
48 changes: 48 additions & 0 deletions src/components/HighTable/HighTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,38 @@ function createFilteredData(): DataFrame {
return df
}

function createLargeData(): DataFrame {
const numRows = 777_000_000
const columnDescriptors = ['ID', 'Value'].map(name => ({ name }))
function getCell({ row, column }: { row: number, column: string }): ResolvedValue | undefined {
return {
value: column === 'ID'
? `row ${row}`
: column === 'Value'
? Math.floor(100 * random(135 + row))
: undefined,
}
}
const getRowNumber = createGetRowNumber({ numRows })
return { columnDescriptors, numRows, getCell, getRowNumber }
}

function createSmallData(): DataFrame {
const numRows = 8
const columnDescriptors = ['ID', 'Value'].map(name => ({ name }))
function getCell({ row, column }: { row: number, column: string }): ResolvedValue | undefined {
return {
value: column === 'ID'
? `row ${row}`
: column === 'Value'
? Math.floor(100 * random(135 + row))
: undefined,
}
}
const getRowNumber = createGetRowNumber({ numRows })
return { columnDescriptors, numRows, getCell, getRowNumber }
}

function CustomCellContent({ cell, row, col, stringify }: CellContentProps) {
return (
<span>
Expand Down Expand Up @@ -572,3 +604,19 @@ export const SortedVaryingData: Story = {
data: sortableDataFrame(createVaryingArrayDataFrame({ delay_ms: 200, maxRows: 20 })),
},
}

export const LargeData: Story = {
args: {
data: createLargeData(),
onError: (error: unknown) => {
console.error('Error in LargeData story:', error)
alert(error)
},
},
}

export const SmallData: Story = {
args: {
data: createSmallData(),
},
}
109 changes: 24 additions & 85 deletions src/components/HighTable/Scroller.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,21 @@
import type { KeyboardEvent } from 'react'
import { useCallback, useContext, useMemo, useState } from 'react'
import { useCallback, useContext, useMemo } from 'react'

import { CellNavigationContext } from '../../contexts/CellNavigationContext.js'
import { DataContext } from '../../contexts/DataContext.js'
import { RowsAndColumnsContext } from '../../contexts/RowsAndColumnsContext.js'
import { ScrollerContext } from '../../contexts/ScrollerContext.js'
import { ScrollModeContext } from '../../contexts/ScrollModeContext.js'
import styles from '../../HighTable.module.css'
import { ariaOffset, rowHeight } from './constants.js'

interface Props {
headerHeight?: number // height of the table header
setViewportWidth: (width: number) => void // callback to set the current viewport width
children?: React.ReactNode
setViewportWidth: (width: number) => void // callback to set the current viewport width
}

export default function Scroller({
headerHeight = rowHeight,
setViewportWidth,
children,
setViewportWidth,
}: Props) {
const [scrollToTop, setScrollToTop] = useState<((top: number) => void) | undefined>(undefined)

const { numRows } = useContext(DataContext)
const { setShouldFocus, rowIndex } = useContext(CellNavigationContext)
const { fetchedRowsRange, renderedRowsRange, setVisibleRowsRange } = useContext(RowsAndColumnsContext)

/**
* Compute the values:
* - scrollHeight: total scrollable height
* - rowsRange: rows to fetch based on the current scroll position
* - tableOffset: offset of the table inside the scrollable area
*/
// total scrollable height - it's fixed, based on the number of rows.
// if CSS is not completely changed, viewport.current.scrollHeight will be equal to this value
const scrollHeight = useMemo(() => headerHeight + numRows * rowHeight, [numRows, headerHeight])

// sanity check
if (scrollHeight <= 0) {
throw new Error(`invalid scrollHeight ${scrollHeight}`)
}

const computeAndSetRowsRange = useCallback((viewport: HTMLDivElement) => {
const { scrollTop, clientHeight: viewportHeight } = viewport

// TODO(SL): remove this fallback? It's only for the tests, where the elements have zero height
const clientHeight = viewportHeight === 0 ? 100 : viewportHeight

// determine visible rows based on current scroll position (indexes refer to the virtual table domain)
const start = Math.max(0, Math.floor(numRows * scrollTop / scrollHeight))
const end = Math.min(numRows, Math.ceil(numRows * (scrollTop + clientHeight) / scrollHeight))

if (isNaN(start)) throw new Error(`invalid start row ${start}`)
if (isNaN(end)) throw new Error(`invalid end row ${end}`)
if (end - start > 1000) throw new Error(`attempted to render too many rows ${end - start} table must be contained in a scrollable div`)
setVisibleRowsRange?.({ start, end })
}, [numRows, scrollHeight, setVisibleRowsRange])

/**
* Vertically scroll to bring a specific row into view
*/
const scrollRowIntoView = useCallback(({ rowIndex }: { rowIndex: number }) => {
if (scrollToTop === undefined || fetchedRowsRange === undefined) {
return
}
if (rowIndex < 1) {
throw new Error(`invalid rowIndex ${rowIndex}`)
}
if (rowIndex === 1) {
// always visible
return
}
// should be zero-based
const row = rowIndex - ariaOffset
// if the row is outside of the fetched rows range, scroll to the estimated position of the cell,
// to wait for the cell to be fetched and rendered
// TODO(SL): should fetchedRowsRange be replaced with visibleRowsRange?
if (row < fetchedRowsRange.start || row >= fetchedRowsRange.end) {
scrollToTop(row * rowHeight)
}
}, [fetchedRowsRange, scrollToTop])
const { canvasHeight, sliceTop, onViewportChange, scrollRowIntoView, setScrollToTop } = useContext(ScrollModeContext)

/**
* Handle keyboard events for scrolling
Expand All @@ -96,7 +33,7 @@ export default function Scroller({
event.stopPropagation()
event.preventDefault()
// scroll to the active cell
scrollRowIntoView({ rowIndex })
scrollRowIntoView?.({ rowIndex })
// focus the cell (once it exists)
setShouldFocus(true)
}
Expand All @@ -110,14 +47,13 @@ export default function Scroller({
// eslint-disable-next-line func-style
const updateViewportSize = () => {
setViewportWidth(viewport.clientWidth)
// recompute the rows range if the height has changed
computeAndSetRowsRange(viewport)
onViewportChange?.(viewport)
}

// eslint-disable-next-line func-style
const handleScroll = () => {
// TODO(SL): throttle? see https://github.com/hyparam/hightable/pull/347
// recompute the rows range if the scroll position changed
computeAndSetRowsRange(viewport)
onViewportChange?.(viewport)
}

// run once
Expand All @@ -126,15 +62,15 @@ export default function Scroller({

// set scrollToTop function
if ('scrollTo' in viewport) {
setScrollToTop(() => {
setScrollToTop?.(() => {
// ^ we need to use a setter function, we cannot set a function as a value
return (top: number) => {
viewport.scrollTo({ top })
}
})
} else {
// scrollTo does not exist in jsdom, used in the tests
setScrollToTop(undefined)
setScrollToTop?.(undefined)
}

// listeners
Expand All @@ -156,20 +92,23 @@ export default function Scroller({
resizeObserver?.disconnect()
viewport.removeEventListener('scroll', handleScroll)
}
}, [setViewportWidth, computeAndSetRowsRange])
}, [setScrollToTop, setViewportWidth, onViewportChange])

// TODO(SL): maybe pass CSS variables instead of inline styles?
// the viewport div scrollHeight will be equal to canvasHeight (unless custom CSS is messing with it)
const canvasHeightStyle = useMemo(() => {
return canvasHeight !== undefined ? { height: `${canvasHeight}px` } : {}
}, [canvasHeight])

// Note: it does not depend on headerHeight, because the header is always present in the DOM
const top = useMemo(() => {
return (renderedRowsRange?.start ?? 0) * rowHeight
}, [renderedRowsRange])
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={{ height: `${scrollHeight}px` }}>
<div style={{ top: `${top}px` }}>
<ScrollerContext.Provider value={{ scrollRowIntoView }}>
{children}
</ScrollerContext.Provider>
<div style={canvasHeightStyle}>
<div style={sliceTopStyle}>
{children}
</div>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/components/HighTable/Slice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { CellNavigationContext } from '../../contexts/CellNavigationContext.js'
import { DataContext } from '../../contexts/DataContext.js'
import { OrderByContext } from '../../contexts/OrderByContext.js'
import { RowsAndColumnsContext } from '../../contexts/RowsAndColumnsContext.js'
import { ScrollerContext } from '../../contexts/ScrollerContext.js'
import { ScrollModeContext } from '../../contexts/ScrollModeContext.js'
import { SelectionContext } from '../../contexts/SelectionContext.js'
import { ariaOffset, defaultNumRowsPerPage } from '../../helpers/constants.js'
import { stringify as stringifyDefault } from '../../utils/stringify.js'
import Cell, { type CellContentProps } from '../Cell/Cell.js'
import Row from '../Row/Row.js'
import RowHeader from '../RowHeader/RowHeader.js'
import TableCorner from '../TableCorner/TableCorner.js'
import TableHeader from '../TableHeader/TableHeader.js'
import { ariaOffset, defaultNumRowsPerPage } from './constants.js'

export interface SliceProps {
numRowsPerPage?: number // number of rows per page for keyboard navigation (default 20)
Expand Down Expand Up @@ -43,7 +43,7 @@ export default function Slice({
const { orderBy, onOrderByChange } = useContext(OrderByContext)
const { selectable, toggleAllRows, pendingSelectionGesture, onTableKeyDown: onSelectionTableKeyDown, allRowsSelected, isRowSelected, toggleRowNumber, toggleRangeToRowNumber } = useContext(SelectionContext)
const { columnsParameters, renderedRowsRange, fetchedRowsRange } = useContext(RowsAndColumnsContext)
const { scrollRowIntoView } = useContext(ScrollerContext)
const { scrollRowIntoView } = useContext(ScrollModeContext)

// TODO(SL): we depend on rowIndex to trigger the scroll effect, which means we recreate the
// callback every time the rowIndex changes. Can we avoid that?
Expand Down
18 changes: 10 additions & 8 deletions src/components/HighTable/Wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useContext, useMemo, useRef, useState } from 'react'
import { DataContext } from '../../contexts/DataContext.js'
import { PortalContainerContext } from '../../contexts/PortalContainerContext.js'
import type { ColumnConfiguration } from '../../helpers/columnConfiguration.js'
import { columnVisibilityStatesSuffix, columnWidthsSuffix, rowHeight } from '../../helpers/constants.js'
import type { Selection } from '../../helpers/selection.js'
import type { OrderBy } from '../../helpers/sort.js'
import styles from '../../HighTable.module.css'
Expand All @@ -16,9 +17,8 @@ import { ColumnWidthsProvider } from '../../providers/ColumnWidthsProvider.js'
import { OrderByProvider } from '../../providers/OrderByProvider.js'
import type { RowsAndColumnsProviderProps } from '../../providers/RowsAndColumnsProvider.js'
import { RowsAndColumnsProvider } from '../../providers/RowsAndColumnsProvider.js'
import { ScrollModeProvider } from '../../providers/ScrollModeProvider.js'
import { SelectionProvider } from '../../providers/SelectionProvider.js'
import { rowHeight } from './constants.js'
import { columnVisibilityStatesSuffix, columnWidthsSuffix } from './constants.js'
import Scroller from './Scroller.js'
import type { SliceProps } from './Slice.js'
import Slice from './Slice.js'
Expand Down Expand Up @@ -102,14 +102,16 @@ export default function Wrapper({
{/* Create a new navigation context if the dataframe has changed, because the focused cell might not exist anymore */}
<CellNavigationProvider key={key} focus={focus}>
<RowsAndColumnsProvider key={key} padding={padding} overscan={overscan}>
<ScrollModeProvider numRows={numRows} headerHeight={headerHeight}>

<Scroller setViewportWidth={setViewportWidth} headerHeight={headerHeight}>
<Slice
setTableCornerSize={setTableCornerSize}
{...rest}
/>
</Scroller>
<Scroller setViewportWidth={setViewportWidth}>
<Slice
setTableCornerSize={setTableCornerSize}
{...rest}
/>
</Scroller>

</ScrollModeProvider>
</RowsAndColumnsProvider>
</CellNavigationProvider>
</SelectionProvider>
Expand Down
14 changes: 14 additions & 0 deletions src/contexts/ScrollModeContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createContext } from 'react'

export interface ScrollModeContextType {
scrollMode?: 'native' | 'virtual'
canvasHeight?: number // total scrollable height
sliceTop?: number // offset of the top of the slice from the top of the canvas
onViewportChange?: (viewport: { clientHeight: number, scrollTop: number }) => void // function to call when the current viewport height and scroll top position change
scrollRowIntoView?: ({ rowIndex }: { rowIndex: number }) => void // function to scroll so that the row is visible in the table
setScrollToTop?: (scrollToTop: ((top: number) => void) | undefined) => void // function to set the scrollToTop function
}

export const defaultScrollModeContext: ScrollModeContextType = {}

export const ScrollModeContext = createContext<ScrollModeContextType>(defaultScrollModeContext)
9 changes: 0 additions & 9 deletions src/contexts/ScrollerContext.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ const columnVisibilityStatesFormatVersion = '2' // increase in case of breaking
export const columnVisibilityStatesSuffix = `:${columnVisibilityStatesFormatVersion}:column:visibility` // suffix used to store the columns visibility in local storage

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
export const maxElementHeight = 8_000_000 // a safe maximum height for an element in the DOM
30 changes: 18 additions & 12 deletions src/hooks/useCellFocus.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useContext } from 'react'

import { CellNavigationContext } from '../contexts/CellNavigationContext.js'
import { ScrollModeContext } from '../contexts/ScrollModeContext.js'

interface CellData {
ariaColIndex: number // table column index, same semantic as aria-colindex (1-based, includes row headers)
Expand All @@ -15,6 +16,7 @@ interface CellFocus {

export function useCellFocus({ ariaColIndex, ariaRowIndex }: CellData): CellFocus {
const { colIndex, rowIndex, setColIndex, setRowIndex, shouldFocus, setShouldFocus } = useContext(CellNavigationContext)
const { scrollMode } = useContext(ScrollModeContext)

// Check if the cell is the current navigation cell
const isCurrentCell = ariaColIndex === colIndex && ariaRowIndex === rowIndex
Expand All @@ -25,20 +27,24 @@ export function useCellFocus({ ariaColIndex, ariaRowIndex }: CellData): CellFocu
return
}
// focus on the cell when needed
if (isCurrentCell && shouldFocus) {
if (!isHeaderCell) {
// 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
//
// not applied for header cells, as they are always visible, and it was causing jumps when resizing a column
element.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' })
if (scrollMode === 'virtual') {
// TODO(SL): to be implemented
} else {
if (isCurrentCell && shouldFocus) {
if (!isHeaderCell) {
// 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
//
// not applied for header cells, as they are always visible, and it was causing jumps when resizing a column
element.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' })
}
element.focus()
setShouldFocus(false)
}
element.focus()
setShouldFocus(false)
}
}, [isCurrentCell, isHeaderCell, shouldFocus, setShouldFocus])
}, [isCurrentCell, isHeaderCell, shouldFocus, setShouldFocus, scrollMode])

// Roving tabindex: only the current navigation cell is focusable with Tab (tabindex = 0)
// All other cells are focusable only with javascript .focus() (tabindex = -1)
Expand Down
Loading