vitessce
Version:
Vitessce app and React component library
217 lines (198 loc) • 7.24 kB
JavaScript
/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useEffect, useCallback, useState } from 'react';
import { Table, AutoSizer } from 'react-virtualized';
import uuidv4 from 'uuid/v4';
import union from 'lodash/union';
import difference from 'lodash/difference';
import isEqual from 'lodash/isEqual';
const SHIFT_KEYCODE = 16;
/**
* A table with "selectable" rows.
* @prop {string[]} columns An array of column names, corresponding to data object properties.
* @prop {object[]} data An array of data objects used to populate table rows.
* @prop {function} onChange Callback function,
* passed a selection object when `allowMultiple` is false (and `null` if `allowUncheck` is true),
* or passed an array of selection objects when `allowMultiple` is true.
* @prop {string} idKey The key for a unique identifier property of `data` objects.
* @prop {string} valueKey If initially-selected rows are required,
* this key specifies a boolean property of the `data` objects
* indicating those rows that should be initially selected.
* @prop {boolean} allowMultiple Whether to allow multiple rows to be selected.
* @prop {boolean} allowUncheck Whether to allow selected rows to be un-checked. By default, false.
* @prop {boolean} showTableHead Whether to show the table header element. By default, true.
* @prop {boolean} showTableInputs Whether to show the table input elements for each row.
* By default, false.
*/
export default function SelectableTable(props) {
const {
hasColorEncoding,
columns,
data,
onChange,
idKey = 'id',
valueKey = 'value',
allowMultiple = false,
allowUncheck = false,
showTableHead = true,
showTableInputs = false,
testHeight = undefined,
testWidth = undefined,
} = props;
const [selectedRows, setSelectedRows] = useState(null);
const [isCheckingMultiple, setIsCheckingMultiple] = useState(false);
// Enable selecting multiple rows while the shift key is down.
useEffect(() => {
function onKeyDown(event) {
if (allowMultiple && event.keyCode === SHIFT_KEYCODE) {
setIsCheckingMultiple(true);
}
}
function onKeyUp(event) {
if (allowMultiple && event.keyCode === SHIFT_KEYCODE) {
setIsCheckingMultiple(false);
}
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [allowMultiple]);
// Callback function to update the `selectedRows` state.
const onSelectRow = useCallback((value, checked) => {
if (checked || allowUncheck) {
if (!isCheckingMultiple
&& (checked || (!checked && allowMultiple && selectedRows.length > 1))
) {
setSelectedRows([value]);
} else if (!allowMultiple && !checked) {
setSelectedRows([]);
} else {
setSelectedRows(
checked
? union(selectedRows || [], [value])
: difference(selectedRows || [], [value]),
);
}
}
}, [allowMultiple, isCheckingMultiple, allowUncheck, selectedRows]);
// Handler for checkbox input elements.
const handleInputChange = useCallback((event) => {
const { target } = event;
const { checked } = target;
const { value } = target;
onSelectRow(value, checked);
}, [onSelectRow]);
// Function to map row IDs to corresponding objects
// to pass to the `onChange` callback.
const getDataFromIds = useCallback(ids => ids.map(id => ({
[idKey]: id,
data: data.find(item => item[idKey] === id),
})), [data, idKey]);
// Function to check if a row ID has been selected.
const isSelected = useCallback(id => (
Array.isArray(selectedRows) && selectedRows.includes(id)
), [selectedRows]);
/* eslint-disable react-hooks/exhaustive-deps */
useEffect(() => {
// Check whether an initial set of rows should be selected.
const initialSelectedRows = data
.map((d) => {
if (d[valueKey]) {
return d[idKey];
}
return null;
})
.filter(Boolean);
if (!isEqual(initialSelectedRows, selectedRows)) {
if (initialSelectedRows.length > 0) {
setSelectedRows(initialSelectedRows);
} else {
setSelectedRows(null);
}
}
}, [data, idKey, valueKey]);
/* eslint-disable react-hooks/exhaustive-deps */
useEffect(() => {
// Call the `onChange` prop function with an updated row or set of rows.
if (!onChange || !selectedRows) {
return;
}
const selectedRowData = getDataFromIds(selectedRows);
if (allowMultiple) {
onChange(selectedRowData);
} else if (selectedRows.length === 1) {
onChange(selectedRowData[0]);
} else if (selectedRows.length === 0) {
onChange(null);
}
}, [selectedRows, allowMultiple]);
// Generate a unique ID to use in (for, id) label-input pairs.
const inputUuid = uuidv4();
// Class for first column of inputs, to hide them if desired.
const hiddenInputsClass = (showTableInputs ? '' : 'hidden-input-column');
const rowRenderer = ({ index, style }) => (
// eslint-disable-next-line jsx-a11y/interactive-supports-focus
<div
key={data[index][idKey]}
className={`table-item table-row ${isSelected(data[index][idKey]) ? 'row-checked ' : ''}`}
style={style}
role="button"
onClick={() => onSelectRow(
data[index][idKey],
!isSelected(data[index][idKey]) || !hasColorEncoding,
)}
>
<div className={`input-container ${hiddenInputsClass} table-cell`}>
<label htmlFor={`${inputUuid}_${data[index][idKey]}`}>
<input
id={`${inputUuid}_${data[index][idKey]}`}
type="checkbox"
className={(isCheckingMultiple ? 'checkbox' : 'radio')}
name={inputUuid}
value={data[index][idKey]}
onChange={handleInputChange}
checked={isSelected(data[index][idKey])}
/>
</label>
</div>
{columns.map(column => (
<div
className="table-cell"
key={column}
>
{data[index][column]}
</div>
))}
</div>
);
const headerRowRenderer = ({ style }) => (
<div className={`${hiddenInputsClass} table-row`} style={style}>
{columns.map(column => (
<div key={column}>{column}</div>
))}
</div>
);
return (
<div className="selectable-table">
<AutoSizer>
{({ width, height }) => (
<Table
height={testHeight || height}
gridStyle={{ outline: 'none' }}
rowCount={data.length}
// 24 is 1 em + padding in either direction (see _selectable_table.scss).
rowHeight={24}
headerHeight={showTableHead ? 24 : undefined}
rowRenderer={rowRenderer}
width={testWidth || width}
headerRowRenderer={showTableHead ? headerRowRenderer : undefined}
rowGetter={({ index }) => data[index]}
/>
)}
</AutoSizer>
</div>
);
}