Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Select rows, one by one #18

Merged
merged 6 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/HighTable.css
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,26 @@
min-width: 32px;
max-width: none;
width: 32px;
cursor: pointer;
}
.table td:first-child span {
display: inline;
}
.table td:first-child input {
display: none;
}
.table.selectable td:first-child:hover span, .table.selectable tr.selected td:first-child span {
display: none;
}
.table.selectable td:first-child:hover input, .table.selectable tr.selected td:first-child input {
display: inline;
cursor: pointer;
}
.table.selectable tr.selected {
background-color: #fbf7bf;
}
.table.selectable tr.selected td:first-child {
background-color: #f1edbb;
}

/* cells */
Expand Down
30 changes: 20 additions & 10 deletions src/HighTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ReactNode, useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
import { AsyncRow, DataFrame, Row, asyncRows } from './dataframe.js'
import { Selection, isSelected, toggleIndex } from './selection.js'
import TableHeader, { cellStyle } from './TableHeader.js'
export { rowCache } from './rowCache.js'
export {
AsyncRow,
DataFrame,
Expand All @@ -16,6 +16,7 @@ export {
sortableDataFrame,
wrapPromise,
} from './dataframe.js'
export { rowCache } from './rowCache.js'
export { HighTable }

const rowHeight = 33 // row height px
Expand All @@ -27,6 +28,7 @@ interface TableProps {
padding?: number // number of padding rows to render outside of the viewport
focus?: boolean // focus table on mount? (default true)
tableControl?: TableControl // control the table from outside
selectable?: boolean // enable row selection (default false)
onDoubleClickCell?: (event: React.MouseEvent, col: number, row: number) => void
onMouseDownCell?: (event: React.MouseEvent, col: number, row: number) => void
onError?: (error: Error) => void
Expand All @@ -39,6 +41,7 @@ type State = {
startIndex: number
rows: AsyncRow[]
orderBy?: string
selection: Selection
}

type Action =
Expand All @@ -47,6 +50,7 @@ type Action =
| { type: 'SET_COLUMN_WIDTHS', columnWidths: Array<number | undefined> }
| { type: 'SET_ORDER', orderBy: string | undefined }
| { type: 'DATA_CHANGED' }
| { type: 'SET_SELECTION', selection: Selection }

function reducer(state: State, action: Action): State {
switch (action.type) {
Expand All @@ -69,11 +73,13 @@ function reducer(state: State, action: Action): State {
if (state.orderBy === action.orderBy) {
return state
} else {
return { ...state, orderBy: action.orderBy, rows: [] }
return { ...state, orderBy: action.orderBy, rows: [], selection: [] }
}
}
case 'DATA_CHANGED':
return { ...state, invalidate: true, hasCompleteRow: false }
return { ...state, invalidate: true, hasCompleteRow: false, selection: [] }
case 'SET_SELECTION':
return { ...state, selection: action.selection }
default:
return state
}
Expand All @@ -85,6 +91,7 @@ const initialState: State = {
rows: [],
invalidate: true,
hasCompleteRow: false,
selection: [],
}

/**
Expand All @@ -97,13 +104,14 @@ export default function HighTable({
padding = 20,
focus = true,
tableControl,
selectable = false,
onDoubleClickCell,
onMouseDownCell,
onError = console.error,
}: TableProps) {
const [state, dispatch] = useReducer(reducer, initialState)

const { columnWidths, startIndex, rows, orderBy, invalidate, hasCompleteRow } = state
const { columnWidths, startIndex, rows, orderBy, invalidate, hasCompleteRow, selection } = state
const offsetTopRef = useRef(0)

const scrollRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -264,8 +272,9 @@ export default function HighTable({
}
}, [focus])

const rowNumber = useCallback((rowIndex: number) => {
return rows[rowIndex].__index__ ?? rowIndex + startIndex + 1
const rowNumber = useCallback((rowIndex: number): number => {
return (rows[rowIndex].__index__ ?? rowIndex + startIndex + 1) as unknown as number
/// TODO(SL): improve rows typing
}, [rows, startIndex])

// add empty pre and post rows to fill the viewport
Expand All @@ -287,7 +296,7 @@ export default function HighTable({
<table
aria-colcount={data.header.length}
aria-rowcount={data.numRows}
className={data.sortable ? 'table sortable' : 'table'}
className={`table${data.sortable ? ' sortable' : ''}${selectable ? ' selectable' : ''}`}
ref={tableRef}
role='grid'
style={{ top: `${offsetTopRef.current}px` }}
Expand All @@ -310,9 +319,10 @@ export default function HighTable({
</tr>
)}
{rows.map((row, rowIndex) =>
<tr key={startIndex + rowIndex} title={rowError(row, rowIndex)}>
<td style={cornerStyle}>
{rowNumber(rowIndex).toLocaleString()}
<tr key={startIndex + rowIndex} title={rowError(row, rowIndex)} className={isSelected({ selection, index: rowNumber(rowIndex) }) ? 'selected' : ''}>
<td style={cornerStyle} onClick={() => selectable && dispatch({ type: 'SET_SELECTION', selection: toggleIndex({ selection, index: rowNumber(rowIndex) }) })}>
<span>{rowNumber(rowIndex).toLocaleString()}</span>
<input type='checkbox' checked={isSelected({ selection, index: rowNumber(rowIndex) })} />
</td>
{data.header.map((col, colIndex) =>
Cell(row[col], colIndex, startIndex + rowIndex, row.__index__?.resolved)
Expand Down
105 changes: 105 additions & 0 deletions src/selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* A selection is an array of ordered and non-overlapping ranges.
* The ranges are separated, ie. the end of one range is strictly less than the start of the next range.
*/
export type Selection = Array<Range>

interface Range {
start: number // inclusive lower limit, positive integer
end: number // exclusive upper limit, positive integer or Infinity, strictly greater than start (no zero-length ranges).
}

export function isValidIndex(index: number): boolean {
return Number.isInteger(index) && index >= 0
}

export function isValidRange(range: Range): boolean {
return isValidIndex(range.start)
&& (isValidIndex(range.end) || range.end === Infinity)
&& range.end > range.start
}

export function isValidSelection(selection: Selection): boolean {
if (selection.length === 0) {
return true
}
if (selection.some(range => !isValidRange(range))) {
return false
}
for (let i = 0; i < selection.length - 1; i++) {
if (selection[i].end >= selection[i + 1].start) {
return false
}
}
return true
}

export function toggleIndex({ selection, index }: {selection: Selection, index: number}): Selection {
if (!isValidIndex(index)) {
throw new Error('Invalid index')
}
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
}

if (selection.length === 0) {
return [{ start: index, end: index + 1 }]
}

const newSelection: Selection = []
let rangeIndex = 0

// copy the ranges before the index
while (rangeIndex < selection.length && selection[rangeIndex].end < index) {
newSelection.push({ ...selection[rangeIndex] })
rangeIndex++
}

if (rangeIndex < selection.length && selection[rangeIndex].start <= index + 1) {
// the index affects one or two ranges
const { start, end } = selection[rangeIndex]
if (start === index + 1) {
// prepend the range with the index
newSelection.push({ start: index, end })
} else if (end === index) {
// two cases:
if (rangeIndex + 1 < selection.length && selection[rangeIndex + 1].start === index + 1) {
// merge with following range
newSelection.push({ start, end: selection[rangeIndex + 1].end })
rangeIndex ++ // remove the following range
} else {
// extend the range to the index
newSelection.push({ start, end: index + 1 })
}
} else {
// the index is inside the range, and must be removed
if (start === index) {
if (end > index + 1) {
newSelection.push({ start: index + 1, end })
}
// else: the range is removed
} else if (end === index + 1) {
newSelection.push({ start, end: index })
} else {
newSelection.push({ start, end: index })
newSelection.push({ start: index + 1, end })
}
}
rangeIndex++
} else {
// insert a new range for the index
newSelection.push({ start: index, end: index + 1 })
}

// copy the remaining ranges
while (rangeIndex < selection.length) {
newSelection.push({ ...selection[rangeIndex] })
rangeIndex++
}

return newSelection
}

export function isSelected({ selection, index }: {selection: Selection, index: number}): boolean {
return selection.some(range => range.start <= index && index < range.end)
}
111 changes: 111 additions & 0 deletions test/selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, test } from 'vitest'
import { isSelected, isValidIndex, isValidRange, isValidSelection, toggleIndex } from '../src/selection.js'

describe('an index', () => {
test('is a positive integer', () => {
expect(isValidIndex(0)).toBe(true)
expect(isValidIndex(1)).toBe(true)
expect(isValidIndex(1.5)).toBe(false)
expect(isValidIndex(-1)).toBe(false)
expect(isValidIndex(NaN)).toBe(false)
expect(isValidIndex(Infinity)).toBe(false)
})
})

describe('a range', () => {
test('cannot be empty', () => {
expect(isValidRange({ start: 7, end: 7 })).toBe(false)
})

test('expects end to be greater than start', () => {
expect(isValidRange({ start: 7, end: 8 })).toBe(true)
expect(isValidRange({ start: 8, end: 7 })).toBe(false)
})

test('expects start and end to be positive integers', () => {
expect(isValidRange({ start: 0, end: 1 })).toBe(true)
expect(isValidRange({ start: 0, end: 1.5 })).toBe(false)
expect(isValidRange({ start: -1, end: 1 })).toBe(false)
expect(isValidRange({ start: 0, end: NaN })).toBe(false)
})

test('accepts Infinity as the end boundary', () => {
expect(isValidRange({ start: 0, end: Infinity })).toBe(true)
expect(isValidRange({ start: Infinity, end: Infinity })).toBe(false)
})
})

describe('a selection', () => {
test('can be empty', () => {
expect(isValidSelection([])).toBe(true)
})

test('has valid ranges', () => {
expect(isValidSelection([{ start: 0, end: 1 }])).toBe(true)
expect(isValidSelection([{ start: 0, end: Infinity }])).toBe(true)
expect(isValidSelection([{ start: 1, end: 0 }])).toBe(false)
expect(isValidSelection([{ start: -1, end: 1 }])).toBe(false)
expect(isValidSelection([{ start: NaN, end: 1 }])).toBe(false)
})

test('has ordered ranges', () => {
expect(isValidSelection([{ start: 0, end: 1 }, { start: 2, end: Infinity }])).toBe(true)
expect(isValidSelection([{ start: 2, end: 3 }, { start: 0, end: 1 }])).toBe(false)
})

test('has non-overlapping, separated ranges', () => {
expect(isValidSelection([{ start: 0, end: 1 }, { start: 2, end: 3 }])).toBe(true)
expect(isValidSelection([{ start: 0, end: 1 }, { start: 0, end: 1 }])).toBe(false)
expect(isValidSelection([{ start: 0, end: 2 }, { start: 1, end: 3 }])).toBe(false)
expect(isValidSelection([{ start: 0, end: 2 }, { start: 2, end: 3 }])).toBe(false)
})

test('can contain any number of ranges', () => {
expect(isValidSelection([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }])).toBe(true)
})
})

