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 }) => (
+
+ }
+ >
+ {label}
+
+)
+
+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 }) => (
+
+ }
+ >
+ {label}
+
+)
+
+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 }) => (
+
+ }
+ >
+ {label}
+
+)
+
+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 }) => (
+
+ }
+ >
+ {label}
+
+)
+
+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'