diff --git a/src/Transfer.js b/src/Transfer.js new file mode 100644 index 000000000..a62ff9b29 --- /dev/null +++ b/src/Transfer.js @@ -0,0 +1,412 @@ +import './Transfer/types.js' + +import React, { Children, useState } from 'react' +import propTypes from '@dhis2/prop-types' + +import { Actions } from './Transfer/Actions.js' +import { AddAll } from './Transfer/AddAll.js' +import { AddIndividual } from './Transfer/AddIndividual.js' +import { Container } from './Transfer/Container.js' +import { Filter } from './Transfer/Filter.js' +import { LeftFooter } from './Transfer/LeftFooter.js' +import { LeftHeader } from './Transfer/LeftHeader.js' +import { LeftSide } from './Transfer/LeftSide.js' +import { PickedOptions } from './Transfer/PickedOptions.js' +import { RemoveAll } from './Transfer/RemoveAll.js' +import { RemoveIndividual } from './Transfer/RemoveIndividual.js' +import { ReorderingActions } from './Transfer/ReorderingActions.js' +import { RightFooter } from './Transfer/RightFooter.js' +import { RightSide } from './Transfer/RightSide.js' +import { SourceOptions } from './Transfer/SourceOptions.js' +import { + addAllSelectableSourceOptions, + addIndividualSourceOptions, + createDoubleClickHandlers, + extractPickedReactOptions, + defaultFilterCallback, + filterOutOptions, + getSubsetByFilter, + isReorderDownDisabled, + isReorderUpDisabled, + moveHighlightedPickedOptionDown, + moveHighlightedPickedOptionUp, + removeAllPickedOptions, + removeIndividualPickedOptions, + useHighlightedOptions, +} from './Transfer/helper/index.js' +import { filterReactOptionsBy } from './Transfer/helper/filterReactOptionsBy' +import { + singleSelectedPropType, + multiSelectedPropType, +} from './common-prop-types.js' + +/** + * @module + * @param {Transfer.PropTypes} props + * + * @returns {React.Component} + * + * @example import { Transfer } from @dhis2/ui-core + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/organisms/transfer.md|Design system} + * @see Live demo: {@link /demo/?path=/story/transfer--basic|Storybook} + * + * This component has to differentiate between different types of options, + * here's an explanation of their meaning: + * + * * source options -> These are options listed on the left and are available + * for selection + * + * * picked options -> These options have been selected by the user and are on + * the right side + * + * * highlighted option -> These are visually highlighted options than can be + * on either side and are ready for transferral with the action buttons to the + * other side + * + * * filtered options -> These are the displayed source options filtered + * by a search term or a custom search algorithm. + * + * The api surface uses "selected" for "picked" to be consistent with the + * rest of the library + */ +export const Transfer = ({ + onChange, + + children, + className, + dataTest, + disabled, + sourceEmptyPlaceholder, + selectedEmptyComponent, + enableOrderChange, + filterLabel, + filterCallback, + filterable, + height, + initialSearchTerm, + addAllText, + addIndividualText, + removeAllText, + removeIndividualText, + leftFooter, + leftHeader, + maxSelections, + optionsWidth, + rightFooter, + searchTerm, + selected, + selectedWidth, + hideFilterInput, + onFilterChange, +}) => { + /* + * Used in the "Filter" section and for + * limiting the selectable source options + * + * Filter can be controlled & uncontrolled. + * Providing the "onFilterChange" callback will make it a controlled value + */ + const [internalFilter, setInternalFilter] = useState(initialSearchTerm) + const actualFilter = onFilterChange ? searchTerm : internalFilter + + /* + * These are all the not-selected option react elements. + * It will replace all selected options with null + */ + const sourceOptions = filterOutOptions(children, selected) + const filteredSourceOptions = getSubsetByFilter({ + reactOptions: sourceOptions, + filter: actualFilter, + filterable, + filterCallback, + }) + + /* + * Extract the selected options. This way custom options are supported + * without having to provide a component via the props + * + * Children are sorted by the order given in the "selected" array. + * This is done in order to cover the "append newly selected items + * at the end" feature/behavior. + */ + const pickedOptions = extractPickedReactOptions({ + reactOptions: children, + selectedPlainOptions: selected, + }) + + /* + * These are all the highlighted option react elements on the options side. + */ + const { + highlightedOptions: highlightedSourceOptions, + setHighlightedOptions: setHighlightedSourceOptions, + toggleHighlightedOption: toggleHighlightedSourceOption, + } = useHighlightedOptions({ + reactOptions: filteredSourceOptions, + disabled, + maxSelections, + }) + + /* + * These are all the highlighted option react elements on the selected side. + */ + const { + highlightedOptions: highlightedPickedOptions, + setHighlightedOptions: setHighlightedPickedOptions, + toggleHighlightedOption: toggleHighlightedPickedOption, + } = useHighlightedOptions({ + reactOptions: pickedOptions, + disabled, + maxSelections, + }) + + const { + selectSingleOption, + deselectSingleOption, + } = createDoubleClickHandlers({ + selectedPlainOptions: selected, + setHighlightedSourceOptions, + setHighlightedPickedOptions, + onChange, + maxSelections, + }) + + return ( + + + {(leftHeader || filterable) && ( + + {leftHeader} + + {filterable && !hideFilterInput && ( + + setInternalFilter(value) + } + /> + )} + + )} + + + {filteredSourceOptions} + + + {leftFooter && ( + + {leftFooter} + + )} + + + + {maxSelections === Infinity && ( + !disabled, + filteredSourceOptions + ) + ) + } + onClick={() => + addAllSelectableSourceOptions({ + sourceReactOptions: filteredSourceOptions, + selectedPlainOptions: selected, + onChange, + setHighlightedSourceOptions, + }) + } + /> + )} + + + addIndividualSourceOptions({ + filterable, + filteredSourcePlainOptions: filteredSourceOptions, + highlightedSourcePlainOptions: highlightedSourceOptions, + selectedPlainOptions: selected, + maxSelections, + onChange, + setHighlightedSourceOptions, + }) + } + /> + + {maxSelections === Infinity && ( + + removeAllPickedOptions({ + setHighlightedPickedOptions, + onChange, + }) + } + /> + )} + + + removeIndividualPickedOptions({ + highlightedPickedReactOptions: highlightedPickedOptions, + onChange, + selectedPlainOptions: selected, + setHighlightedPickedOptions, + }) + } + /> + + + + + {pickedOptions} + + + {(rightFooter || enableOrderChange) && ( + + {enableOrderChange && ( + + moveHighlightedPickedOptionUp({ + selectedPlainOptions: selected, + highlightedPickedPlainOptions: highlightedPickedOptions, + onChange, + }) + } + onChangeDown={() => { + moveHighlightedPickedOptionDown({ + selectedPlainOptions: selected, + highlightedPickedPlainOptions: highlightedPickedOptions, + onChange, + }) + }} + /> + )} + + {rightFooter} + + )} + + + ) +} + +Transfer.defaultProps = { + dataTest: 'dhis2-uicore-transfer', + initialSearchTerm: '', + selected: [], + height: '240px', + optionsWidth: '320px', + selectedWidth: '320px', + maxSelections: Infinity, + filterCallback: defaultFilterCallback, +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {Function} onChange + * @prop {string} [addAllText] + * @prop {string} [addIndividualText] + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + * @prop {Node} [sourceEmptyPlaceholder] + * @prop {Node} [selectedEmptyComponent] + * @prop {bool} [hideFilterInput] Automatically true when "hideFilterInput" is true + * @prop {bool} [enableOrderChange] + * @prop {string} [filterLabel] + * @prop {Function} [filterCallback] + * @prop {string} [height] + * @prop {bool} [hideFilterInput] + * @prop {string} [initialSearchTerm] + * @prop {Node} [leftFooter] + * @prop {Node} [leftHeader] + * @prop {1|Infinity} maxSelections + * @prop {string} [optionsWidth] + * @prop {string} [removeAllText] + * @prop {string} [removeIndividualText] + * @prop {Node} [rightFooter] + * @prop {string} [searchTerm] + * @prop {Option|Option[]} selected + * @prop {string} [selectedWidth] + * @prop {Function} [onFilterChange] + */ +Transfer.propTypes = { + onChange: propTypes.func.isRequired, + + addAllText: propTypes.string, + addIndividualText: propTypes.string, + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + enableOrderChange: propTypes.bool, + filterCallback: propTypes.func, + filterLabel: propTypes.string, + filterable: propTypes.bool, + height: propTypes.string, + hideFilterInput: propTypes.bool, + initialSearchTerm: propTypes.string, + leftFooter: propTypes.node, + leftHeader: propTypes.node, + maxSelections: propTypes.oneOf([1, Infinity]), + optionsWidth: propTypes.string, + removeAllText: propTypes.string, + removeIndividualText: propTypes.string, + rightFooter: propTypes.node, + searchTerm: propTypes.string, + selected: propTypes.oneOfType([ + singleSelectedPropType, + multiSelectedPropType, + ]), + selectedEmptyComponent: propTypes.node, + selectedWidth: propTypes.string, + sourceEmptyPlaceholder: propTypes.node, + onFilterChange: propTypes.func, +} diff --git a/src/Transfer/Actions.js b/src/Transfer/Actions.js new file mode 100644 index 000000000..8d6b59b1e --- /dev/null +++ b/src/Transfer/Actions.js @@ -0,0 +1,33 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { spacers } from '../theme.js' + +export const Actions = ({ children, dataTest }) => ( +
+ {children} + + +
+) + +Actions.propTypes = { + dataTest: propTypes.string.isRequired, + children: propTypes.node, +} diff --git a/src/Transfer/AddAll.js b/src/Transfer/AddAll.js new file mode 100644 index 000000000..87d82723d --- /dev/null +++ b/src/Transfer/AddAll.js @@ -0,0 +1,28 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { Button } from '../Button' +import { IconAddAll } from './icons' + +export const AddAll = ({ label, dataTest, disabled, onClick }) => ( + +) + +AddAll.propTypes = { + dataTest: propTypes.string.isRequired, + onClick: propTypes.func.isRequired, + disabled: propTypes.bool, + label: propTypes.string, +} diff --git a/src/Transfer/AddIndividual.js b/src/Transfer/AddIndividual.js new file mode 100644 index 000000000..94fd14fff --- /dev/null +++ b/src/Transfer/AddIndividual.js @@ -0,0 +1,28 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { Button } from '../Button' +import { IconAddIndividual } from './icons' + +export const AddIndividual = ({ label, dataTest, disabled, onClick }) => ( + +) + +AddIndividual.propTypes = { + dataTest: propTypes.string.isRequired, + onClick: propTypes.func.isRequired, + disabled: propTypes.bool, + label: propTypes.string, +} diff --git a/src/Transfer/Container.js b/src/Transfer/Container.js new file mode 100644 index 000000000..dfbf6b186 --- /dev/null +++ b/src/Transfer/Container.js @@ -0,0 +1,23 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +export const Container = ({ children, dataTest, className, height }) => ( +
+ {children} + + +
+) + +Container.propTypes = { + height: propTypes.string.isRequired, + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/src/Transfer/Filter.js b/src/Transfer/Filter.js new file mode 100644 index 000000000..aa7cdc2c2 --- /dev/null +++ b/src/Transfer/Filter.js @@ -0,0 +1,46 @@ +import { resolve } from 'styled-jsx/css' +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { InputField } from '../InputField.js' +import { spacers } from '../theme.js' + +// "div" is required for specificity +const filterStyles = resolve` + div { + margin: 0; + } +` + +export const Filter = ({ dataTest, filter, onChange, label }) => { + return ( +
+ + + {filterStyles.styles} + +
+ ) +} + +Filter.propTypes = { + dataTest: propTypes.string.isRequired, + filter: propTypes.string.isRequired, + onChange: propTypes.func.isRequired, + label: propTypes.string, +} diff --git a/src/Transfer/LeftFooter.js b/src/Transfer/LeftFooter.js new file mode 100644 index 000000000..f83d6eecb --- /dev/null +++ b/src/Transfer/LeftFooter.js @@ -0,0 +1,24 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { borderColor } from './common.js' +import { spacers } from '../theme.js' + +export const LeftFooter = ({ children, dataTest }) => ( +
+ {children} + + +
+) + +LeftFooter.propTypes = { + children: propTypes.node, + dataTest: propTypes.string, +} diff --git a/src/Transfer/LeftHeader.js b/src/Transfer/LeftHeader.js new file mode 100644 index 000000000..a9b5ce234 --- /dev/null +++ b/src/Transfer/LeftHeader.js @@ -0,0 +1,24 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { borderColor } from './common.js' +import { spacers } from '../theme.js' + +export const LeftHeader = ({ children, dataTest }) => ( +
+ {children} + + +
+) + +LeftHeader.propTypes = { + children: propTypes.node, + dataTest: propTypes.string, +} diff --git a/src/Transfer/LeftSide.js b/src/Transfer/LeftSide.js new file mode 100644 index 000000000..181444ffd --- /dev/null +++ b/src/Transfer/LeftSide.js @@ -0,0 +1,34 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { borderColor, borderRadius } from './common.js' + +export const LeftSide = ({ children, dataTest, width }) => ( +
+ {children} + + { + /** + * Flex basis 0px to make sure right and left side + * always have the same width + */ '' + } + +
+) + +LeftSide.propTypes = { + width: propTypes.string.isRequired, + children: propTypes.node, + dataTest: propTypes.string, +} diff --git a/src/Transfer/PickedOptions.js b/src/Transfer/PickedOptions.js new file mode 100644 index 000000000..c8a6b14c6 --- /dev/null +++ b/src/Transfer/PickedOptions.js @@ -0,0 +1,51 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { findOption, getModeByModifierKey } from './common' +import { multiSelectedPropType } from '../common-prop-types' +import { spacers } from '../theme.js' + +export const PickedOptions = ({ + children, + dataTest, + toggleHighlightedPickedOption, + selectedEmptyComponent, + highlightedPickedOptions, + deselectSingleOption, +}) => ( +
+ {!React.Children.count(children) && selectedEmptyComponent} + {React.Children.map(children, child => { + const option = { + label: child.props.label, + value: child.props.value, + } + + return React.cloneElement(child, { + onClick: (_, event) => { + const mode = getModeByModifierKey(event) + toggleHighlightedPickedOption({ option, mode }) + }, + onDoubleClick: deselectSingleOption, + highlighted: !!findOption(highlightedPickedOptions, option), + }) + })} + + +
+) + +PickedOptions.propTypes = { + children: propTypes.node.isRequired, + dataTest: propTypes.string.isRequired, + deselectSingleOption: propTypes.func.isRequired, + toggleHighlightedPickedOption: propTypes.func.isRequired, + highlightedPickedOptions: multiSelectedPropType, + selectedEmptyComponent: propTypes.node, +} diff --git a/src/Transfer/RemoveAll.js b/src/Transfer/RemoveAll.js new file mode 100644 index 000000000..0960a503e --- /dev/null +++ b/src/Transfer/RemoveAll.js @@ -0,0 +1,28 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { Button } from '../Button' +import { IconRemoveAll } from './icons' + +export const RemoveAll = ({ label, dataTest, disabled, onClick }) => ( + +) + +RemoveAll.propTypes = { + dataTest: propTypes.string.isRequired, + onClick: propTypes.func.isRequired, + disabled: propTypes.bool, + label: propTypes.string, +} diff --git a/src/Transfer/RemoveIndividual.js b/src/Transfer/RemoveIndividual.js new file mode 100644 index 000000000..5058d94b3 --- /dev/null +++ b/src/Transfer/RemoveIndividual.js @@ -0,0 +1,28 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { Button } from '../Button' +import { IconRemoveIndividual } from './icons' + +export const RemoveIndividual = ({ label, dataTest, disabled, onClick }) => ( + +) + +RemoveIndividual.propTypes = { + dataTest: propTypes.string.isRequired, + onClick: propTypes.func.isRequired, + disabled: propTypes.bool, + label: propTypes.string, +} diff --git a/src/Transfer/ReorderingActions.js b/src/Transfer/ReorderingActions.js new file mode 100644 index 000000000..89302863a --- /dev/null +++ b/src/Transfer/ReorderingActions.js @@ -0,0 +1,66 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { Button } from '../Button.js' +import { IconMoveDown, IconMoveUp } from './icons' +import { spacers } from '../theme.js' + +export const ReorderingActions = ({ + dataTest, + disabledDown, + disabledUp, + onChangeUp, + onChangeDown, +}) => ( +
+
+) + +ReorderingActions.propTypes = { + dataTest: propTypes.string.isRequired, + onChangeDown: propTypes.func.isRequired, + onChangeUp: propTypes.func.isRequired, + disabledDown: propTypes.bool, + disabledUp: propTypes.bool, +} diff --git a/src/Transfer/RightFooter.js b/src/Transfer/RightFooter.js new file mode 100644 index 000000000..7a57c73b2 --- /dev/null +++ b/src/Transfer/RightFooter.js @@ -0,0 +1,24 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { borderColor } from './common.js' +import { spacers } from '../theme.js' + +export const RightFooter = ({ children, dataTest }) => ( +
+ {children} + + +
+) + +RightFooter.propTypes = { + children: propTypes.node, + dataTest: propTypes.string, +} diff --git a/src/Transfer/RightSide.js b/src/Transfer/RightSide.js new file mode 100644 index 000000000..df3ab4e5e --- /dev/null +++ b/src/Transfer/RightSide.js @@ -0,0 +1,33 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { borderColor, borderRadius } from './common.js' + +export const RightSide = ({ children, dataTest, width }) => ( +
+ {children} + + { + /** + * Flex basis 0px to make sure right and left side + * always have the same width + */ '' + } + +
+) + +RightSide.propTypes = { + width: propTypes.string.isRequired, + children: propTypes.node, + dataTest: propTypes.string, +} diff --git a/src/Transfer/SourceOptions.js b/src/Transfer/SourceOptions.js new file mode 100644 index 000000000..eb3f0fb9d --- /dev/null +++ b/src/Transfer/SourceOptions.js @@ -0,0 +1,52 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { findOption, getModeByModifierKey } from './common' +import { multiSelectedPropType } from '../common-prop-types.js' +import { spacers } from '../theme.js' + +export const SourceOptions = ({ + children, + dataTest, + toggleHighlightedSourceOption, + sourceEmptyPlaceholder, + highlightedSourceOptions, + selectSingleOption, +}) => ( +
+ {React.Children.map(children, child => { + const option = { + label: child.props.label, + value: child.props.value, + } + + return React.cloneElement(child, { + onClick: (_, event) => { + const mode = getModeByModifierKey(event) + toggleHighlightedSourceOption({ option, mode }) + }, + onDoubleClick: selectSingleOption, + highlighted: !!findOption(highlightedSourceOptions, option), + }) + })} + + {!React.Children.count(children) && sourceEmptyPlaceholder} + + +
+) + +SourceOptions.propTypes = { + dataTest: propTypes.string.isRequired, + selectSingleOption: propTypes.func.isRequired, + toggleHighlightedSourceOption: propTypes.func.isRequired, + children: propTypes.node, + highlightedSourceOptions: multiSelectedPropType, + sourceEmptyPlaceholder: propTypes.node, +} diff --git a/src/Transfer/common.js b/src/Transfer/common.js new file mode 100644 index 000000000..1598d8996 --- /dev/null +++ b/src/Transfer/common.js @@ -0,0 +1,113 @@ +import './types.js' +import { colors } from '../theme.js' + +export const borderColor = colors.grey400 +export const borderRadius = '3px' + +/** + * Click modes when clicking on an option with/without + * a modifier key (ctrl, alt, cmd, shift) + */ + +// no or multiple modifier keys +export const REPLACE_MODE = 'REPLACE_MODE' +// add/remove options from selection +export const ADD_MODE = 'ADD_MODE' +// create selection range +export const RANGE_MODE = 'RANGE_MODE' + +/** + * @param {Option} left + * @param {Option} left + * @returns {bool} + */ +export const isOption = (left, right) => + left.label === right.label && left.value === right.value + +/** + * @param {Option[]} options + * @param {Option} option + * @returns {Int} + */ +export const findOptionIndex = (options, option) => + options.findIndex(current => isOption(current, option)) + +/** + * @param {Option[]} options + * @param {Option} option + * @returns {Option} + */ +export const findOption = (options, option) => + options.find(current => isOption(current, option)) + +/** + * @param {Option[]} options + * @param {Option} option + * @returns {Option} + */ +export const addOption = (options, option) => { + const found = findOption(options, option) + if (found) return options + return [...options, option] +} + +/** + * @param {Option[]} options + * @param {Option} option + * @returns {Option} + */ +export const removeOption = (options, option) => { + const index = findOptionIndex(options, option) + + if (index === -1) return options + if (index === 0) return options.slice(1) + + return [...options.slice(0, index), ...options.slice(index + 1)] +} + +/** + * @param {Option[]} options + * @param {Option} option + * @returns {Option} + */ +export const toggleOption = (options, option) => + findOption(options, option) + ? removeOption(options, option) + : addOption(options, option) + +/** + * @param {Option[]} collection + * @param {Option[]} options + * @param {Function} modifier + * @returns {Option} + */ +export const toggleOptions = ( + collection, + optionsToToggle, + modifier = toggleOption +) => { + return optionsToToggle.reduce( + (curSelected, option) => modifier(curSelected, option), + collection + ) +} + +export const getModeByModifierKey = ({ + altKey, + shiftKey, + ctrlKey, + metaKey, +}) => { + const keys = [altKey, shiftKey, ctrlKey, metaKey] + const amountKeyPressed = keys.filter(v => v) + const moreThanOneKeyPressed = amountKeyPressed.length + + if (moreThanOneKeyPressed !== 1) return REPLACE_MODE + + if (altKey || ctrlKey || metaKey) return ADD_MODE + + if (shiftKey) return RANGE_MODE + + // default to replace mode + return REPLACE_MODE +} diff --git a/src/Transfer/helper/addAllSelectableSourceOptions.js b/src/Transfer/helper/addAllSelectableSourceOptions.js new file mode 100644 index 000000000..a5a23d495 --- /dev/null +++ b/src/Transfer/helper/addAllSelectableSourceOptions.js @@ -0,0 +1,36 @@ +import { addOption, toggleOptions } from '../common' +import { getPlainOptionsFromReactOptions } from './getPlainOptionsFromReactOptions' + +/** + * @param {Object} args + * @param {ReactElement} args.sourceReactOptions + * @param {Option[]} args.selectedPlainOptions + * @param {Function} args.onChange + * @param {Function} arg.setHighlightedSourceOptions + * @returns {void} + */ +export const addAllSelectableSourceOptions = ({ + sourceReactOptions, + onChange, + selectedPlainOptions, + setHighlightedSourceOptions, +}) => { + const all = getPlainOptionsFromReactOptions(sourceReactOptions) + const allEnabled = all.filter(({ disabled }) => !disabled) + const newSelected = toggleOptions( + selectedPlainOptions, + allEnabled, + addOption + ) + + setHighlightedSourceOptions([]) + + // If we ever allow maxSelection to be any value + // other than 1 and Infinity, we need to think + // about how this should behave: + // + // Either we hide this button when it's not "Infinity", + // or we have to decide whether we want to take the first + // nth options or the last + onChange({ selected: newSelected }) +} diff --git a/src/Transfer/helper/addIndividualSourceOptions.js b/src/Transfer/helper/addIndividualSourceOptions.js new file mode 100644 index 000000000..404a38c9f --- /dev/null +++ b/src/Transfer/helper/addIndividualSourceOptions.js @@ -0,0 +1,59 @@ +import { Children } from 'react' +import { addOption, isOption, toggleOptions } from '../common' + +/** + * @param {Object} args + * @param {bool} args.filterable + * @param {ReactElement} args.filteredSourcePlainOptions + * @param {Option[]} args.highlightedSourcePlainOptions + * @param {Function} args.onChange + * @param {Option[]} args.selectedPlainOptions + * @param {Function} args.setHighlightedSourceOptions + * @returns void + */ +export const addIndividualSourceOptions = ({ + filterable, + filteredSourcePlainOptions, + highlightedSourcePlainOptions, + maxSelections, + onChange, + selectedPlainOptions, + setHighlightedSourceOptions, +}) => { + /** + * Creates a subset of the highlighted options to reflect a changed + * filter value in case previously highlighted options are now + * hidden. + * + * This enables us to keep items highlighted while searching for + * a particular one. + * + * With this subset we only select the subset when the user + * clicks the "add individuals" button + */ + const filteredHighlightedSourceOptions = filterable + ? highlightedSourcePlainOptions.filter(option => + Children.toArray(filteredSourcePlainOptions) + .map(({ props }) => props) + .find(filteredOption => isOption(filteredOption, option)) + ) + : highlightedSourcePlainOptions + + const newSelected = toggleOptions( + selectedPlainOptions, + filteredHighlightedSourceOptions, + addOption + ) + + setHighlightedSourceOptions([]) + + /** + * This will extract from the end, hence the "-1 *" + * As the "newest" additions are always at the end of the array, + * it's safe to just take the last nth (depending on maxSelection) + * to always get the right ones + */ + onChange({ + selected: newSelected.slice(-1 * maxSelections), + }) +} diff --git a/src/Transfer/helper/createDoubleClickHandlers.js b/src/Transfer/helper/createDoubleClickHandlers.js new file mode 100644 index 000000000..37637e6e3 --- /dev/null +++ b/src/Transfer/helper/createDoubleClickHandlers.js @@ -0,0 +1,32 @@ +import { addOption, removeOption } from '../common' + +/** + * @param {Object} args + * @param {number} args.maxSelections + * @param {Function} args.onChange + * @param {Option[]} args.selectedPlainOptions + * @param {Function} args.setHighlightedSourceOptions + * @param {Function} args.setHighlightedPickedOptions + * @returns void + */ +export const createDoubleClickHandlers = ({ + maxSelections, + onChange, + selectedPlainOptions, + setHighlightedPickedOptions, + setHighlightedSourceOptions, +}) => { + const selectSingleOption = ({ option }) => { + const newSelected = addOption(selectedPlainOptions, option) + setHighlightedSourceOptions([]) + onChange({ selected: newSelected.slice(-1 * maxSelections) }) + } + + const deselectSingleOption = ({ option }) => { + const newSelected = removeOption(selectedPlainOptions, option) + setHighlightedPickedOptions([]) + onChange({ selected: newSelected }) + } + + return { selectSingleOption, deselectSingleOption } +} diff --git a/src/Transfer/helper/defaultFilterCallback.js b/src/Transfer/helper/defaultFilterCallback.js new file mode 100644 index 000000000..2cb282cca --- /dev/null +++ b/src/Transfer/helper/defaultFilterCallback.js @@ -0,0 +1,11 @@ +/** + * @param {Option[]} plainOptions + * @param {string} filter + * @returns {Option[]} + */ +export const defaultFilterCallback = (plainOptions, filter) => + filter === '' + ? plainOptions + : plainOptions.filter(({ label }) => + label.match(new RegExp(filter, 'i')) + ) diff --git a/src/Transfer/helper/extractPickedReactOptions.js b/src/Transfer/helper/extractPickedReactOptions.js new file mode 100644 index 000000000..d811303ef --- /dev/null +++ b/src/Transfer/helper/extractPickedReactOptions.js @@ -0,0 +1,33 @@ +import { Children } from 'react' +import { findOption, findOptionIndex } from '../common' + +/** + * @param {Object} args + * @param {ReactElement} args.reactOptions + * @param {Option[]} args.selectedPlainOptions + * @returns {ReactElement} React elements + */ +export const extractPickedReactOptions = ({ + reactOptions, + selectedPlainOptions, +}) => { + const pickedOptions = Children.toArray(reactOptions) + .map(child => { + const { props } = child + const isSelected = !!findOption(selectedPlainOptions, props) + + return isSelected ? child : null + }) + // We can ONLY do this because the reactOptions have keys + .filter(child => !!child) + + pickedOptions.sort((left, right) => { + const leftIndex = findOptionIndex(selectedPlainOptions, left.props) + const rightIndex = findOptionIndex(selectedPlainOptions, right.props) + + if (leftIndex < rightIndex) return -1 + return 1 + }) + + return pickedOptions +} diff --git a/src/Transfer/helper/filterOutOptions.js b/src/Transfer/helper/filterOutOptions.js new file mode 100644 index 000000000..4701aab1a --- /dev/null +++ b/src/Transfer/helper/filterOutOptions.js @@ -0,0 +1,13 @@ +import { Children } from 'react' +import { findOption } from '../common' + +/** + * @param {ReactElement} reactOptions + * @param {Option[]} plainOptions + * @returns {Object} React elements + */ +export const filterOutOptions = (reactOptions, plainOptions) => { + return Children.map(reactOptions, child => + findOption(plainOptions, child.props) ? null : child + ) +} diff --git a/src/Transfer/helper/filterReactOptionsBy.js b/src/Transfer/helper/filterReactOptionsBy.js new file mode 100644 index 000000000..b17009cde --- /dev/null +++ b/src/Transfer/helper/filterReactOptionsBy.js @@ -0,0 +1,12 @@ +import { Children } from 'react' +import { getPlainOptionFromReactOption } from './getPlainOptionFromReactOption' + +export const filterReactOptionsBy = (callback, reactOptions) => { + return Children.map(reactOptions, child => { + const plainOption = getPlainOptionFromReactOption(child) + const keep = callback(plainOption) + + if (!keep) return null + return child + }) +} diff --git a/src/Transfer/helper/getPlainOptionFromReactOption.js b/src/Transfer/helper/getPlainOptionFromReactOption.js new file mode 100644 index 000000000..f78a48e8f --- /dev/null +++ b/src/Transfer/helper/getPlainOptionFromReactOption.js @@ -0,0 +1,12 @@ +import '../types.js' + +/** + * @param {ReactElement} reactOption + * @returns {Option} plainOption + */ +export const getPlainOptionFromReactOption = reactOption => ({ + label: reactOption.props.label, + value: reactOption.props.value, + disabled: reactOption.props.disabled, + ...(reactOption.props.additionalData || {}), +}) diff --git a/src/Transfer/helper/getPlainOptionsFromReactOptions.js b/src/Transfer/helper/getPlainOptionsFromReactOptions.js new file mode 100644 index 000000000..f2a1e91c9 --- /dev/null +++ b/src/Transfer/helper/getPlainOptionsFromReactOptions.js @@ -0,0 +1,10 @@ +import '../types.js' +import { Children } from 'react' +import { getPlainOptionFromReactOption } from './getPlainOptionFromReactOption' + +/** + * @param {ReactElement} reactOptions + * @returns {Option[]} plainOption + */ +export const getPlainOptionsFromReactOptions = reactOptions => + Children.toArray(reactOptions).map(getPlainOptionFromReactOption) diff --git a/src/Transfer/helper/getSubsetByFilter.js b/src/Transfer/helper/getSubsetByFilter.js new file mode 100644 index 000000000..523140916 --- /dev/null +++ b/src/Transfer/helper/getSubsetByFilter.js @@ -0,0 +1,29 @@ +import { Children } from 'react' +import { getPlainOptionsFromReactOptions } from './getPlainOptionsFromReactOptions' +import { isOption } from '../common' + +/** + * @param {Object} args + * @param {ReactElement} args.reactOptions + * @param {string} args.filter + * @param {bool} args.filterable + * @param {Function} args.filterCallback + * @returns {Object} React elements + */ +export const getSubsetByFilter = ({ + reactOptions, + filter, + filterable, + filterCallback, +}) => { + const options = getPlainOptionsFromReactOptions(reactOptions) + + const filtered = filterable ? filterCallback(options, filter) : options + + return Children.map(reactOptions, child => { + if (!filterable) return child + if (!filtered.find(option => isOption(option, child.props))) return null + + return child + }) +} diff --git a/src/Transfer/helper/index.js b/src/Transfer/helper/index.js new file mode 100644 index 000000000..1f8f66c18 --- /dev/null +++ b/src/Transfer/helper/index.js @@ -0,0 +1,15 @@ +export * from './addAllSelectableSourceOptions.js' +export * from './addIndividualSourceOptions.js' +export * from './createDoubleClickHandlers.js' +export * from './extractPickedReactOptions.js' +export * from './defaultFilterCallback.js' +export * from './filterOutOptions.js' +export * from './getPlainOptionsFromReactOptions.js' +export * from './getSubsetByFilter.js' +export * from './isReorderDownDisabled.js' +export * from './isReorderUpDisabled.js' +export * from './moveHighlightedPickedOptionDown.js' +export * from './moveHighlightedPickedOptionUp.js' +export * from './removeAllPickedOptions.js' +export * from './removeIndividualPickedOptions.js' +export * from './useHighlightedOptions.js' diff --git a/src/Transfer/helper/isReorderDownDisabled.js b/src/Transfer/helper/isReorderDownDisabled.js new file mode 100644 index 000000000..2a7e5ebbd --- /dev/null +++ b/src/Transfer/helper/isReorderDownDisabled.js @@ -0,0 +1,17 @@ +import { findOptionIndex } from '../common' + +/** + * @param {Object} args + * @param {PlainElement} args.highlightedPickedPlainOptions + * @param {Option[]} args.selectedPlainOptions + * @returns {bool} + */ +export const isReorderDownDisabled = ({ + highlightedPickedPlainOptions, + selectedPlainOptions, +}) => + // only one item can be moved with the buttons + highlightedPickedPlainOptions.length !== 1 || + // can't move an item down if it's the last one + findOptionIndex(selectedPlainOptions, highlightedPickedPlainOptions[0]) === + selectedPlainOptions.length - 1 diff --git a/src/Transfer/helper/isReorderUpDisabled.js b/src/Transfer/helper/isReorderUpDisabled.js new file mode 100644 index 000000000..c83425136 --- /dev/null +++ b/src/Transfer/helper/isReorderUpDisabled.js @@ -0,0 +1,17 @@ +import { findOptionIndex } from '../common' + +/** + * @param {Object} args + * @param {PlainElement} args.highlightedPickedPlainOptions + * @param {Option[]} args.selectedPlainOptions + * @returns {bool} + */ +export const isReorderUpDisabled = ({ + highlightedPickedPlainOptions, + selectedPlainOptions, +}) => + // only one item can be moved with the buttons + highlightedPickedPlainOptions.length !== 1 || + // can't move an item up if it's the first one + findOptionIndex(selectedPlainOptions, highlightedPickedPlainOptions[0]) === + 0 diff --git a/src/Transfer/helper/moveHighlightedPickedOptionDown.js b/src/Transfer/helper/moveHighlightedPickedOptionDown.js new file mode 100644 index 000000000..324cad5a9 --- /dev/null +++ b/src/Transfer/helper/moveHighlightedPickedOptionDown.js @@ -0,0 +1,33 @@ +import { findOptionIndex } from '../common' + +/** + * @param {Object} args + * @param {Option[]} args.selectedPlainOptions + * @param {Option[]} args.highlightedPickedPlainOptions + * @param {Function} args.onChange + * @returns {void} + */ +export const moveHighlightedPickedOptionDown = ({ + selectedPlainOptions, + highlightedPickedPlainOptions, + onChange, +}) => { + const optionIndex = findOptionIndex( + selectedPlainOptions, + highlightedPickedPlainOptions[0] + ) + + // Can't move down last or non-existing option + if (optionIndex === -1 || optionIndex > selectedPlainOptions.length - 2) + return + + // swap with next item + const reordered = [ + ...selectedPlainOptions.slice(0, optionIndex), + selectedPlainOptions[optionIndex + 1], + selectedPlainOptions[optionIndex], + ...selectedPlainOptions.slice(optionIndex + 2), + ] + + onChange({ selected: reordered }) +} diff --git a/src/Transfer/helper/moveHighlightedPickedOptionUp.js b/src/Transfer/helper/moveHighlightedPickedOptionUp.js new file mode 100644 index 000000000..983db5cd7 --- /dev/null +++ b/src/Transfer/helper/moveHighlightedPickedOptionUp.js @@ -0,0 +1,32 @@ +import { findOptionIndex } from '../common' + +/** + * @param {Object} args + * @param {Option[]} args.selectedPlainOptions + * @param {Option[]} args.highlightedPickedPlainOptions + * @param {Function} args.onChange + * @returns {void} + */ +export const moveHighlightedPickedOptionUp = ({ + selectedPlainOptions, + highlightedPickedPlainOptions, + onChange, +}) => { + const optionIndex = findOptionIndex( + selectedPlainOptions, + highlightedPickedPlainOptions[0] + ) + + // Can't move up option at index 0 or non-existing option + if (optionIndex < 1) return + + // swap with previous item + const reordered = [ + ...selectedPlainOptions.slice(0, optionIndex - 1), + selectedPlainOptions[optionIndex], + selectedPlainOptions[optionIndex - 1], + ...selectedPlainOptions.slice(optionIndex + 1), + ] + + onChange({ selected: reordered }) +} diff --git a/src/Transfer/helper/removeAllPickedOptions.js b/src/Transfer/helper/removeAllPickedOptions.js new file mode 100644 index 000000000..004d638c1 --- /dev/null +++ b/src/Transfer/helper/removeAllPickedOptions.js @@ -0,0 +1,13 @@ +/** + * @param {Object} args + * @param {Function} args.setHighlightedPickedOptions + * @param {Function} args.onChange + * @returns {void} + */ +export const removeAllPickedOptions = ({ + setHighlightedPickedOptions, + onChange, +}) => { + setHighlightedPickedOptions([]) + onChange({ selected: [] }) +} diff --git a/src/Transfer/helper/removeIndividualPickedOptions.js b/src/Transfer/helper/removeIndividualPickedOptions.js new file mode 100644 index 000000000..90b6a022c --- /dev/null +++ b/src/Transfer/helper/removeIndividualPickedOptions.js @@ -0,0 +1,31 @@ +import { removeOption, toggleOptions } from '../common' + +/** + * @param {Object} args + * @param {ReactElement} args.highlightedPickedReactOptions + * @param {Function} args.setHighlightedPickedOptions + * @param {Option[]} args.selectedPlainOptions + * @param {Function} args.onChange + * @returns {void} + */ +export const removeIndividualPickedOptions = ({ + highlightedPickedReactOptions, + onChange, + selectedPlainOptions, + setHighlightedPickedOptions, +}) => { + const newSelected = toggleOptions( + selectedPlainOptions, + highlightedPickedReactOptions, + removeOption + ) + + setHighlightedPickedOptions([]) + + /** + * as the maximum amount of selected items + * is already restricted through the selection mechanism, + * there's no need to handle `maxSelection` here + */ + onChange({ selected: newSelected }) +} diff --git a/src/Transfer/helper/useHighlightedOptions.js b/src/Transfer/helper/useHighlightedOptions.js new file mode 100644 index 000000000..d4a3d18aa --- /dev/null +++ b/src/Transfer/helper/useHighlightedOptions.js @@ -0,0 +1,41 @@ +import '../types.js' + +import { useState } from 'react' + +import { createToggleHighlightedOption } from './useHighlightedOptions/createToggleHighlightedOption' + +/** + * @param {Object} args + * @param {bool} args.disabled + * @param {number} args.maxSelection + * @param {ReactElement} args.reactOptions + * @returns {Object} highlighted options & helpers + */ +export const useHighlightedOptions = ({ + disabled, + maxSelections, + reactOptions, +}) => { + /** + * These are important so the stored element can be used + * as range-start when using shift multiple times consecutively + */ + const [lastClicked, setLastClicked] = useState(null) + const [highlightedOptions, setHighlightedOptions] = useState([]) + + const toggleHighlightedOption = createToggleHighlightedOption({ + disabled, + highlightedOptions, + setHighlightedOptions, + maxSelections, + setLastClicked, + reactOptions, + lastClicked, + }) + + return { + highlightedOptions, + setHighlightedOptions, + toggleHighlightedOption, + } +} diff --git a/src/Transfer/helper/useHighlightedOptions/createToggleHighlightedOption.js b/src/Transfer/helper/useHighlightedOptions/createToggleHighlightedOption.js new file mode 100644 index 000000000..153eeae2b --- /dev/null +++ b/src/Transfer/helper/useHighlightedOptions/createToggleHighlightedOption.js @@ -0,0 +1,49 @@ +import { ADD_MODE, RANGE_MODE } from '../../common' +import { toggleAdd } from './toggleAdd' +import { toggleRange } from './toggleRange' +import { toggleReplace } from './toggleReplace' + +export const createToggleHighlightedOption = ({ + disabled, + highlightedOptions, + setHighlightedOptions, + maxSelections, + setLastClicked, + reactOptions, + lastClicked, +}) => ({ option, mode }) => { + if (disabled) return + + setHighlightedOptions([]) + + if (mode === ADD_MODE) { + setLastClicked(option) + + return toggleAdd({ + highlightedOptions, + maxSelections, + option, + setHighlightedOptions, + }) + } + + if (mode === RANGE_MODE) { + return toggleRange({ + highlightedOptions, + reactOptions, + option, + setHighlightedOptions, + lastClicked, + maxSelections, + }) + } + + // REPLACE_MODE + setLastClicked(option) + + return toggleReplace({ + option, + highlightedOptions, + setHighlightedOptions, + }) +} diff --git a/src/Transfer/helper/useHighlightedOptions/toggleAdd.js b/src/Transfer/helper/useHighlightedOptions/toggleAdd.js new file mode 100644 index 000000000..f5d228802 --- /dev/null +++ b/src/Transfer/helper/useHighlightedOptions/toggleAdd.js @@ -0,0 +1,21 @@ +import '../../types.js' +import { toggleOption } from '../../common' + +/** + * @param {Object} args + * @param {Option[]} args.highlightedOptions + * @param {number} args.maxSelections + * @param {Option} args.option + * @param {Function} args.setHighlightedOption + * @returns {void} + */ +export const toggleAdd = ({ + highlightedOptions, + maxSelections, + option, + setHighlightedOptions, +}) => { + setHighlightedOptions( + toggleOption(highlightedOptions, option).slice(-1 * maxSelections) + ) +} diff --git a/src/Transfer/helper/useHighlightedOptions/toggleRange.js b/src/Transfer/helper/useHighlightedOptions/toggleRange.js new file mode 100644 index 000000000..0db1b82fb --- /dev/null +++ b/src/Transfer/helper/useHighlightedOptions/toggleRange.js @@ -0,0 +1,61 @@ +import '../../types.js' +import { findOption, findOptionIndex } from '../../common' +import { getPlainOptionsFromReactOptions } from '../getPlainOptionsFromReactOptions' + +/** + * @param {Object} args + * @param {Option[]} args.highlightedOptions + * @param {ReactElement} args.reactOptions + * @param {Option} args.option + * @param {Function} args.setHighlightedOption + * @param {number} args.maxSelections + * @param {Option} args.lastClicked + * @returns {void} + */ +export const toggleRange = ({ + highlightedOptions, + reactOptions, + option, + setHighlightedOptions, + lastClicked, + maxSelections, +}) => { + if (highlightedOptions.length === 0) { + setHighlightedOptions([option]) + } else { + let from, to + + const options = getPlainOptionsFromReactOptions(reactOptions) + const clickedOptionIndex = findOptionIndex(options, option) + const lastClickedSourceOptionWithoutRangeModeIndex = lastClicked + ? findOptionIndex(options, lastClicked) + : -1 + + if (lastClickedSourceOptionWithoutRangeModeIndex !== -1) { + from = lastClickedSourceOptionWithoutRangeModeIndex + to = clickedOptionIndex + } else { + /** + * A filter-change has removed the most recently highlighted option + */ + const firstHighlightedInList = options.findIndex(option => + findOption(highlightedOptions, option) + ) + + from = firstHighlightedInList + to = clickedOptionIndex + } + + // this is so we can also selected + // a range of options above "from" option. + // -> Just how slice works ;) + const lower = Math.min(from, to) + const higher = Math.max(from, to) + const newHighlightedSourceOptions = options + .slice(lower, higher + 1) + .filter(option => !option.disabled) + .slice(maxSelections * -1) + + setHighlightedOptions(newHighlightedSourceOptions) + } +} diff --git a/src/Transfer/helper/useHighlightedOptions/toggleReplace.js b/src/Transfer/helper/useHighlightedOptions/toggleReplace.js new file mode 100644 index 000000000..2b002e0f8 --- /dev/null +++ b/src/Transfer/helper/useHighlightedOptions/toggleReplace.js @@ -0,0 +1,23 @@ +import '../../types.js' +import { toggleOption } from '../../common' + +/** + * @param {Object} args + * @param {Option[]} args.highlightedOptions + * @param {Option} args.option + * @param {Function} args.setHighlightedOption + * @returns {void} + */ +export const toggleReplace = ({ + option, + highlightedOptions, + setHighlightedOptions, +}) => { + if (highlightedOptions.length > 1) { + setHighlightedOptions([option]) + } else { + setHighlightedOptions( + toggleOption(highlightedOptions, option).slice(-1) + ) + } +} diff --git a/src/Transfer/icons.js b/src/Transfer/icons.js new file mode 100644 index 000000000..5fd99755d --- /dev/null +++ b/src/Transfer/icons.js @@ -0,0 +1,157 @@ +import React from 'react' +import css from 'styled-jsx/css' +import propTypes from '@dhis2/prop-types' +import { theme } from '../theme.js' + +const centerButtonStyles = css` + svg { + min-width: 20px; + } +` + +export const IconAddAll = ({ dataTest, disabled }) => ( + + + + + + + + + +) + +IconAddAll.propTypes = { + dataTest: propTypes.string.isRequired, + disabled: propTypes.bool, +} + +export const IconAddIndividual = ({ dataTest, disabled }) => ( + + + + + +) + +IconAddIndividual.propTypes = { + dataTest: propTypes.string.isRequired, + disabled: propTypes.bool, +} + +export const IconRemoveAll = ({ dataTest, disabled }) => ( + + + + + + + + + +) + +IconRemoveAll.propTypes = { + dataTest: propTypes.string.isRequired, + disabled: propTypes.bool, +} + +export const IconRemoveIndividual = ({ dataTest, disabled }) => ( + + + + + +) + +IconRemoveIndividual.propTypes = { + dataTest: propTypes.string.isRequired, + disabled: propTypes.bool, +} + +export const IconMoveDown = ({ dataTest, disabled }) => ( + + + +) + +IconMoveDown.propTypes = { + dataTest: propTypes.string.isRequired, + disabled: propTypes.bool, +} + +export const IconMoveUp = ({ dataTest, disabled }) => ( + + + +) + +IconMoveUp.propTypes = { + dataTest: propTypes.string.isRequired, + disabled: propTypes.bool, +} diff --git a/src/Transfer/types.js b/src/Transfer/types.js new file mode 100644 index 000000000..55aa9c7e6 --- /dev/null +++ b/src/Transfer/types.js @@ -0,0 +1,12 @@ +/** + * These are instances of react components, + * both built-in and custom ones. + * + * @typedef {Object} ReactElement + */ + +/** + * @typedef {Object} Option + * @prop {string} label + * @prop {string} value + */ diff --git a/src/TransferOption.js b/src/TransferOption.js new file mode 100644 index 000000000..3d8a7817b --- /dev/null +++ b/src/TransferOption.js @@ -0,0 +1,114 @@ +import React, { useRef } from 'react' +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' + +import { colors } from './theme.js' + +const DOUBLE_CLICK_MAX_DELAY = 500 + +/** + * @module + * @param {TransferOption.PropTypes} props + * @returns {React.Component} + * + * @example import { TransferOption } from '@dhis2/ui-core' + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/organisms/transfer.md|Design system} + * @see Live demo: {@link /demo/?path=/story/transfer--basic|Storybook} + */ +export const TransferOption = ({ + className, + disabled, + dataTest, + label, + highlighted, + onClick, + onDoubleClick, + value, + additionalData, +}) => { + const doubleClickTimeout = useRef(null) + + return ( +
{ + if (disabled) return + + const option = { label, value, ...additionalData } + + if (doubleClickTimeout.current) { + clearTimeout(doubleClickTimeout.current) + doubleClickTimeout.current = null + + onDoubleClick({ option }, event) + } else { + doubleClickTimeout.current = setTimeout(() => { + clearTimeout(doubleClickTimeout.current) + doubleClickTimeout.current = null + }, DOUBLE_CLICK_MAX_DELAY) + + onClick({ option }, event) + } + }} + data-value={value} + className={cx(className, { highlighted, disabled })} + > + {label} + + +
+ ) +} + +TransferOption.defaultProps = { + dataTest: 'dhis2-uicore-transferoption', + addDecorator: {}, +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} label + * @prop {string} value + * @prop {Object} [additionalData] + * @prop {string} [className] + * @prop {string} [dataTest] + * @prop {bool} [disabled] + * @prop {bool} [highlighted] + * @prop {Function} [onClick] + * @prop {Function} [onDoubleClick] + */ +TransferOption.propTypes = { + label: propTypes.string.isRequired, + value: propTypes.string.isRequired, + additionalData: propTypes.object, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + highlighted: propTypes.bool, + onClick: propTypes.func, + onDoubleClick: propTypes.func, +} diff --git a/src/index.js b/src/index.js index 3a59c0cc2..53ae35891 100644 --- a/src/index.js +++ b/src/index.js @@ -67,6 +67,8 @@ export { SingleSelectOption } from './SingleSelectOption.js' export { MultiSelect } from './MultiSelect.js' export { MultiSelectField } from './MultiSelectField.js' export { MultiSelectOption } from './MultiSelectOption.js' +export { Transfer } from './Transfer.js' +export { TransferOption } from './TransferOption.js' /* table */ export { TableBody } from './TableBody.js'