react-dual-listbox
Version:
A feature-rich dual listbox for React.
818 lines (727 loc) • 26.1 kB
JSX
import classNames from 'classnames';
import escapeRegExp from 'lodash/escapeRegExp';
import PropTypes from 'prop-types';
import React, {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import refShape from '../shapes/refShape';
import optionsShape from '../shapes/optionsShape';
import valueShape from '../shapes/valueShape';
import indexesOf from '../util/indexesOf';
import mergeRefs from '../util/mergeRefs';
import swapOptions from '../util/swapOptions';
import { ALIGNMENTS, KEYS } from '../constants';
import Action from './Action';
import HiddenInput from './HiddenInput';
import ListBox from './ListBox';
const propTypes = {
options: optionsShape.isRequired,
onChange: PropTypes.func.isRequired,
alignActions: PropTypes.oneOf([ALIGNMENTS.MIDDLE, ALIGNMENTS.TOP]),
allowDuplicates: PropTypes.bool,
available: valueShape,
availableRef: refShape,
canFilter: PropTypes.bool,
className: PropTypes.string,
disabled: PropTypes.bool,
filter: PropTypes.shape({
available: PropTypes.string.isRequired,
selected: PropTypes.string.isRequired,
}),
filterCallback: PropTypes.func,
getOptionLabel: PropTypes.func,
getOptionValue: PropTypes.func,
htmlDir: PropTypes.string,
iconsClass: PropTypes.string,
id: PropTypes.string,
moveKeys: PropTypes.arrayOf(PropTypes.string),
name: PropTypes.string,
preserveSelectOrder: PropTypes.bool,
required: PropTypes.bool,
selected: valueShape,
selectedRef: refShape,
showHeaderLabels: PropTypes.bool,
showNoOptionsText: PropTypes.bool,
showOrderButtons: PropTypes.bool,
onFilterChange: PropTypes.func,
};
const defaultFilter = (option, filterInput, { getOptionLabel }) => {
if (filterInput === '') {
return true;
}
return (new RegExp(escapeRegExp(filterInput), 'i')).test(getOptionLabel(option));
};
const defaultProps = {
alignActions: ALIGNMENTS.MIDDLE,
allowDuplicates: false,
available: undefined,
availableRef: null,
canFilter: false,
className: null,
disabled: false,
filter: null,
filterCallback: defaultFilter,
getOptionLabel: ({ label }) => label,
getOptionValue: ({ value }) => value,
htmlDir: 'ltr',
iconsClass: 'fa6',
id: 'rdl',
moveKeys: [KEYS.SPACEBAR, KEYS.ENTER],
name: null,
preserveSelectOrder: null,
required: false,
selected: [],
selectedRef: null,
showHeaderLabels: false,
showNoOptionsText: false,
showOrderButtons: false,
onFilterChange: null,
};
/* eslint-disable react/require-default-props */
function DualListBox(props) {
const { selected, filter: filterProp } = props;
const availableRef = useRef(null);
const selectedRef = useRef(null);
const [filter, setFilter] = useState(filterProp !== null ? filterProp : {
available: '',
selected: '',
});
const [selections, setSelections] = useState({
available: [],
selected: [],
});
// Update the filter state if the caller changes the property
useEffect(() => {
if (filterProp !== null) {
setFilter(filterProp);
}
}, [filterProp]);
/**
* Flattens a hierarchical list of options to a key/value mapping.
*
* @param {Array} options
*
* @returns {Object}
*/
function getValueMap(options) {
const { getOptionValue } = props;
let valueMap = {};
options.forEach((option) => {
const { options: children } = option;
const value = getOptionValue(option);
if (children !== undefined) {
valueMap = { ...valueMap, ...getValueMap(children) };
} else {
valueMap[value] = option;
}
});
return valueMap;
}
/**
* Returns the highlighted options from a given element.
*
* @param {Object} element
*
* @returns {Array}
*/
function getMarkedOptions(element) {
if (element === null) {
return [];
}
return Array.from(element.options)
.map(({ dataset, label, selected: isSelected }, index) => ({
index,
isSelected,
label,
order: parseInt(dataset.order, 10),
value: JSON.parse(dataset.value),
}))
.filter(({ isSelected }) => isSelected);
}
/**
* Filter the given options with by filtering function and the search string.
*
* @param {Array} options
* @param {Function} filterer
* @param {string} filterInput
* @param {boolean} ignoreSearch
*
* @returns {Array}
*/
function filterOptions(options, filterer, filterInput, ignoreSearch = false) {
const { canFilter, filterCallback } = props;
const filtered = [];
options.forEach((option) => {
if (option.options !== undefined) {
// Recursively filter any children
const filteredChildren = filterOptions(
option.options,
filterer,
filterInput,
// If the parent succeeds the search filter, then all children also pass
ignoreSearch || filterCallback(option, filterInput, props),
);
if (filteredChildren.length > 0) {
filtered.push({
...option,
options: filteredChildren,
});
}
} else {
const subFiltered = [];
// Run the main (non-search) filterer against the given item
const filterResult = filterer(option);
// For selected options, the filterer returns the indexes of all instances of a
// given option, because `allowDuplicates` allows for multiple instances, in
// contrast to available options.
if (Array.isArray(filterResult)) {
filterResult.forEach((index) => {
subFiltered.push({
...option,
order: index,
});
});
} else if (filterResult) {
// The available options filterer is simpler, as there can only be one instance
subFiltered.push(option);
}
// If any matched options go through, optionally apply user filtering and then add
// these options to the filtered list. The text search filtering is applied AFTER
// the main filtering to prevent unnecessary calls to the filterCallback function.
if (subFiltered.length > 0) {
if (
canFilter &&
!ignoreSearch &&
!filterCallback(option, filterInput, props)
) {
return;
}
subFiltered.forEach((subItem) => {
filtered.push(subItem);
});
}
}
});
return filtered;
}
/**
* Filter the available options.
*
* @param {Array} options
* @param {boolean} ignoreSearch Ignore the search filter.
*
* @returns {Array}
*/
function filterAvailable(options, ignoreSearch = false) {
const { allowDuplicates, available, getOptionValue } = props;
const { available: availableFilter } = filter;
const filters = [];
// Apply user-defined available restrictions, if any
if (available !== undefined) {
filters.push((option) => available.indexOf(getOptionValue(option)) >= 0);
}
// If duplicates are not allowed, filter out selected options
if (!allowDuplicates) {
filters.push((option) => selected.indexOf(getOptionValue(option)) < 0);
}
// Apply each filter function on the option
const filterer = (option) => filters.reduce(
(previousValue, filterFunction) => previousValue && filterFunction(option),
true,
);
return filterOptions(options, filterer, availableFilter, ignoreSearch);
}
/**
* Filter the selected options by selection order. This drops the optgroup associations.
*
* @param {Array} options
*
* @returns {Array}
*/
function filterSelectedByOrder(options) {
const { canFilter, filterCallback } = props;
const { selected: selectedFilter } = filter;
const valueMap = getValueMap(options);
// Compile the full details of all selected options, including the selection order
const selectedOptions = selected.map((value, index) => ({
...valueMap[value],
order: index,
}));
if (canFilter) {
return selectedOptions.filter(
(selectedOption) => filterCallback(selectedOption, selectedFilter, props),
);
}
return selectedOptions;
}
/**
* Filter the selected options.
*
* @param {Array} options
* @param {boolean} ignoreSearch Ignore the search filter.
*
* @returns {Array}
*/
function filterSelected(options, ignoreSearch = false) {
const { getOptionValue, preserveSelectOrder } = props;
const { selected: selectedFilter } = filter;
// Filter and order the selections by selection order
if (preserveSelectOrder) {
return filterSelectedByOrder(options);
}
// Filter and order the selections by the default order
return filterOptions(
options,
(option) => indexesOf(selected, getOptionValue(option)),
selectedFilter,
ignoreSearch,
);
}
/**
* Re-arrange the marked options to move up or down in the selected list.
*
* @param {Array} markedOptions
* @param {string} direction
*
* @returns {Array}
*/
function rearrangeSelected(markedOptions, direction) {
let newOrder = [...selected];
if (markedOptions.length === 0) {
return newOrder;
}
if (direction === 'up') {
// If all the marked options are already as high as they can get, ignore the
// re-arrangement request because they will end of swapping their order amongst
// themselves.
if (markedOptions[markedOptions.length - 1].order > markedOptions.length - 1) {
markedOptions.forEach(({ order }) => {
if (order > 0) {
newOrder = swapOptions(order, order - 1)(newOrder);
}
});
}
} else if (direction === 'down') {
// Similar to the above, if all the marked options are already as low as they can get,
// ignore the re-arrangement request.
if (markedOptions[0].order < selected.length - markedOptions.length) {
markedOptions.reverse().forEach(({ order }) => {
if (order < selected.length - 1) {
newOrder = swapOptions(order, order + 1)(newOrder);
}
});
}
}
return newOrder;
}
/**
* Move the marked options to the top or bottom of the selected options.
*
* @param {Array} markedOptions
* @param {string} direction 'top' | 'bottom'
*
* @returns {Array}
*/
function rearrangeToExtremes(markedOptions, direction) {
let unmarked = [...selected];
// Filter out marked options
markedOptions.forEach(({ order }) => {
unmarked[order] = null;
});
unmarked = unmarked.filter((v) => v !== null);
// Condense marked options raw values
const marked = markedOptions.map(({ order }) => selected[order]);
if (direction === 'top') {
return [...marked, ...unmarked];
}
return [...unmarked, ...marked];
}
/**
* Recursively make the given set of options selected.
*
* @param {Array} options
*
* @returns {String[]}
*/
function makeOptionsSelectedRecursive(options) {
const { getOptionValue } = props;
let newSelected = [];
options.forEach((option) => {
// Skip disabled options
if (option.disabled) {
return;
}
if (option.options !== undefined) {
newSelected = [
...newSelected,
...makeOptionsSelectedRecursive(option.options),
];
} else {
newSelected.push(getOptionValue(option));
}
});
return newSelected;
}
/**
* Make all the given options selected, appending them after the existing selections.
*
* @param {Array} options
*
* @returns {String[]}
*/
function makeOptionsSelected(options) {
const availableOptions = filterAvailable(options);
return [
...selected,
...makeOptionsSelectedRecursive(availableOptions),
];
}
/**
* Recursively unselect the given options, except for those disabled.
*
* @param {String[]} previousSelected
* @param {Array} optionsToRemove
*
* @returns {String[]}
*/
function makeOptionsUnselectedRecursive(previousSelected, optionsToRemove) {
const { getOptionValue } = props;
let newSelected = [...previousSelected];
optionsToRemove.forEach((option) => {
if (option.options !== undefined) {
// Traverse any parents for leaf options
newSelected = makeOptionsUnselectedRecursive(newSelected, option.options);
} else if (!option.disabled) {
// Remove non-disabled options
newSelected = newSelected.filter((oldValue) => (
oldValue !== getOptionValue(option)
));
}
});
return newSelected;
}
/**
* Make all the given options unselected, except for those disabled.
*
* @param {Array} options
*
* @returns {Array}
*/
function makeOptionsUnselected(options) {
return makeOptionsUnselectedRecursive(selected, filterSelected(options));
}
/**
* Toggle a set of highlighted elements.
*
* @param {Array} toggleItems
* @param {string} controlKey
*
* @returns {Array}
*/
function toggleHighlighted(toggleItems, controlKey) {
const { allowDuplicates } = props;
const selectedItems = selected.slice(0);
const toggleItemsMap = { ...selectedItems };
// Add/remove the individual items based on previous state
toggleItems.forEach(({ value, order }) => {
const inSelectedOptions = selectedItems.indexOf(value) > -1;
if (inSelectedOptions && (!allowDuplicates || controlKey === 'selected')) {
// Toggled items that were previously selected are removed unless `allowDuplicates`
// is set to true or the option was sourced from the selected ListBox. We use an
// object mapping such that we can remove the exact index of the selected items
// without the array re-arranging itself.
delete toggleItemsMap[order];
} else {
selectedItems.push(value);
}
});
// Convert object mapping back to an array
if (controlKey === 'selected') {
return Object.keys(toggleItemsMap).map((key) => toggleItemsMap[key]);
}
return selectedItems;
}
/**
* @param {Array} newSelected The new selected values.
* @param {Array} selection The options the user highlighted (if any).
* @param {string} controlKey The key for the control that fired this event.
* @param {boolean} isRearrange Whether the change is a result of re-arrangement.
*
* @returns {void}
*/
function onChange(newSelected, selection, controlKey, isRearrange = false) {
const { onChange: onChangeProp } = props;
const userSelection = selection.map(({ index, label, value }) => ({ index, label, value }));
onChangeProp(newSelected, userSelection, controlKey);
// Reset selections after moving items for cleaner experience and to remove invalid values
// Note that this should not occur for re-arrangement operations
if (!isRearrange) {
setSelections({
...selections,
[controlKey]: [],
});
}
}
/**
* @param {string} direction
* @param {boolean} isMoveAll
*
* @returns {void}
*/
const onActionClick = useCallback(({ direction, isMoveAll }) => {
const { options } = props;
const isToSelected = direction === 'toSelected';
const sourceListBox = isToSelected ? availableRef : selectedRef;
const marked = getMarkedOptions(sourceListBox.current);
let isRearrangement = false;
let newSelected;
if (['up', 'down'].indexOf(direction) > -1) {
isRearrangement = true;
newSelected = rearrangeSelected(marked, direction);
} else if (['top', 'bottom'].indexOf(direction) > -1) {
isRearrangement = true;
newSelected = rearrangeToExtremes(marked, direction);
} else if (isMoveAll) {
newSelected = isToSelected ?
makeOptionsSelected(options) :
makeOptionsUnselected(options);
} else {
newSelected = toggleHighlighted(
marked,
isToSelected ? 'available' : 'selected',
);
}
onChange(newSelected, marked, isToSelected ? 'available' : 'selected', isRearrangement);
}, [selected, filter]);
/**
* @param {Object} event
* @param {string} controlKey
*
* @returns {void}
*/
const onOptionDoubleClick = useCallback((event, controlKey) => {
// Prevent double click from parent triggering a selected option
if (event.target.tagName === 'OPTGROUP') {
return;
}
const marked = getMarkedOptions(event.currentTarget);
const newSelected = toggleHighlighted(marked, controlKey);
onChange(newSelected, marked, controlKey);
}, [selected]);
/**
* @param {Event} event
* @param {string} controlKey
*
* @returns {void}
*/
const onOptionKeyUp = useCallback((event, controlKey) => {
const { currentTarget, key } = event;
const { moveKeys } = props;
if (moveKeys.indexOf(key) > -1) {
const marked = getMarkedOptions(currentTarget);
const newSelected = toggleHighlighted(marked, controlKey);
onChange(newSelected, marked, controlKey);
}
}, [selected]);
/**
* @param {Event} event
* @param {string} controlKey
*
* @returns {void}
*/
const onSelectionChange = useCallback((event, controlKey) => {
const { target: { options } } = event;
const newSelections = Array.from(options)
.filter(({ selected: isSelected }) => isSelected)
.map(({ value }) => value);
setSelections({
...selections,
[controlKey]: newSelections,
});
}, [selections]);
/**
* @param {Event} event
*
* @returns {void}
*/
const onFilterChangeCallback = useCallback((event) => {
const { onFilterChange } = props;
const { target: { value, dataset: { controlKey } } } = event;
const newFilter = { ...filter, [controlKey]: value };
if (onFilterChange) {
onFilterChange(newFilter);
} else {
setFilter(newFilter);
}
}, [filter]);
/**
* Focus the selected list-box whenever a form flags this component as invalid.
*
* @returns {void}
*/
const onHiddenFocus = useCallback(() => {
availableRef.current.focus();
}, []);
/**
* @param {Array} options
*
* @returns {Array}
*/
function renderOptions(options) {
const { allowDuplicates, getOptionLabel, getOptionValue } = props;
return options.map((option) => {
const label = getOptionLabel(option);
const value = getOptionValue(option);
const key = !allowDuplicates ?
`${value}-${label}` :
`${value}-${label}-${option.order}`;
if (option.options !== undefined) {
return (
<optgroup
key={key}
disabled={option.disabled}
label={label}
title={option.title}
>
{renderOptions(option.options)}
</optgroup>
);
}
// If we allow duplicates, append the index to keep each entry unique such that the
// controlled component can easily update its state
const optionValue = !allowDuplicates ? value : `${value}-${option.order}`;
return (
<option
key={key}
data-order={option.order}
data-value={JSON.stringify(value)}
disabled={option.disabled}
title={option.title}
value={optionValue}
>
{label}
</option>
);
});
}
/**
* @param {string} controlKey
* @param {Array} options
* @param {React.MutableRefObject} ref
* @param {JSX.Element} actions
*
* @returns {JSX.Element}
*/
function renderListBox(controlKey, options, ref, actions) {
const {
alignActions,
canFilter,
[`${controlKey}Ref`]: refProp,
disabled,
id,
showNoOptionsText,
} = props;
// Wrap event handlers with a controlKey reference
const wrapHandler = (handler) => ((event) => handler(event, controlKey));
return (
<ListBox
actions={alignActions === ALIGNMENTS.TOP ? actions : null}
canFilter={canFilter}
controlKey={controlKey}
disabled={disabled}
filterValue={filter[controlKey]}
id={id}
inputRef={mergeRefs([ref, refProp])}
selections={selections[controlKey]}
showNoOptionsText={showNoOptionsText}
onDoubleClick={wrapHandler(onOptionDoubleClick)}
onFilterChange={wrapHandler(onFilterChangeCallback)}
onKeyUp={wrapHandler(onOptionKeyUp)}
onSelectionChange={wrapHandler(onSelectionChange)}
>
{options}
</ListBox>
);
}
const {
alignActions,
canFilter,
className,
disabled,
htmlDir,
iconsClass,
id,
name,
options,
preserveSelectOrder,
required,
showHeaderLabels,
showOrderButtons,
} = props;
const availableOptions = renderOptions(filterAvailable(options));
const selectedOptions = renderOptions(filterSelected(options));
const makeAction = (direction, isMoveAll = false) => (
<Action
direction={direction}
disabled={disabled}
isMoveAll={isMoveAll}
onClick={onActionClick}
/>
);
const actionsToSelected = (
<div className="rdl-actions-to-selected">
{makeAction('toSelected', true)}
{makeAction('toSelected')}
</div>
);
const actionsToAvailable = (
<div className="rdl-actions-to-available">
{makeAction('toAvailable')}
{makeAction('toAvailable', true)}
</div>
);
const rootClassName = classNames({
'react-dual-listbox': true,
[`rdl-icons-${iconsClass}`]: true,
'rdl-has-filter': canFilter,
'rdl-has-header': showHeaderLabels,
'rdl-align-top': alignActions === ALIGNMENTS.TOP,
...(className && { [className]: true }),
});
return (
<div className={rootClassName} dir={htmlDir} id={id}>
<div className="rdl-controls">
{renderListBox('available', availableOptions, availableRef, actionsToSelected)}
{alignActions === ALIGNMENTS.MIDDLE ? (
<div className="rdl-actions">
{actionsToSelected}
{actionsToAvailable}
</div>
) : null}
{renderListBox('selected', selectedOptions, selectedRef, actionsToAvailable)}
{preserveSelectOrder && showOrderButtons ? (
<div className="rdl-actions">
{makeAction('top')}
{makeAction('up')}
{makeAction('down')}
{makeAction('bottom')}
</div>
) : null}
</div>
<HiddenInput
availableRef={availableRef}
disabled={disabled}
name={name}
required={required}
selected={selected}
onFocus={onHiddenFocus}
/>
</div>
);
}
DualListBox.propTypes = propTypes;
export { propTypes, defaultProps };
export default DualListBox;