UNPKG

react-dual-listbox

Version:
818 lines (727 loc) 26.1 kB
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;