Skip to content

Commit

Permalink
apply React logic to HighTable selection
Browse files Browse the repository at this point in the history
The selection in HighTable is controlled, or uncontrolled, and it cannot
be changed once the component has been created.

Four modes:
   * - controlled (selection and onSelectionChange are defined): the parent controls the selection and receives the user interactions. No local state.
   * - controlled read-only (selection is defined, onSelectionChange is undefined): the parent controls the selection and the user interactions are disabled. No local state.
   * - uncontrolled (selection is undefined, onSelectionChange is defined): the component controls the selection and the user interactions. Local state.
   * - disabled (selection and onSelectionChange are undefined): the selection is hidden and the user interactions are disabled. No local state.

Next steps:
- do the same for orderBy
- merge ControlledHighTable back to HighTable
- fix tests
- handle selection with keyboard (onChange on checkboxes)
  • Loading branch information
severo committed Jan 17, 2025
1 parent 6aa002d commit d30143d
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 64 deletions.
82 changes: 50 additions & 32 deletions src/ControlledHighTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ function rowLabel(rowIndex?: number): string {
export type ControlledTableProps = TableProps & {
state: InternalState
dispatch: React.Dispatch<InternalAction>
selectionAndAnchor?: SelectionAndAnchor // controlled selection state
setSelectionAndAnchor?: (selectionAndAnchor: SelectionAndAnchor) => void // controlled selection state setter
selectionAndAnchor?: SelectionAndAnchor // selection and anchor rows. If undefined, the selection is hidden and the interactions are disabled.
onSelectionAndAnchor?: (selectionAndAnchor: SelectionAndAnchor) => void // callback to call when a user interaction changes the selection. The interactions are disabled if undefined.
}

/**
Expand All @@ -87,7 +87,7 @@ export default function ControlledHighTable({
padding = 20,
focus = true,
selectionAndAnchor,
setSelectionAndAnchor,
onSelectionAndAnchor,
state,
dispatch,
onDoubleClickCell,
Expand All @@ -104,8 +104,39 @@ export default function ControlledHighTable({
*/
const { columnWidths, startIndex, rows, orderBy, invalidate, hasCompleteRow } = state

const selectable = selectionAndAnchor && setSelectionAndAnchor
const { selection, anchor } = selectionAndAnchor ?? { selection: [], anchor: undefined }
const showSelection = selectionAndAnchor !== undefined
const showSelectionControls = showSelection && onSelectionAndAnchor !== undefined
const getOnSelectAllRows = useCallback(() => {
if (!selectionAndAnchor || !onSelectionAndAnchor) return
const { selection } = selectionAndAnchor
return () => onSelectionAndAnchor({
selection: toggleAll({ selection, length: data.numRows }),
anchor: undefined,
})
}, [onSelectionAndAnchor, data.numRows, selectionAndAnchor])
const getOnSelectRowClick = useCallback((tableIndex: number) => {
if (!selectionAndAnchor || !onSelectionAndAnchor) return
const { selection, anchor } = selectionAndAnchor
return (event: React.MouseEvent) => {
const useAnchor = event.shiftKey && selectionAndAnchor.anchor !== undefined
if (useAnchor) {
onSelectionAndAnchor({ selection: extendFromAnchor({ selection, anchor, index: tableIndex }), anchor })
} else {
onSelectionAndAnchor({ selection: toggleIndex({ selection, index: tableIndex }), anchor: tableIndex })
}
}
}, [onSelectionAndAnchor, selectionAndAnchor])
const allRowsSelected = useMemo(() => {
if (!selectionAndAnchor) return false
const { selection } = selectionAndAnchor
return areAllSelected({ selection, length: data.numRows })
}, [selectionAndAnchor, data.numRows])
const isRowSelected = useCallback((tableIndex: number) => {
if (!selectionAndAnchor) return undefined
const { selection } = selectionAndAnchor
return isSelected({ selection, index: tableIndex })
}, [selectionAndAnchor])


const offsetTopRef = useRef(0)

Expand Down Expand Up @@ -285,16 +316,6 @@ export default function ControlledHighTable({
return { dataIndex, tableIndex }
}, [rows, startIndex, orderBy])


const onRowNumberClick = useCallback(({ useAnchor, tableIndex }: {useAnchor: boolean, tableIndex: number}) => {
if (!setSelectionAndAnchor) return
if (useAnchor) {
setSelectionAndAnchor({ selection: extendFromAnchor({ selection, anchor, index: tableIndex }), anchor })
} else {
setSelectionAndAnchor({ selection: toggleIndex({ selection, index: tableIndex }), anchor: tableIndex })
}
}, [setSelectionAndAnchor, selection, anchor])

// add empty pre and post rows to fill the viewport
const prePadding = Array.from({ length: Math.min(padding, startIndex) }, () => [])
const postPadding = Array.from({
Expand All @@ -320,7 +341,7 @@ export default function ControlledHighTable({
// don't render table if header is empty
if (!data.header.length) return

return <div className={`table-container${selectable ? ' selectable' : ''}`}>
return <div className={`table-container${showSelectionControls ? ' selectable' : ''}${showSelection ? ' show-selection' : ''}`}>
<div className='table-scroll' ref={scrollRef}>
<div style={{ height: `${scrollHeight}px` }}>
<table
Expand All @@ -346,22 +367,21 @@ export default function ControlledHighTable({
{prePadding.map((_, prePaddingIndex) => {
const { tableIndex, dataIndex } = getRowIndexes(-prePadding.length + prePaddingIndex)
return <tr key={tableIndex} aria-rowindex={tableIndex + 2 /* 1-based + the header row */} >
<th scope="row" style={cornerStyle}>
{
rowLabel(dataIndex)
}
</th>
<th scope="row" style={cornerStyle}>{
rowLabel(dataIndex)
}</th>
</tr>
})}
{rows.map((row, rowIndex) => {
const { tableIndex, dataIndex } = getRowIndexes(rowIndex)
const selected = isRowSelected(tableIndex)
return <tr key={tableIndex} aria-rowindex={tableIndex + 2 /* 1-based + the header row */} title={rowError(row, dataIndex)}
className={selectable && isSelected({ selection, index: tableIndex }) ? 'selected' : ''}
aria-selected={isSelected({ selection, index: tableIndex })}
className={selected ? 'selected' : ''}
aria-selected={selected}
>
<th scope="row" style={cornerStyle} onClick={selectable && (event => onRowNumberClick({ useAnchor: event.shiftKey, tableIndex }))}>
<th scope="row" style={cornerStyle} onClick={getOnSelectRowClick(tableIndex)}>
<span>{rowLabel(dataIndex)}</span>
{ selectable && <input type='checkbox' checked={isSelected({ selection, index: tableIndex })} readOnly={true} /> }
{ showSelection && <input type='checkbox' checked={selected} readOnly /> }
</th>
{data.header.map((col, colIndex) =>
Cell(row[col], colIndex, dataIndex)
Expand All @@ -371,20 +391,18 @@ export default function ControlledHighTable({
{postPadding.map((_, postPaddingIndex) => {
const { tableIndex, dataIndex } = getRowIndexes(rows.length + postPaddingIndex)
return <tr key={tableIndex} aria-rowindex={tableIndex + 2 /* 1-based + the header row */} >
<th scope="row" style={cornerStyle} >
{
rowLabel(dataIndex)
}
</th>
<th scope="row" style={cornerStyle} >{
rowLabel(dataIndex)
}</th>
</tr>
})}
</tbody>
</table>
</div>
</div>
<div className='table-corner' style={cornerStyle} onClick={selectable && (() => setSelectionAndAnchor({ selection: toggleAll({ selection, length: rows.length }), anchor: undefined }))}>
<div className='table-corner' style={cornerStyle} onClick={getOnSelectAllRows()}>
<span>&nbsp;</span>
{selectable && <input type='checkbox' checked={selection && areAllSelected({ selection, length: rows.length })} readOnly={true} />}
{ showSelection && <input type='checkbox' checked={allRowsSelected} readOnly /> }
</div>
<div className='mock-row-label' style={cornerStyle}>&nbsp;</div>
</div>
Expand Down
35 changes: 23 additions & 12 deletions src/HighTable.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,19 +110,27 @@
.table th:first-child input {
display: none;
}
.selectable th:first-child:hover span, .selectable tr.selected th:first-child span {
/* for selected rows, replace row numbers with checkboxes and highlight the rows */
tr.selected th:first-child span {
display: none;
}
.selectable th:first-child:hover input, .selectable tr.selected th:first-child input {
tr.selected th:first-child input {
display: inline;
cursor: pointer;
}
.selectable tr.selected {
tr.selected {
background-color: #fbf7bf;
}
.selectable tr.selected th:first-child {
tr.selected th:first-child {
background-color: #f1edbb;
}
/* if selectable, show checkboxes on hover (and focus) */
th.selectable:first-child:hover span, th.selectable:first-child:focus span {
display: none;
}
th.selectable:first-child:hover input, th.selectable:first-child:focus input {
display: inline;
cursor: pointer;
}

/* cells */
.table th,
Expand Down Expand Up @@ -226,21 +234,24 @@
display: flex;
justify-content: center;
}
.selectable .table-corner {
background: #e4e4e6;
cursor: pointer;
}
/* replace corner with checkbox if selection is enabled (read-only or not) */
.table-corner input {
display: none;
}
.selectable .table-corner span {
.show-selection .table-corner {
background: #e4e4e6;
}
.show-selection .table-corner span {
display: none;
}
.selectable .table-corner input {
.show-selection .table-corner input {
display: inline;
cursor: pointer;
text-align: center;
}
/* if selectable, show pointer cursor on checkbox */
.selectable .table-corner, .selectable .table-corner input {
cursor: pointer;
}

/* mock row numbers */
.mock-row-label {
Expand Down
82 changes: 62 additions & 20 deletions src/HighTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useCallback, useReducer } from 'react'
import { useCallback, useReducer, useState } from 'react'
import type { InternalAction, InternalState, SelectionAndAnchor, TableProps } from './ControlledHighTable.js'
import ControlledHighTable from './ControlledHighTable.js'
import type { Selection } from './selection.js'
export { stringify, throttle } from './ControlledHighTable.js'
export type { ControlledTableProps, InternalAction, InternalState, SelectionAndAnchor, TableProps } from './ControlledHighTable.js'
export {
Expand All @@ -16,20 +15,23 @@ export { rowCache } from './rowCache.js'
export type { Selection } from './selection.js'
export { ControlledHighTable, HighTable }

export const initialState = {
type State = InternalState & { selectionAndAnchor?: SelectionAndAnchor }

type Action = InternalAction
| { type: 'SET_SELECTION' } & { selectionAndAnchor: SelectionAndAnchor }

export const initialState: State = {
columnWidths: [],
startIndex: 0,
rows: [],
invalidate: true,
hasCompleteRow: false,
selection: [],
selectionAndAnchor: {
selection: [],
anchor: undefined,
},
}

type State = InternalState & SelectionAndAnchor

type Action = InternalAction
| ({ type: 'SET_SELECTION' } & SelectionAndAnchor)

export function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_ROWS':
Expand All @@ -52,44 +54,84 @@ export function reducer(state: State, action: Action): State {
return state
} else {
// the selection is relative to the order, and must be reset if the order changes
return { ...state, orderBy: action.orderBy, rows: [], selection: [], anchor: undefined }
return { ...state, orderBy: action.orderBy, rows: [], selectionAndAnchor: { selection : [], anchor: undefined } }
}
}
case 'DATA_CHANGED':
return { ...state, invalidate: true, hasCompleteRow: false, selection: [], anchor: undefined }
return { ...state, invalidate: true, hasCompleteRow: false, selectionAndAnchor: { selection : [], anchor: undefined } }
case 'SET_SELECTION':
return { ...state, selection: action.selection, anchor: action.anchor }
return { ...state, selectionAndAnchor: action.selectionAndAnchor }
default:
return state
}
}

type HighTableProps = TableProps & {
onSelectionChange?: (selection: Selection) => void
selection: SelectionAndAnchor
onSelectionChange?: (selection: SelectionAndAnchor) => void
}

/**
* Render a table with streaming rows on demand from a DataFrame.
*
* selection: the selected rows and the anchor row. If set, the component is controlled, and the property cannot be unset (undefined) later. If undefined, the component is uncontrolled (internal state).
* onSelectionChange: the callback to call when the selection changes. If undefined, the component selection is read-only if controlled (selection is set), or disabled if not.
*/
export default function HighTable({
data,
cacheKey,
overscan = 20,
padding = 20,
focus = true,
selection,
onSelectionChange,
onDoubleClickCell,
onMouseDownCell,
onError = console.error,
}: HighTableProps) {
const [state, dispatch] = useReducer(reducer, initialState)

const selectable = onSelectionChange !== undefined
const selectionAndAnchor = selectable ? { selection: state.selection, anchor: state.anchor } : undefined
const setSelectionAndAnchor = useCallback((selectionAndAnchor: SelectionAndAnchor) => {
onSelectionChange?.(selectionAndAnchor.selection)
dispatch({ type: 'SET_SELECTION', ...selectionAndAnchor })
}, [dispatch, onSelectionChange])
/**
* Four modes:
* - controlled (selection and onSelectionChange are defined): the parent controls the selection and receives the user interactions. No local state.
* - controlled read-only (selection is defined, onSelectionChange is undefined): the parent controls the selection and the user interactions are disabled. No local state.
* - uncontrolled (selection is undefined, onSelectionChange is defined): the component controls the selection and the user interactions. Local state.
* - disabled (selection and onSelectionChange are undefined): the selection is hidden and the user interactions are disabled. No local state.
*/
const [initialSelection, _] = useState<SelectionAndAnchor | undefined>(selection)
const isSelectionControlled = initialSelection !== undefined
const showSelectionInteractions = onSelectionChange !== undefined
let selectionAndAnchor: SelectionAndAnchor | undefined
if (isSelectionControlled) {
if (selection === undefined) {
console.warn('The component selection is controlled (is has no local state) because "selection" was initially defined. "selection" cannot be set to undefined now (it is set back to the initial value).')
selectionAndAnchor = initialSelection
} else {
selectionAndAnchor = selection
}
} else {
if (selection !== undefined) {
console.warn('The component selection is uncontrolled (it only has a local state) because "selection" was initially undefined. "selection" cannot be set to a value now and is ignored.')
}
if (onSelectionChange === undefined) {
console.warn('The component selection is disabled because "onSelectionChange" is undefined. If you want to enable selection, you must provide "onSelectionChange".')
selectionAndAnchor = undefined
} else {
// eslint-disable-next-line prefer-destructuring
selectionAndAnchor = state.selectionAndAnchor
}
}
const getOnSelectionAndAnchorChange = useCallback(() => {
if (!showSelectionInteractions) {
return undefined
}
return (selectionAndAnchor: SelectionAndAnchor) => {
onSelectionChange?.(selectionAndAnchor)
if (!isSelectionControlled) {
dispatch({ type: 'SET_SELECTION', selectionAndAnchor })
}
}
}, [dispatch, onSelectionChange, isSelectionControlled, showSelectionInteractions])

return <ControlledHighTable
data={data}
Expand All @@ -98,7 +140,7 @@ export default function HighTable({
padding={padding}
focus={focus}
selectionAndAnchor={selectionAndAnchor}
setSelectionAndAnchor={selectable ? setSelectionAndAnchor : undefined}
onSelectionAndAnchor={getOnSelectionAndAnchorChange()}
onDoubleClickCell={onDoubleClickCell}
onMouseDownCell={onMouseDownCell}
onError={onError}
Expand Down

0 comments on commit d30143d

Please sign in to comment.