box-ui-elements-mlh
Version:
395 lines (323 loc) • 14.8 kB
Flow
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Set } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import { Hotkeys, HotkeyRecord } from '../hotkeys';
import messages from './messages';
import shiftSelect from './shiftSelect';
const SEARCH_TIMER_DURATION = 1000;
function makeSelectable(BaseTable) {
const originalDisplayName = BaseTable.displayName || BaseTable.name || 'Table';
return class SelectableTable extends Component {
static displayName = `Selectable(${originalDisplayName})`;
static propTypes = {
className: PropTypes.string,
/** Array of unique IDs of the items in the table. Each item should be a string or number, in the order they appear in the table. */
data: PropTypes.array.isRequired,
/** Called when focus changes. `(focusedIndex: number) => void` */
onFocus: PropTypes.func,
/** Called when selection changes. `(selectedItems: Array<string> | Array<number> | Set<string> | Set<number>) => void` */
onSelect: PropTypes.func.isRequired,
/**
* Array of strings for keyboard search corresponding to the data prop. If not provided, keyboard search won't work.
* Example: data = ['f_123', 'f_456'], and corresponding searchStrings = ['file.png', 'another file.pdf']
*/
searchStrings: PropTypes.array,
/**
* Array of IDs that are currently selected, in any order.
* If you pass a native JS array, then your onSelect function will be called with a native JS array;
* likewise, if you pass an ImmutableJS Set, then your onSelect function will be called
* with an ImmutableJS Set.
*/
selectedItems: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.set]),
enableHotkeys: PropTypes.bool,
/** Translated type for hotkeys. If not provided, then the hotkeys will not appear in the help modal. */
hotkeyType: PropTypes.string,
};
static defaultProps = {
selectedItems: new Set(),
};
constructor(props) {
super(props);
this.anchorIndex = 0;
this.searchString = '';
this.searchTimeout = null;
// we have to store the previously focused index because a focus event
// will be fired before the click event; thus, in the click handler,
// the focusedItem will already be the new item
this.previousIndex = 0;
this.blurTimerID = null;
}
state = {
focusedIndex: undefined,
};
componentDidMount() {
document.addEventListener('keypress', this.handleKeyboardSearch);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.focusedIndex !== this.state.focusedIndex && this.props.onFocus) {
this.props.onFocus(this.state.focusedIndex);
}
}
componentWillUnmount() {
document.removeEventListener('keypress', this.handleKeyboardSearch);
clearTimeout(this.blurTimerID);
}
onSelect = (selectedItems, newFocusedIndex) => {
const { onSelect } = this.props;
this.previousIndex = this.state.focusedIndex || 0;
this.setState({
focusedIndex: newFocusedIndex,
});
if (onSelect) {
// If selectedItems were given as an Immutable Set, they should also be returned as one,
// and vice versa if they were given as a native JS array
onSelect(Set.isSet(this.props.selectedItems) ? selectedItems : selectedItems.toJS());
}
};
getHotkeyConfigs = () => {
const { enableHotkeys, hotkeyType } = this.props;
if (!enableHotkeys && !this.hotkeys) {
this.hotkeys = [];
}
if (!this.hotkeys) {
this.hotkeys = [
new HotkeyRecord({
key: 'down',
description: <FormattedMessage {...messages.downDescription} />,
handler: event => {
const { data } = this.props;
const { focusedIndex } = this.state;
event.preventDefault();
const newFocusedIndex =
focusedIndex !== undefined ? Math.min(focusedIndex + 1, data.length - 1) : 0;
this.setState({ focusedIndex: newFocusedIndex });
},
type: hotkeyType,
}),
new HotkeyRecord({
key: 'up',
description: <FormattedMessage {...messages.upDescription} />,
handler: event => {
const { focusedIndex = 0 } = this.state;
event.preventDefault();
const newFocusedIndex = Math.max(focusedIndex - 1, 0);
this.setState({ focusedIndex: newFocusedIndex });
},
type: hotkeyType,
}),
new HotkeyRecord({
key: 'shift+x',
description: <FormattedMessage {...messages.shiftXDescription} />,
handler: () => {
const { focusedIndex } = this.state;
if (focusedIndex === undefined) {
return;
}
this.selectToggle(focusedIndex);
},
type: hotkeyType,
}),
new HotkeyRecord({
key: ['meta+a', 'ctrl+a'],
description: <FormattedMessage {...messages.selectAllDescription} />,
handler: event => {
const { data } = this.props;
event.preventDefault();
this.onSelect(new Set(data), this.state.focusedIndex);
},
type: hotkeyType,
}),
new HotkeyRecord({
key: 'shift+down',
description: <FormattedMessage {...messages.shiftDownDescription} />,
handler: () => {
const { data } = this.props;
const { focusedIndex } = this.state;
if (focusedIndex === undefined) {
return;
}
const newFocusedIndex = Math.min(focusedIndex + 1, data.length - 1);
this.handleShiftKeyDown(newFocusedIndex, data.length - 1);
},
type: hotkeyType,
}),
new HotkeyRecord({
key: 'shift+up',
description: <FormattedMessage {...messages.shiftUpDescription} />,
handler: () => {
const { focusedIndex } = this.state;
if (focusedIndex === undefined) {
return;
}
const newFocusedIndex = Math.max(focusedIndex - 1, 0);
this.handleShiftKeyDown(newFocusedIndex, 0);
},
type: hotkeyType,
}),
new HotkeyRecord({
key: 'esc',
description: <FormattedMessage {...messages.deselectAllDescription} />,
handler: () => {
this.onSelect(new Set(), this.state.focusedIndex);
},
type: hotkeyType,
}),
];
}
return this.hotkeys;
};
getProcessedProps = () => {
const { selectedItems } = this.props;
return {
...this.props,
selectedItems: Set.isSet(selectedItems) ? selectedItems : new Set(selectedItems),
};
};
hotkeys = null;
selectToggle = rowIndex => {
const { data, selectedItems } = this.getProcessedProps();
if (selectedItems.has(data[rowIndex])) {
this.onSelect(selectedItems.delete(data[rowIndex]), rowIndex);
} else {
this.onSelect(selectedItems.add(data[rowIndex]), rowIndex);
}
this.anchorIndex = rowIndex;
};
selectRange = rowIndex => {
const { data, selectedItems } = this.getProcessedProps();
// Don't change selection if we're shift-clicking the same row
if (rowIndex === this.previousIndex) {
return;
}
// Converts set of items to set of indices to do some slicing magic
const selectedRows = new Set(
data.reduce((rows, item, i) => {
if (selectedItems.has(item)) {
rows.push(i);
}
return rows;
}, []),
);
const newSelectedRows = shiftSelect(selectedRows, this.previousIndex, rowIndex, this.anchorIndex);
// Converts set back to set of items
const newSelectedItems = newSelectedRows.map(i => data[i]);
this.onSelect(newSelectedItems, rowIndex);
};
selectOne = rowIndex => {
const { data, selectedItems } = this.getProcessedProps();
// Don't change selection if we're clicking on a row that we've already selected
// This allows us to use the native onDoubleClick handler because we're referencing the
// same DOM node on double-click.
if (selectedItems.has(data[rowIndex]) && selectedItems.size === 1) {
return;
}
this.onSelect(new Set([data[rowIndex]]), rowIndex);
this.anchorIndex = rowIndex;
};
clearFocus = () => {
this.setState({
focusedIndex: undefined,
});
};
handleRowClick = (event, index) => {
if (event.metaKey || event.ctrlKey) {
this.selectToggle(index);
} else if (event.shiftKey) {
this.selectRange(index);
} else {
this.selectOne(index);
}
};
handleRowFocus = (event, index) => {
const { selectedItems } = this.getProcessedProps();
this.onSelect(selectedItems, index);
};
handleTableBlur = () => {
const { focusedIndex } = this.state;
if (focusedIndex !== undefined) {
// table may get focus back right away in the same tick, in which case we shouldn't clear focus
this.blurTimerID = setTimeout(this.clearFocus);
}
};
handleTableFocus = () => {
clearTimeout(this.blurTimerID);
};
handleShiftKeyDown = (newFocusedIndex, boundary) => {
const { data, selectedItems } = this.getProcessedProps();
const { focusedIndex } = this.state;
// if we're at a boundary of the table and the row is selected, no-op
if (focusedIndex === boundary && selectedItems.has(data[focusedIndex])) {
return;
}
// if target is not selected, select it
if (!selectedItems.has(data[newFocusedIndex])) {
this.onSelect(selectedItems.add(data[newFocusedIndex]), newFocusedIndex);
return;
}
// if both source and target are selected, deselect source
if (selectedItems.has(data[newFocusedIndex]) && selectedItems.has(data[focusedIndex])) {
this.onSelect(selectedItems.delete(data[focusedIndex]), newFocusedIndex);
return;
}
// if target is selected and source is not, select source
this.onSelect(selectedItems.add(data[focusedIndex]), newFocusedIndex);
};
handleKeyboardSearch = event => {
const { searchStrings } = this.props;
if (!searchStrings) {
return;
}
if (
event.target.hasAttribute('contenteditable') ||
event.target.nodeName === 'INPUT' ||
event.target.nodeName === 'TEXTAREA'
) {
return;
}
// character keys have a value for event.which
if (event.which === 0) {
return;
}
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searchString += event.key;
this.searchTimeout = setTimeout(() => {
this.searchString = '';
}, SEARCH_TIMER_DURATION);
const index = searchStrings.findIndex(
string =>
string
.trim()
.toLowerCase()
.indexOf(this.searchString) === 0,
);
if (index !== -1) {
this.setState({ focusedIndex: index });
}
};
render() {
const { className, data } = this.props;
const { focusedIndex } = this.state;
const focusedItem = data[focusedIndex];
return (
<Hotkeys configs={this.getHotkeyConfigs()}>
<BaseTable
{...this.props}
className={classNames(className, 'is-selectable')}
focusedIndex={focusedIndex}
focusedItem={focusedItem}
onRowClick={this.handleRowClick}
onRowFocus={this.handleRowFocus}
onTableBlur={this.handleTableBlur}
onTableFocus={this.handleTableFocus}
/>
</Hotkeys>
);
}
};
}
export default makeSelectable;