describe('toggling an index', () => {
test('should throw an error if the index is invalid', () => {
expect(() => toggleIndex({ selection: [], index: -1 })).toThrow('Invalid index')
})

test('should throw an error if the selection is invalid', () => {
expect(() => toggleIndex({ selection: [{ start: 1, end: 0 }], index: 0 })).toThrow('Invalid selection')
})

test('should add a new range if outside and separated from existing ranges', () => {
expect(toggleIndex({ selection: [], index: 0 })).toEqual([{ start: 0, end: 1 }])
expect(toggleIndex({ selection: [{ start: 0, end: 1 }, { start: 4, end: 5 }], index: 2 })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }])
})

test('should merge with the previous and/or following ranges if adjacent', () => {
expect(toggleIndex({ selection: [{ start: 0, end: 1 }], index: 1 })).toEqual([{ start: 0, end: 2 }])
expect(toggleIndex({ selection: [{ start: 1, end: 2 }], index: 0 })).toEqual([{ start: 0, end: 2 }])
expect(toggleIndex({ selection: [{ start: 0, end: 1 }, { start: 2, end: 3 }], index: 1 })).toEqual([{ start: 0, end: 3 }])
})

test('should split a range if the index is inside', () => {
expect(toggleIndex({ selection: [{ start: 0, end: 2 }], index: 1 })).toEqual([{ start: 0, end: 1 }])
expect(toggleIndex({ selection: [{ start: 0, end: 2 }], index: 0 })).toEqual([{ start: 1, end: 2 }])
expect(toggleIndex({ selection: [{ start: 0, end: 3 }], index: 1 })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }])
})

test('should remove a range if it\'s only the index', () => {
expect(toggleIndex({ selection: [{ start: 0, end: 1 }], index: 0 })).toEqual([])
})

test('twice should be idempotent', () => {
const a = toggleIndex({ selection: [], index: 0 })
const b = toggleIndex({ selection: a, index: 0 })
expect(b).toEqual([])
})
})

describe('isSelected', () => {
test('should return true if the index is selected', () => {
expect(isSelected({ selection: [{ start: 0, end: 1 }], index: 0 })).toBe(true)
expect(isSelected({ selection: [{ start: 0, end: Infinity }], index: 1 })).toBe(true)
expect(isSelected({ selection: [{ start: 0, end: 1 }], index: 1 })).toBe(false)
})
})
Loading