diff --git a/components/transfer/package.json b/components/transfer/package.json
index 41db5731d9..35d3677f4b 100644
--- a/components/transfer/package.json
+++ b/components/transfer/package.json
@@ -41,6 +41,7 @@
"@dhis2-ui/loader": "10.17.0",
"@dhis2-ui/tooltip": "10.17.0",
"@dhis2/ui-constants": "10.17.0",
+ "@dnd-kit/core": "^6.3.1",
"classnames": "^2.3.1",
"prop-types": "^15.7.2"
},
diff --git a/components/transfer/src/__tests__/helper/add-source-options-on-drop.test.js b/components/transfer/src/__tests__/helper/add-source-options-on-drop.test.js
new file mode 100644
index 0000000000..cde60935ef
--- /dev/null
+++ b/components/transfer/src/__tests__/helper/add-source-options-on-drop.test.js
@@ -0,0 +1,139 @@
+import { addSourceOptionsOnDrop } from '../../transfer/add-source-options-on-drop.js'
+
+describe('Transfer - addSourceOptionsOnDrop', () => {
+ const onChange = jest.fn()
+ const setHighlightedSourceOptions = jest.fn()
+
+ afterEach(() => {
+ onChange.mockClear()
+ setHighlightedSourceOptions.mockClear()
+ })
+
+ it('should append when no drop index is provided', () => {
+ addSourceOptionsOnDrop({
+ selected: ['a', 'b'],
+ draggedValues: ['x'],
+ maxSelections: Infinity,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['a', 'b', 'x'],
+ })
+ })
+
+ it('should insert at the provided drop index', () => {
+ addSourceOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['x'],
+ dropIndex: 1,
+ maxSelections: Infinity,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['a', 'x', 'b', 'c'],
+ })
+ })
+
+ it('should insert multiple values as a block preserving their order', () => {
+ addSourceOptionsOnDrop({
+ selected: ['a', 'b'],
+ draggedValues: ['x', 'y'],
+ dropIndex: 0,
+ maxSelections: Infinity,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['x', 'y', 'a', 'b'],
+ })
+ })
+
+ it('should clear the source highlights', () => {
+ addSourceOptionsOnDrop({
+ selected: [],
+ draggedValues: ['x'],
+ maxSelections: Infinity,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(setHighlightedSourceOptions).toHaveBeenCalledWith([])
+ })
+
+ it('should evict the oldest values when exceeding maxSelections on append', () => {
+ addSourceOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['x'],
+ maxSelections: 3,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['b', 'c', 'x'],
+ })
+ })
+
+ it('should never evict the just-dropped values on a positional insert', () => {
+ addSourceOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['x'],
+ dropIndex: 0,
+ maxSelections: 3,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['x', 'b', 'c'],
+ })
+ })
+
+ it('should replace the current selection when maxSelections is 1', () => {
+ addSourceOptionsOnDrop({
+ selected: ['a'],
+ draggedValues: ['x'],
+ dropIndex: 0,
+ maxSelections: 1,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['x'],
+ })
+ })
+
+ it('should clamp a drop index beyond the list length', () => {
+ addSourceOptionsOnDrop({
+ selected: ['a'],
+ draggedValues: ['x'],
+ dropIndex: 100,
+ maxSelections: Infinity,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['a', 'x'],
+ })
+ })
+
+ it('should do nothing when the dragged set is empty', () => {
+ addSourceOptionsOnDrop({
+ selected: ['a'],
+ draggedValues: [],
+ maxSelections: Infinity,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+
+ expect(onChange).toHaveBeenCalledTimes(0)
+ expect(setHighlightedSourceOptions).toHaveBeenCalledTimes(0)
+ })
+})
diff --git a/components/transfer/src/__tests__/helper/get-dragged-values.test.js b/components/transfer/src/__tests__/helper/get-dragged-values.test.js
new file mode 100644
index 0000000000..56308235db
--- /dev/null
+++ b/components/transfer/src/__tests__/helper/get-dragged-values.test.js
@@ -0,0 +1,60 @@
+import { getDraggedValues } from '../../transfer/get-dragged-values.js'
+
+describe('Transfer - getDraggedValues', () => {
+ const visibleOptions = [
+ { label: 'A', value: 'a' },
+ { label: 'B', value: 'b' },
+ { label: 'C', value: 'c' },
+ { label: 'D', value: 'd' },
+ ]
+
+ it('should return only the dragged value when it is not highlighted', () => {
+ expect(
+ getDraggedValues({
+ draggedValue: 'b',
+ highlightedOptions: ['c', 'd'],
+ visibleOptions,
+ })
+ ).toEqual(['b'])
+ })
+
+ it('should return only the dragged value when nothing is highlighted', () => {
+ expect(
+ getDraggedValues({
+ draggedValue: 'b',
+ highlightedOptions: [],
+ visibleOptions,
+ })
+ ).toEqual(['b'])
+ })
+
+ it('should return the whole highlighted set when the dragged value is highlighted', () => {
+ expect(
+ getDraggedValues({
+ draggedValue: 'c',
+ highlightedOptions: ['a', 'c'],
+ visibleOptions,
+ })
+ ).toEqual(['a', 'c'])
+ })
+
+ it('should order the dragged values by list order, not highlight order', () => {
+ expect(
+ getDraggedValues({
+ draggedValue: 'a',
+ highlightedOptions: ['d', 'a', 'b'],
+ visibleOptions,
+ })
+ ).toEqual(['a', 'b', 'd'])
+ })
+
+ it('should exclude highlighted values that are not visible (hidden by a filter)', () => {
+ expect(
+ getDraggedValues({
+ draggedValue: 'a',
+ highlightedOptions: ['a', 'hidden-by-filter'],
+ visibleOptions,
+ })
+ ).toEqual(['a'])
+ })
+})
diff --git a/components/transfer/src/__tests__/helper/reorder-picked-options-on-drop.test.js b/components/transfer/src/__tests__/helper/reorder-picked-options-on-drop.test.js
new file mode 100644
index 0000000000..4f8324938e
--- /dev/null
+++ b/components/transfer/src/__tests__/helper/reorder-picked-options-on-drop.test.js
@@ -0,0 +1,161 @@
+import { reorderPickedOptionsOnDrop } from '../../transfer/reorder-picked-options-on-drop.js'
+
+describe('Transfer - reorderPickedOptionsOnDrop', () => {
+ const onChange = jest.fn()
+
+ afterEach(() => {
+ onChange.mockClear()
+ })
+
+ it('should move a single option down', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c', 'd'],
+ draggedValues: ['a'],
+ dropIndex: 3,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['b', 'c', 'a', 'd'],
+ })
+ })
+
+ it('should move a single option up', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c', 'd'],
+ draggedValues: ['c'],
+ dropIndex: 1,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['a', 'c', 'b', 'd'],
+ })
+ })
+
+ it('should move an option to the very top', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['c'],
+ dropIndex: 0,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['c', 'a', 'b'],
+ })
+ })
+
+ it('should move an option to the very end', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['a'],
+ dropIndex: 3,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['b', 'c', 'a'],
+ })
+ })
+
+ it('should move a contiguous block as a group', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c', 'd', 'e'],
+ draggedValues: ['b', 'c'],
+ dropIndex: 5,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['a', 'd', 'e', 'b', 'c'],
+ })
+ })
+
+ it('should collapse a non-contiguous selection into a contiguous block at the drop position', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c', 'd', 'e'],
+ draggedValues: ['a', 'd'],
+ dropIndex: 3,
+ onChange,
+ })
+
+ // 'a' and 'd' are removed, insertion index 3 adjusted by the
+ // one dragged item ('a') above it -> lands after 'c'
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['b', 'c', 'a', 'd', 'e'],
+ })
+ })
+
+ it('should preserve list order of dragged values regardless of input order', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c', 'd', 'e'],
+ draggedValues: ['d', 'b'],
+ dropIndex: 0,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['b', 'd', 'a', 'c', 'e'],
+ })
+ })
+
+ it('should not call onChange when the drop results in no change', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['b'],
+ dropIndex: 1,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledTimes(0)
+ })
+
+ it('should not call onChange when dropping right below the dragged option', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['b'],
+ dropIndex: 2,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledTimes(0)
+ })
+
+ it('should not call onChange when no dragged value exists in selected', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['ghost'],
+ dropIndex: 0,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledTimes(0)
+ })
+
+ it('should ignore dragged values that do not exist in selected', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['ghost', 'c'],
+ dropIndex: 0,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['c', 'a', 'b'],
+ })
+ })
+
+ it('should clamp a drop index beyond the list length', () => {
+ reorderPickedOptionsOnDrop({
+ selected: ['a', 'b', 'c'],
+ draggedValues: ['a'],
+ dropIndex: 100,
+ onChange,
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ selected: ['b', 'c', 'a'],
+ })
+ })
+})
diff --git a/components/transfer/src/__tests__/helper/resolve-drop-target.test.js b/components/transfer/src/__tests__/helper/resolve-drop-target.test.js
new file mode 100644
index 0000000000..116e90cb77
--- /dev/null
+++ b/components/transfer/src/__tests__/helper/resolve-drop-target.test.js
@@ -0,0 +1,203 @@
+import { resolveDropTarget } from '../../transfer/resolve-drop-target.js'
+
+describe('Transfer - resolveDropTarget', () => {
+ const defaults = {
+ draggedValues: ['dragged'],
+ enableOrderChange: true,
+ filterActivePicked: false,
+ }
+ // A 20px tall row starting at y=100, midline at y=110
+ const overRect = { top: 100, height: 20 }
+
+ it('should return null when not over anything', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'source',
+ overSide: undefined,
+ })
+ ).toBeNull()
+ })
+
+ it('should return null when dragging a source option over the source side', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'source',
+ overSide: 'source',
+ overType: 'option',
+ overValue: 'a',
+ })
+ ).toBeNull()
+ })
+
+ it('should resolve to removal when dragging a picked option over a source option', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'picked',
+ overSide: 'source',
+ overType: 'option',
+ overValue: 'a',
+ })
+ ).toEqual({ side: 'source', pos: 'container' })
+ })
+
+ it('should resolve to removal when dragging a picked option over the source container', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'picked',
+ overSide: 'source',
+ overType: 'container',
+ })
+ ).toEqual({ side: 'source', pos: 'container' })
+ })
+
+ it('should insert before the hovered option when the pointer is above its midline', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'source',
+ overSide: 'picked',
+ overType: 'option',
+ overValue: 'b',
+ overIndex: 1,
+ overRect,
+ dragY: 105,
+ })
+ ).toEqual({ side: 'picked', pos: 'before', value: 'b', index: 1 })
+ })
+
+ it('should insert after the hovered option when the pointer is below its midline', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'source',
+ overSide: 'picked',
+ overType: 'option',
+ overValue: 'b',
+ overIndex: 1,
+ overRect,
+ dragY: 115,
+ })
+ ).toEqual({ side: 'picked', pos: 'after', value: 'b', index: 2 })
+ })
+
+ it('should append when dropping on the picked container', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'source',
+ overSide: 'picked',
+ overType: 'container',
+ })
+ ).toEqual({ side: 'picked', pos: 'end' })
+ })
+
+ it('should append when order change is disabled', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ enableOrderChange: false,
+ activeSide: 'source',
+ overSide: 'picked',
+ overType: 'option',
+ overValue: 'b',
+ overIndex: 1,
+ overRect,
+ dragY: 105,
+ })
+ ).toEqual({ side: 'picked', pos: 'end' })
+ })
+
+ it('should append when the picked filter is active', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ filterActivePicked: true,
+ activeSide: 'source',
+ overSide: 'picked',
+ overType: 'option',
+ overValue: 'b',
+ overIndex: 1,
+ overRect,
+ dragY: 105,
+ })
+ ).toEqual({ side: 'picked', pos: 'end' })
+ })
+
+ it('should resolve a reorder target within the picked list', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'picked',
+ overSide: 'picked',
+ overType: 'option',
+ overValue: 'b',
+ overIndex: 1,
+ overRect,
+ dragY: 115,
+ })
+ ).toEqual({ side: 'picked', pos: 'after', value: 'b', index: 2 })
+ })
+
+ it('should return null when reordering while order change is disabled', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ enableOrderChange: false,
+ activeSide: 'picked',
+ overSide: 'picked',
+ overType: 'option',
+ overValue: 'b',
+ overIndex: 1,
+ overRect,
+ dragY: 105,
+ })
+ ).toBeNull()
+ })
+
+ it('should return null when reordering while the picked filter is active', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ filterActivePicked: true,
+ activeSide: 'picked',
+ overSide: 'picked',
+ overType: 'option',
+ overValue: 'b',
+ overIndex: 1,
+ overRect,
+ dragY: 105,
+ })
+ ).toBeNull()
+ })
+
+ it('should return null when hovering an option that is part of the dragged set', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ draggedValues: ['a', 'b'],
+ activeSide: 'picked',
+ overSide: 'picked',
+ overType: 'option',
+ overValue: 'b',
+ overIndex: 1,
+ overRect,
+ dragY: 105,
+ })
+ ).toBeNull()
+ })
+
+ it('should allow appending a picked option via the picked container while reordering', () => {
+ expect(
+ resolveDropTarget({
+ ...defaults,
+ activeSide: 'picked',
+ overSide: 'picked',
+ overType: 'container',
+ })
+ ).toEqual({ side: 'picked', pos: 'end' })
+ })
+})
diff --git a/components/transfer/src/__tests__/helper/resolve-positional-index.test.js b/components/transfer/src/__tests__/helper/resolve-positional-index.test.js
new file mode 100644
index 0000000000..11fdd19879
--- /dev/null
+++ b/components/transfer/src/__tests__/helper/resolve-positional-index.test.js
@@ -0,0 +1,33 @@
+import { resolvePositionalIndex } from '../../transfer/resolve-positional-index.js'
+
+describe('Transfer - resolvePositionalIndex', () => {
+ const selected = ['a', 'b', 'c']
+
+ it('should return null when dropping at the end', () => {
+ expect(resolvePositionalIndex({ pos: 'end' }, selected)).toBeNull()
+ })
+
+ it('should return the value index when dropping before', () => {
+ expect(
+ resolvePositionalIndex({ pos: 'before', value: 'b' }, selected)
+ ).toBe(1)
+ })
+
+ it('should return the value index + 1 when dropping after', () => {
+ expect(
+ resolvePositionalIndex({ pos: 'after', value: 'b' }, selected)
+ ).toBe(2)
+ })
+
+ it('should return 0 when dropping before the first value', () => {
+ expect(
+ resolvePositionalIndex({ pos: 'before', value: 'a' }, selected)
+ ).toBe(0)
+ })
+
+ it('should return null when the value is not in selected', () => {
+ expect(
+ resolvePositionalIndex({ pos: 'after', value: 'ghost' }, selected)
+ ).toBeNull()
+ })
+})
diff --git a/components/transfer/src/draggable-option.js b/components/transfer/src/draggable-option.js
new file mode 100644
index 0000000000..3b2a241706
--- /dev/null
+++ b/components/transfer/src/draggable-option.js
@@ -0,0 +1,105 @@
+import { colors, spacers } from '@dhis2/ui-constants'
+import { useDraggable, useDroppable } from '@dnd-kit/core'
+import cx from 'classnames'
+import PropTypes from 'prop-types'
+import React, { useCallback } from 'react'
+
+/**
+ * Wraps a rendered option to make it draggable and a drop target,
+ * without touching the option's own markup.
+ *
+ * dnd-kit's accessibility `attributes` are deliberately not spread:
+ * they would put `role="button"` and `tabIndex={0}` on every row
+ * without any keyboard drag behavior behind them — the action buttons
+ * remain the keyboard path.
+ */
+export const DraggableOption = ({
+ children,
+ side,
+ value,
+ index,
+ disabled,
+ dragging,
+ dropPos,
+}) => {
+ const data = { side, type: 'option', value, index }
+ const { setNodeRef: setDraggableRef, listeners } = useDraggable({
+ id: value,
+ data,
+ disabled,
+ })
+ const { setNodeRef: setDroppableRef } = useDroppable({ id: value, data })
+ const setNodeRef = useCallback(
+ (node) => {
+ setDraggableRef(node)
+ setDroppableRef(node)
+ },
+ [setDraggableRef, setDroppableRef]
+ )
+
+ return (
+
+ {children}
+
+
+
+ )
+}
+
+DraggableOption.propTypes = {
+ children: PropTypes.node.isRequired,
+ index: PropTypes.number.isRequired,
+ side: PropTypes.oneOf(['source', 'picked']).isRequired,
+ value: PropTypes.string.isRequired,
+ disabled: PropTypes.bool,
+ dragging: PropTypes.bool,
+ dropPos: PropTypes.oneOf(['before', 'after']),
+}
diff --git a/components/transfer/src/features/disabled-transfer-options/index.js b/components/transfer/src/features/disabled-transfer-options/index.js
index fea88e8fbc..a9dfb5a452 100644
--- a/components/transfer/src/features/disabled-transfer-options/index.js
+++ b/components/transfer/src/features/disabled-transfer-options/index.js
@@ -95,8 +95,12 @@ When('the user clicks an enabled option', () => {
When(
'SHIFT+clicks another enabled option, including a disabled item in the range of options',
() => {
+ // options are wrapped in draggable wrappers, so the next
+ // option is the child of the parent wrapper's next sibling
cy.get(disabledSourceOptionSelector)
+ .parent()
.next()
+ .children()
.clickWith('shift')
.as('clickedWithShiftEnabledOption')
}
@@ -171,8 +175,9 @@ Then('the enabled options in the range are highlighted', () => {
() => cy.get('@clickedWithShiftEnabledOption')
).should(
([$all, $clickedEnabledOption, $clickedWithShiftEnabledOption]) => {
- const from = $clickedEnabledOption.index()
- const to = $clickedWithShiftEnabledOption.index()
+ // the option's position is its draggable wrapper's position
+ const from = $clickedEnabledOption.parent().index()
+ const to = $clickedWithShiftEnabledOption.parent().index()
const $allInRange = $all.slice(from, to + 1)
const $allInRangeEnabled = $allInRange.filter(':not(.disabled)')
diff --git a/components/transfer/src/features/highlight-range-of-options/index.js b/components/transfer/src/features/highlight-range-of-options/index.js
index 7b59d7d122..d5ccda0d30 100644
--- a/components/transfer/src/features/highlight-range-of-options/index.js
+++ b/components/transfer/src/features/highlight-range-of-options/index.js
@@ -68,11 +68,17 @@ Given('one item is highlighted', () => {
})
Given('there are at least two options following the highlighted option', () => {
+ // options are wrapped in draggable wrappers, so the next option
+ // is the child of the parent wrapper's next sibling
cy.get('@initiallyHighlighted')
+ .parent()
.next()
+ .children()
.should('exist')
.as('firstBelowInitiallyHighlighted')
+ .parent()
.next()
+ .children()
.should('exist')
.as('secondBelowInitiallyHighlighted')
})
@@ -88,12 +94,18 @@ Given('several options are highlighted', () => {
Given(
'there are at least two options following the last highlighted option',
() => {
+ // options are wrapped in draggable wrappers, so the next
+ // option is the child of the parent wrapper's next sibling
cy.get('@initiallyHighlightedMultiple')
.last('last')
+ .parent()
.next()
+ .children()
.should('exist')
.as('firstBelowInitiallyHighlighted')
+ .parent()
.next()
+ .children()
.should('exist')
.as('secondBelowInitiallyHighlighted')
}
@@ -125,12 +137,16 @@ When(
// last highlighted option
.last()
- // next sibling
+ // next option (the child of the wrapper's next sibling)
+ .parent()
.next()
+ .children()
.as('firstBelowLastHighlighted')
- // second next sibling
+ // second next option
+ .parent()
.next()
+ .children()
.as('secondBelowLastHighlighted')
.clickWith('shift')
}
@@ -242,7 +258,12 @@ Then(
() => {
cy.all(
() =>
- cy.get('@initiallyHighlightedMultiple').last().invoke('index'),
+ // the option's position is its draggable wrapper's position
+ cy
+ .get('@initiallyHighlightedMultiple')
+ .last()
+ .invoke('parent')
+ .invoke('index'),
() => cy.get('@initiallyHighlightedMultiple')
).should(
([
@@ -252,7 +273,10 @@ Then(
$initiallyHighlightedMultiple
.filter((_, el) => {
const $el = Cypress.$(el)
- return $el.index() !== lastInitiallyHighlightedIndex
+ return (
+ $el.parent().index() !==
+ lastInitiallyHighlightedIndex
+ )
})
.each((_, el) => {
const $el = Cypress.$(el)
@@ -272,12 +296,14 @@ Then(
() => cy.get('@list').find('{transferoption}')
).should(
([$initiallyHighlightedMultiple, $firstShiftClicked, $all]) => {
+ // the option's position is its draggable wrapper's position
const firstVisibleHighlightedIndex =
$initiallyHighlightedMultiple
.filter(':visible')
.eq(0)
+ .parent()
.index()
- const shiftIndex = $firstShiftClicked.index()
+ const shiftIndex = $firstShiftClicked.parent().index()
const from = Math.min(firstVisibleHighlightedIndex, shiftIndex)
const to = Math.max(firstVisibleHighlightedIndex, shiftIndex)
const $insideRange = $all.slice(from, to + 1)
diff --git a/components/transfer/src/features/reorder-with-buttons/index.js b/components/transfer/src/features/reorder-with-buttons/index.js
index a3b7cbac93..2501fcd61c 100644
--- a/components/transfer/src/features/reorder-with-buttons/index.js
+++ b/components/transfer/src/features/reorder-with-buttons/index.js
@@ -68,6 +68,8 @@ Then('the highlighted item should be moved to the {int}. place', (next) => {
cy.get('@previousValue')
.then((previousValue) => cy.get(`[data-value="${previousValue}"]`))
+ // the option's position is its draggable wrapper's position
+ .invoke('parent')
.invoke('index')
.should('equal', index)
})
@@ -134,6 +136,8 @@ Then('those items should be at positions {int} and {int}', (first, second) => {
const targetIndices = [first - 1, second - 1]
values.forEach((value, i) => {
cy.get(`[data-value="${value}"]`)
+ // the option's position is its draggable wrapper's position
+ .invoke('parent')
.invoke('index')
.should('equal', targetIndices[i])
})
diff --git a/components/transfer/src/features/set_unset-highlighted-option/index.js b/components/transfer/src/features/set_unset-highlighted-option/index.js
index 1e71a4f0e9..0f3d2f1fde 100644
--- a/components/transfer/src/features/set_unset-highlighted-option/index.js
+++ b/components/transfer/src/features/set_unset-highlighted-option/index.js
@@ -38,10 +38,14 @@ Given('the highlighted item is not visible due to a set filter', () => {
})
When('the user clicks an item in the list that is not highlighted', () => {
+ // options are wrapped in draggable wrappers, so the next option
+ // is the child of the parent wrapper's next sibling
cy.get('@list')
.find('{transferoption}')
.first()
- .invoke('next')
+ .parent()
+ .next()
+ .children()
.as('nextHighlighted')
.click()
})
diff --git a/components/transfer/src/options-container.js b/components/transfer/src/options-container.js
index e60d02473c..45d7277427 100644
--- a/components/transfer/src/options-container.js
+++ b/components/transfer/src/options-container.js
@@ -1,6 +1,10 @@
+import { colors } from '@dhis2/ui-constants'
import { CircularLoader } from '@dhis2-ui/loader'
+import { useDroppable } from '@dnd-kit/core'
+import cx from 'classnames'
import PropTypes from 'prop-types'
-import React, { Fragment, useRef } from 'react'
+import React, { useCallback, useRef } from 'react'
+import { DraggableOption } from './draggable-option.js'
import { EndIntersectionDetector } from './end-intersection-detector.js'
import { useOptionsKeyMonitor } from './transfer/use-options-key-monitor.js'
@@ -17,7 +21,11 @@ export const OptionsContainer = ({
selected = false,
selectionHandler,
toggleHighlightedOption,
+ activeDragValues,
+ draggingDisabled,
+ dropTarget,
}) => {
+ const side = selected ? 'picked' : 'source'
const scrollBoxRef = useRef(null)
const listRef = useRef(null)
useOptionsKeyMonitor({
@@ -26,25 +34,88 @@ export const OptionsContainer = ({
allOptionsKey,
onEndReached,
})
+
+ const { setNodeRef: setContainerDroppableRef } = useDroppable({
+ id: `${side}:container`,
+ data: { side, type: 'container' },
+ })
+ const setContainerRef = useCallback(
+ (node) => {
+ scrollBoxRef.current = node
+ setContainerDroppableRef(node)
+ },
+ [setContainerDroppableRef]
+ )
+
+ const containerDropHighlight =
+ side === 'source'
+ ? dropTarget?.side === 'source'
+ : dropTarget?.side === 'picked' && dropTarget.pos === 'end'
+
+ /* Matched by index rather than by `dropTarget.value`: at the exact
+ * pixel boundary between two adjacent options, dnd-kit's collision
+ * detection can flip `over` between them, resolving to "after the
+ * previous option" or "before the next option" interchangeably.
+ * Both resolve to the same insertion index, so keying off the index
+ * keeps the rendered indicator stable instead of jumping between
+ * the two options' edges. */
+ const positionalDropIndex =
+ side === 'picked' &&
+ dropTarget?.side === 'picked' &&
+ (dropTarget.pos === 'before' || dropTarget.pos === 'after')
+ ? dropTarget.index
+ : null
+
return (
-
+
{loading && (
)}
-
+
{!options.length && emptyComponent}
- {options.map((option) => {
+ {options.map((option, index) => {
const highlighted = !!highlightedOptions.find(
(highlightedSourceOption) =>
highlightedSourceOption === option.value
)
+ let dropPos = null
+ if (positionalDropIndex === index) {
+ dropPos = 'before'
+ } else if (
+ positionalDropIndex === options.length &&
+ index === options.length - 1
+ ) {
+ dropPos = 'after'
+ }
+
return (
-
+
{renderOption({
...option,
...getOptionClickHandlers(
@@ -55,7 +126,7 @@ export const OptionsContainer = ({
highlighted,
selected,
})}
-
+
)
})}
@@ -81,6 +152,20 @@ export const OptionsContainer = ({
height: 100%;
}
+ .container.dropTarget {
+ background-color: ${colors.teal050};
+ }
+
+ .optionsContainer.dropTarget::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: 2px;
+ box-shadow: inset 0 0 0 1px ${colors.teal500};
+ pointer-events: none;
+ z-index: 3;
+ }
+
.loading {
display: flex;
height: 100%;
@@ -110,6 +195,15 @@ OptionsContainer.propTypes = {
allOptionsKey: PropTypes.string.isRequired,
dataTest: PropTypes.string.isRequired,
getOptionClickHandlers: PropTypes.func.isRequired,
+ activeDragValues: PropTypes.arrayOf(PropTypes.string),
+ draggingDisabled: PropTypes.bool,
+ dropTarget: PropTypes.shape({
+ pos: PropTypes.oneOf(['before', 'after', 'end', 'container'])
+ .isRequired,
+ side: PropTypes.oneOf(['source', 'picked']).isRequired,
+ index: PropTypes.number,
+ value: PropTypes.string,
+ }),
emptyComponent: PropTypes.node,
highlightedOptions: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool,
diff --git a/components/transfer/src/transfer-drag-overlay.js b/components/transfer/src/transfer-drag-overlay.js
new file mode 100644
index 0000000000..8687662e00
--- /dev/null
+++ b/components/transfer/src/transfer-drag-overlay.js
@@ -0,0 +1,115 @@
+import { colors, elevations, spacers } from '@dhis2/ui-constants'
+import { DragOverlay } from '@dnd-kit/core'
+import PropTypes from 'prop-types'
+import React, { useMemo } from 'react'
+
+const flyBackDropAnimation = {
+ duration: 250,
+ easing: 'ease',
+ sideEffects: null,
+ keyframes: ({ transform }) => [
+ {
+ opacity: 1,
+ transform: `translate3d(${transform.initial.x}px, ${transform.initial.y}px, 0)`,
+ },
+ {
+ opacity: 0,
+ transform: `translate3d(${transform.final.x}px, ${transform.final.y}px, 0)`,
+ },
+ ],
+}
+
+const fadeDropAnimation = {
+ duration: 150,
+ easing: 'ease',
+ sideEffects: null,
+ keyframes: ({ transform }) => [
+ {
+ opacity: 1,
+ transform: `translate3d(${transform.initial.x}px, ${transform.initial.y}px, 0)`,
+ },
+ {
+ opacity: 0,
+ transform: `translate3d(${transform.initial.x}px, ${transform.initial.y}px, 0)`,
+ },
+ ],
+}
+
+export const TransferDragOverlay = ({ activeDrag, flyBackOnDrop = true }) => {
+ const prefersReducedMotion = useMemo(
+ () =>
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ??
+ false,
+ []
+ )
+
+ let dropAnimation = null
+ if (!prefersReducedMotion) {
+ dropAnimation = flyBackOnDrop ? flyBackDropAnimation : fadeDropAnimation
+ }
+
+ return (
+
+ {activeDrag ? (
+
+ {activeDrag.label}
+
+ {activeDrag.count > 1 && (
+ {activeDrag.count}
+ )}
+
+
+
+ ) : null}
+
+ )
+}
+
+TransferDragOverlay.propTypes = {
+ activeDrag: PropTypes.shape({
+ count: PropTypes.number.isRequired,
+ label: PropTypes.node,
+ }),
+ flyBackOnDrop: PropTypes.bool,
+}
diff --git a/components/transfer/src/transfer-option.js b/components/transfer/src/transfer-option.js
index 3b91b15155..58a16c5a94 100644
--- a/components/transfer/src/transfer-option.js
+++ b/components/transfer/src/transfer-option.js
@@ -1,4 +1,4 @@
-import { colors, spacers } from '@dhis2/ui-constants'
+import { colors } from '@dhis2/ui-constants'
import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { useRef } from 'react'
@@ -66,14 +66,6 @@ export const TransferOption = ({
color: ${colors.grey600};
cursor: not-allowed;
}
-
- div:first-child {
- margin-block-start: ${spacers.dp4};
- }
-
- div:last-child {
- margin-block-end: ${spacers.dp4};
- }
`}
)
diff --git a/components/transfer/src/transfer.js b/components/transfer/src/transfer.js
index e5f995fc6b..01c72a38be 100644
--- a/components/transfer/src/transfer.js
+++ b/components/transfer/src/transfer.js
@@ -1,3 +1,4 @@
+import { DndContext } from '@dnd-kit/core'
import PropTypes from 'prop-types'
import React, { useEffect, useMemo, useRef } from 'react'
import { Actions } from './actions.js'
@@ -31,7 +32,9 @@ import {
removeIndividualPickedOptions,
useFilter,
useHighlightedOptions,
+ useTransferDnd,
} from './transfer/index.js'
+import { TransferDragOverlay } from './transfer-drag-overlay.js'
import { TransferOption } from './transfer-option.js'
const identity = (value) => value
@@ -222,6 +225,37 @@ export const Transfer = ({
element?.scrollIntoView({ block: 'nearest' })
}, [selected, dataTest])
+ /*
+ * Drag and drop:
+ * Options can always be dragged between the lists. Dragging to
+ * reorder the picked list additionally requires `enableOrderChange`,
+ * mirroring the reorder buttons.
+ */
+ const {
+ sensors,
+ collisionDetection,
+ activeDrag,
+ flyBackOnDrop,
+ dropTarget,
+ onDragStart,
+ onDragMove,
+ onDragEnd,
+ onDragCancel,
+ } = useTransferDnd({
+ selected,
+ sourceOptions,
+ pickedOptions,
+ highlightedSourceOptions,
+ highlightedPickedOptions,
+ setHighlightedSourceOptions,
+ setHighlightedPickedOptions,
+ enableOrderChange,
+ filterActivePicked,
+ maxSelections,
+ onChange,
+ reorderScrollTargetRef,
+ })
+
/**
* Disabled button states
*/
@@ -243,232 +277,262 @@ export const Transfer = ({
)
return (
-
-
- {(leftHeader || filterable) && (
-
- {leftHeader}
+
+
+
+ {(leftHeader || filterable) && (
+
+ {leftHeader}
- {filterable && !hideFilterInput && (
-
- setInternalFilter(value)
- }
- />
- )}
-
- )}
+ {filterable && !hideFilterInput && (
+
+ setInternalFilter(value)
+ }
+ />
+ )}
+
+ )}
-
+
+
+ {leftFooter && (
+
+ {leftFooter}
+
+ )}
+
- {leftFooter && (
-
- {leftFooter}
-
- )}
-
+
+ {maxSelections === Infinity && (
+
+ addAllSelectableSourceOptions({
+ sourceOptions,
+ selected,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+ }
+ />
+ )}
-
- {maxSelections === Infinity && (
-
- addAllSelectableSourceOptions({
+ addIndividualSourceOptions({
+ filterable,
sourceOptions,
+ highlightedSourceOptions,
selected,
+ maxSelections,
onChange,
setHighlightedSourceOptions,
})
}
/>
- )}
-
- addIndividualSourceOptions({
- filterable,
- sourceOptions,
- highlightedSourceOptions,
- selected,
- maxSelections,
- onChange,
- setHighlightedSourceOptions,
- })
- }
- />
+ {maxSelections === Infinity && (
+
+ removeAllPickedOptions({
+ setHighlightedPickedOptions,
+ onChange,
+ })
+ }
+ />
+ )}
- {maxSelections === Infinity && (
-
- removeAllPickedOptions({
- setHighlightedPickedOptions,
+ removeIndividualPickedOptions({
+ filterablePicked,
+ pickedOptions,
+ highlightedPickedOptions,
onChange,
+ selected,
+ setHighlightedPickedOptions,
})
}
/>
- )}
-
-
- removeIndividualPickedOptions({
- filterablePicked,
- pickedOptions,
- highlightedPickedOptions,
- onChange,
- selected,
- setHighlightedPickedOptions,
- })
- }
- />
-
+
-
- {(rightHeader || filterablePicked) && (
-
- {rightHeader}
+
+ {(rightHeader || filterablePicked) && (
+
+ {rightHeader}
- {filterablePicked && !hideFilterInputPicked && (
-
- setInternalFilterPicked(value)
- }
- />
- )}
-
- )}
+ {filterablePicked && !hideFilterInputPicked && (
+
+ setInternalFilterPicked(value)
+ }
+ />
+ )}
+
+ )}
-
+
- {(rightFooter || enableOrderChange) && (
-
- {enableOrderChange && (
- {
- const highlightedSet = new Set(
- highlightedPickedOptions
- )
- reorderScrollTargetRef.current =
- selected.find((value) =>
- highlightedSet.has(value)
- ) ?? null
- moveHighlightedPickedOptionUp({
- selected,
- highlightedPickedOptions,
- onChange,
- })
- }}
- onChangeDown={() => {
- const highlightedSet = new Set(
- highlightedPickedOptions
- )
- reorderScrollTargetRef.current =
- selected.findLast((value) =>
- highlightedSet.has(value)
- ) ?? null
- moveHighlightedPickedOptionDown({
- selected,
+ {(rightFooter || enableOrderChange) && (
+
+ {enableOrderChange && (
+ {
- const highlightedSet = new Set(
- highlightedPickedOptions
- )
- reorderScrollTargetRef.current =
- selected.find((value) =>
- highlightedSet.has(value)
- ) ?? null
- moveHighlightedPickedOptionToTop({
selected,
+ filterActivePicked,
+ })}
+ disabledUp={isReorderUpDisabled({
highlightedPickedOptions,
- onChange,
- })
- }}
- onChangeToBottom={() => {
- const highlightedSet = new Set(
- highlightedPickedOptions
- )
- reorderScrollTargetRef.current =
- selected.findLast((value) =>
- highlightedSet.has(value)
- ) ?? null
- moveHighlightedPickedOptionToBottom({
selected,
- highlightedPickedOptions,
- onChange,
- })
- }}
- />
- )}
+ filterActivePicked,
+ })}
+ onChangeUp={() => {
+ const highlightedSet = new Set(
+ highlightedPickedOptions
+ )
+ reorderScrollTargetRef.current =
+ selected.find((value) =>
+ highlightedSet.has(value)
+ ) ?? null
+ moveHighlightedPickedOptionUp({
+ selected,
+ highlightedPickedOptions,
+ onChange,
+ })
+ }}
+ onChangeDown={() => {
+ const highlightedSet = new Set(
+ highlightedPickedOptions
+ )
+ reorderScrollTargetRef.current =
+ selected.findLast((value) =>
+ highlightedSet.has(value)
+ ) ?? null
+ moveHighlightedPickedOptionDown({
+ selected,
+ highlightedPickedOptions,
+ onChange,
+ })
+ }}
+ onChangeToTop={() => {
+ const highlightedSet = new Set(
+ highlightedPickedOptions
+ )
+ reorderScrollTargetRef.current =
+ selected.find((value) =>
+ highlightedSet.has(value)
+ ) ?? null
+ moveHighlightedPickedOptionToTop({
+ selected,
+ highlightedPickedOptions,
+ onChange,
+ })
+ }}
+ onChangeToBottom={() => {
+ const highlightedSet = new Set(
+ highlightedPickedOptions
+ )
+ reorderScrollTargetRef.current =
+ selected.findLast((value) =>
+ highlightedSet.has(value)
+ ) ?? null
+ moveHighlightedPickedOptionToBottom({
+ selected,
+ highlightedPickedOptions,
+ onChange,
+ })
+ }}
+ />
+ )}
+
+ {rightFooter}
+
+ )}
+
+
- {rightFooter}
-
- )}
-
-
+
+
)
}
diff --git a/components/transfer/src/transfer.prod.stories.js b/components/transfer/src/transfer.prod.stories.js
index 72357e7086..81c86cee95 100644
--- a/components/transfer/src/transfer.prod.stories.js
+++ b/components/transfer/src/transfer.prod.stories.js
@@ -30,6 +30,10 @@ here's an explanation of their meaning:
- 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
+#### Drag and drop
+
+Options can be dragged between the two lists with a pointer. When \`enableOrderChange\` is set, the picked options can also be reordered by dragging, and options dropped onto the picked list are inserted at the drop position (otherwise they are appended at the end). Dragging a highlighted option drags the whole highlighted set. Drag and drop is a pointer-only convenience: the action buttons and reordering buttons remain the keyboard-accessible way to transfer and reorder.
+
#### More details
See more about the options available for Transfers at [Design System: Transfer](https://github.com/dhis2/design-system/blob/master/organisms/transfer.md#transfer).
@@ -396,6 +400,54 @@ ReorderingWithCustomOptions.args = {
},
}
+export const DragAndDrop = StatefulTemplate.bind({})
+DragAndDrop.storyName = 'Drag and drop'
+DragAndDrop.args = {
+ enableOrderChange: true,
+ options: options.slice(0, 20),
+ initiallySelected: options.slice(0, 8).map(({ value }) => value),
+}
+DragAndDrop.parameters = {
+ docs: {
+ description: {
+ story: `Options can always be dragged between the two lists. With \`enableOrderChange\` the picked list can additionally be reordered by dragging, and dropped options land at the drop position instead of at the end. Dragging a highlighted option moves the whole highlighted set. Drag and drop is a pointer-only convenience — the action and reordering buttons remain the keyboard-accessible path.`,
+ },
+ },
+}
+
+export const DragAndDropWithoutReordering = StatefulTemplate.bind({})
+DragAndDropWithoutReordering.storyName = 'Drag and drop without reordering'
+DragAndDropWithoutReordering.args = {
+ options: options.slice(0, 20),
+ initiallySelected: options.slice(0, 4).map(({ value }) => value),
+}
+DragAndDropWithoutReordering.parameters = {
+ docs: {
+ description: {
+ story: `Without \`enableOrderChange\`, dragging between the lists still works but options dropped on the picked list are always appended at the end, and the picked list cannot be reordered by dragging.`,
+ },
+ },
+}
+
+export const DragAndDropWithDisabledOptions = StatefulTemplate.bind({})
+DragAndDropWithDisabledOptions.storyName = 'Drag and drop with disabled options'
+DragAndDropWithDisabledOptions.args = {
+ enableOrderChange: true,
+ options: options
+ .slice(0, 10)
+ .map((option, index) =>
+ index % 3 === 0 ? { ...option, disabled: true } : option
+ ),
+ initiallySelected: options.slice(10, 14).map(({ value }) => value),
+}
+DragAndDropWithDisabledOptions.parameters = {
+ docs: {
+ description: {
+ story: 'Disabled options cannot be dragged.',
+ },
+ },
+}
+
export const IncreasedOptionsHeight = StatefulTemplate.bind({})
IncreasedOptionsHeight.args = {
maxSelections: Infinity,
diff --git a/components/transfer/src/transfer/add-source-options-on-drop.js b/components/transfer/src/transfer/add-source-options-on-drop.js
new file mode 100644
index 0000000000..27c55a2d4f
--- /dev/null
+++ b/components/transfer/src/transfer/add-source-options-on-drop.js
@@ -0,0 +1,56 @@
+/**
+ * Adds dragged source options to `selected`, at `dropIndex` or
+ * appended when omitted. When the result exceeds `maxSelections`, the
+ * oldest non-dragged values are evicted from the start, so the
+ * just-dropped options are never evicted (`maxSelections: 1` replaces
+ * the current selection on a positional drop).
+ *
+ * @param {Object} args
+ * @param {string[]} args.selected
+ * @param {string[]} args.draggedValues
+ * @param {number} [args.dropIndex] - insertion index within `selected`,
+ * appends when not provided
+ * @param {number} args.maxSelections
+ * @param {Function} args.onChange
+ * @param {Function} args.setHighlightedSourceOptions
+ * @returns {boolean} whether the selection was changed (`onChange` called)
+ */
+export const addSourceOptionsOnDrop = ({
+ selected,
+ draggedValues,
+ dropIndex,
+ maxSelections,
+ onChange,
+ setHighlightedSourceOptions,
+}) => {
+ if (draggedValues.length === 0) {
+ return false
+ }
+
+ const insertPos =
+ dropIndex === undefined
+ ? selected.length
+ : Math.max(0, Math.min(dropIndex, selected.length))
+
+ let newSelected = [
+ ...selected.slice(0, insertPos),
+ ...draggedValues,
+ ...selected.slice(insertPos),
+ ]
+
+ if (newSelected.length > maxSelections) {
+ const draggedSet = new Set(draggedValues)
+ let overflow = newSelected.length - maxSelections
+ newSelected = newSelected.filter((value) => {
+ if (overflow > 0 && !draggedSet.has(value)) {
+ overflow--
+ return false
+ }
+ return true
+ })
+ }
+
+ setHighlightedSourceOptions([])
+ onChange({ selected: newSelected })
+ return true
+}
diff --git a/components/transfer/src/transfer/get-dragged-values.js b/components/transfer/src/transfer/get-dragged-values.js
new file mode 100644
index 0000000000..1b865f008a
--- /dev/null
+++ b/components/transfer/src/transfer/get-dragged-values.js
@@ -0,0 +1,29 @@
+/**
+ * When the grabbed option is highlighted, the whole highlighted set is
+ * dragged as a block, in list order and restricted to currently
+ * visible options (same subset rule as `addIndividualSourceOptions` /
+ * `removeIndividualPickedOptions`). Otherwise only the grabbed option
+ * is dragged.
+ *
+ * @param {Object} args
+ * @param {string} args.draggedValue
+ * @param {string[]} args.highlightedOptions
+ * @param {Object[]} args.visibleOptions - the (filtered) options of the
+ * list the drag originates from, in list order
+ * @returns {string[]}
+ */
+export const getDraggedValues = ({
+ draggedValue,
+ highlightedOptions,
+ visibleOptions,
+}) => {
+ if (!highlightedOptions.includes(draggedValue)) {
+ return [draggedValue]
+ }
+
+ const highlightedSet = new Set(highlightedOptions)
+
+ return visibleOptions
+ .map(({ value }) => value)
+ .filter((value) => highlightedSet.has(value))
+}
diff --git a/components/transfer/src/transfer/index.js b/components/transfer/src/transfer/index.js
index bfaf64b8a3..571b2dc6ab 100644
--- a/components/transfer/src/transfer/index.js
+++ b/components/transfer/src/transfer/index.js
@@ -1,7 +1,9 @@
export * from './add-all-selectable-source-options.js'
export * from './add-individual-source-options.js'
+export * from './add-source-options-on-drop.js'
export * from './create-double-click-handlers.js'
export * from './default-filter-callback.js'
+export * from './get-dragged-values.js'
export * from './get-highlighted-picked-indices.js'
export * from './get-option-click-handlers.js'
export * from './is-reorder-down-disabled.js'
@@ -12,6 +14,10 @@ export * from './move-highlighted-picked-option-to-top.js'
export * from './move-highlighted-picked-option-up.js'
export * from './remove-all-picked-options.js'
export * from './remove-individual-picked-options.js'
+export * from './reorder-picked-options-on-drop.js'
+export * from './resolve-drop-target.js'
+export * from './resolve-positional-index.js'
export * from './use-filter.js'
export * from './use-highlighted-options.js'
export * from './use-options-key-monitor.js'
+export * from './use-transfer-dnd.js'
diff --git a/components/transfer/src/transfer/reorder-picked-options-on-drop.js b/components/transfer/src/transfer/reorder-picked-options-on-drop.js
new file mode 100644
index 0000000000..45c326a509
--- /dev/null
+++ b/components/transfer/src/transfer/reorder-picked-options-on-drop.js
@@ -0,0 +1,54 @@
+import { getHighlightedPickedIndices } from './get-highlighted-picked-indices.js'
+
+/**
+ * Reinserts the dragged picked options as a contiguous block at the
+ * drop position, collapsing a non-contiguous selection while
+ * preserving relative order (same semantics as the reorder buttons).
+ * `dropIndex` counts before the dragged items are removed, so it's
+ * adjusted for dragged items sitting above the drop position; `onChange`
+ * is skipped when the drop results in no change.
+ *
+ * @param {Object} args
+ * @param {string[]} args.selected
+ * @param {string[]} args.draggedValues
+ * @param {number} args.dropIndex
+ * @param {Function} args.onChange
+ * @returns {boolean} whether the order was changed (`onChange` called)
+ */
+export const reorderPickedOptionsOnDrop = ({
+ selected,
+ draggedValues,
+ dropIndex,
+ onChange,
+}) => {
+ const indices = getHighlightedPickedIndices({
+ selected,
+ highlightedPickedOptions: draggedValues,
+ })
+
+ if (indices.length === 0) {
+ return false
+ }
+
+ const indexSet = new Set(indices)
+ const draggedBlock = indices.map((index) => selected[index])
+ const remaining = selected.filter((_, index) => !indexSet.has(index))
+ const removedBefore = indices.filter((index) => index < dropIndex).length
+ const insertPos = Math.max(
+ 0,
+ Math.min(dropIndex - removedBefore, remaining.length)
+ )
+
+ const reordered = [
+ ...remaining.slice(0, insertPos),
+ ...draggedBlock,
+ ...remaining.slice(insertPos),
+ ]
+
+ if (reordered.every((value, index) => value === selected[index])) {
+ return false
+ }
+
+ onChange({ selected: reordered })
+ return true
+}
diff --git a/components/transfer/src/transfer/resolve-drop-target.js b/components/transfer/src/transfer/resolve-drop-target.js
new file mode 100644
index 0000000000..b6769ac485
--- /dev/null
+++ b/components/transfer/src/transfer/resolve-drop-target.js
@@ -0,0 +1,74 @@
+/**
+ * Resolves where a drag would drop, or `null` if dropping is not
+ * allowed at the current position:
+ * - `{ side: 'picked', pos: 'before'|'after', value, index }` — insert
+ * at a position (`index` already includes the +1 for 'after').
+ * - `{ side: 'picked', pos: 'end' }` — append to the picked list.
+ * - `{ side: 'source', pos: 'container' }` — remove from the picked list.
+ *
+ * Positional inserts require `enableOrderChange` and no active picked
+ * filter (a filtered visual index doesn't map to a `selected` index) —
+ * otherwise a drop only appends, matching the "add individual" button
+ * and the disabled reorder buttons.
+ *
+ * @param {Object} args
+ * @param {string} args.activeSide - 'source' | 'picked'
+ * @param {string} args.overSide - 'source' | 'picked'
+ * @param {string} args.overType - 'option' | 'container'
+ * @param {string} [args.overValue] - value of the hovered option
+ * @param {number} [args.overIndex] - index of the hovered option
+ * @param {Object} [args.overRect] - hovered option rect ({ top, height })
+ * @param {number} [args.dragY] - the y coordinate representing the
+ * drag position (the dragged rect's vertical center)
+ * @param {string[]} args.draggedValues
+ * @param {boolean} args.enableOrderChange
+ * @param {boolean} args.filterActivePicked
+ * @returns {?Object}
+ */
+export const resolveDropTarget = ({
+ activeSide,
+ overSide,
+ overType,
+ overValue,
+ overIndex,
+ overRect,
+ dragY,
+ draggedValues,
+ enableOrderChange,
+ filterActivePicked,
+}) => {
+ if (!overSide) {
+ return null
+ }
+
+ if (overSide === 'source') {
+ // Dropping a picked option on the source side removes it.
+ // Source options can't be dropped on their own list.
+ return activeSide === 'picked'
+ ? { side: 'source', pos: 'container' }
+ : null
+ }
+
+ // overSide === 'picked'
+ const positionalInsertAllowed = enableOrderChange && !filterActivePicked
+
+ if (!positionalInsertAllowed) {
+ // Adding is still possible (append), reordering is not
+ return activeSide === 'source' ? { side: 'picked', pos: 'end' } : null
+ }
+
+ if (overType === 'container') {
+ return { side: 'picked', pos: 'end' }
+ }
+
+ if (activeSide === 'picked' && draggedValues.includes(overValue)) {
+ // Hovering the dragged block itself is a no-op
+ return null
+ }
+
+ const overMiddle = overRect.top + overRect.height / 2
+ const pos = dragY < overMiddle ? 'before' : 'after'
+ const index = pos === 'before' ? overIndex : overIndex + 1
+
+ return { side: 'picked', pos, value: overValue, index }
+}
diff --git a/components/transfer/src/transfer/resolve-positional-index.js b/components/transfer/src/transfer/resolve-positional-index.js
new file mode 100644
index 0000000000..e601a3d4c9
--- /dev/null
+++ b/components/transfer/src/transfer/resolve-positional-index.js
@@ -0,0 +1,22 @@
+/**
+ * Resolves a `resolveDropTarget` result into an insertion index within
+ * `selected`. Looks up the drop target's option by value rather than
+ * reusing its rendered index — `pickedOptions` can omit `selected`
+ * values that have no matching option, so the two indices can diverge.
+ *
+ * @param {Object} dropTarget - result of `resolveDropTarget`
+ * @param {string[]} selected
+ * @returns {?number}
+ */
+export const resolvePositionalIndex = (dropTarget, selected) => {
+ if (dropTarget.pos === 'end') {
+ return null
+ }
+
+ const base = selected.indexOf(dropTarget.value)
+ if (base === -1) {
+ return null
+ }
+
+ return base + (dropTarget.pos === 'after' ? 1 : 0)
+}
diff --git a/components/transfer/src/transfer/use-transfer-dnd.js b/components/transfer/src/transfer/use-transfer-dnd.js
new file mode 100644
index 0000000000..12fd5b77ba
--- /dev/null
+++ b/components/transfer/src/transfer/use-transfer-dnd.js
@@ -0,0 +1,217 @@
+import {
+ PointerSensor,
+ pointerWithin,
+ rectIntersection,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core'
+import { useCallback, useRef, useState } from 'react'
+import { addSourceOptionsOnDrop } from './add-source-options-on-drop.js'
+import { getDraggedValues } from './get-dragged-values.js'
+import { removeIndividualPickedOptions } from './remove-individual-picked-options.js'
+import { reorderPickedOptionsOnDrop } from './reorder-picked-options-on-drop.js'
+import { resolveDropTarget } from './resolve-drop-target.js'
+import { resolvePositionalIndex } from './resolve-positional-index.js'
+
+const isOptionCollision = (collision) =>
+ collision.data?.droppableContainer?.data?.current?.type === 'option'
+
+/* When the pointer is over an option it is necessarily also over that
+ * option's list container — prefer the option so before/after
+ * positions win over "append to container" */
+const preferOptions = (collisions) => {
+ const optionCollisions = collisions.filter(isOptionCollision)
+ return optionCollisions.length ? optionCollisions : collisions
+}
+
+/*
+ * List mutations only commit on drop, via `onChange`.
+ *
+ * The `PointerSensor`'s 6px activation distance keeps plain clicks
+ * (highlighting, modifier-key modes, double-click detection) working
+ * unchanged. The synthetic click after a completed drag is suppressed
+ * by dnd-kit itself, not this file.
+ */
+export const useTransferDnd = ({
+ selected,
+ sourceOptions,
+ pickedOptions,
+ highlightedSourceOptions,
+ highlightedPickedOptions,
+ setHighlightedSourceOptions,
+ setHighlightedPickedOptions,
+ enableOrderChange,
+ filterActivePicked,
+ maxSelections,
+ onChange,
+ reorderScrollTargetRef,
+}) => {
+ const [activeDrag, setActiveDrag] = useState(null)
+ const [dropTarget, setDropTarget] = useState(null)
+ /* The fly-back drop animation returns the overlay to the origin
+ * node, so it only makes sense when the option stays put (cancel or
+ * no-op drop). On a committed move the origin no longer represents
+ * where the option went, so the overlay fades in place instead. */
+ const [flyBackOnDrop, setFlyBackOnDrop] = useState(true)
+ /* Refs mirror the state for the dnd-kit callbacks: a drag can
+ * start and move before the state-triggered re-render happens */
+ const activeDragRef = useRef(null)
+ const dropTargetRef = useRef(null)
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
+ )
+
+ const collisionDetection = useCallback((args) => {
+ const within = pointerWithin(args)
+ return preferOptions(within.length ? within : rectIntersection(args))
+ }, [])
+
+ const updateDropTarget = (nextDropTarget) => {
+ dropTargetRef.current = nextDropTarget
+ setDropTarget(nextDropTarget)
+ }
+
+ const onDragStart = ({ active }) => {
+ const { side, value } = active.data.current
+ const [visibleOptions, highlightedOptions, setHighlightedOptions] =
+ side === 'source'
+ ? [
+ sourceOptions,
+ highlightedSourceOptions,
+ setHighlightedSourceOptions,
+ ]
+ : [
+ pickedOptions,
+ highlightedPickedOptions,
+ setHighlightedPickedOptions,
+ ]
+
+ const draggedValues = getDraggedValues({
+ draggedValue: value,
+ highlightedOptions,
+ visibleOptions,
+ })
+
+ if (!highlightedOptions.includes(value)) {
+ // Grabbing a non-highlighted option highlights it,
+ // like a plain click would
+ setHighlightedOptions([value])
+ }
+
+ const primaryOption = visibleOptions.find(
+ (option) => option.value === draggedValues[0]
+ )
+ const nextActiveDrag = {
+ side,
+ values: draggedValues,
+ label: primaryOption?.label,
+ count: draggedValues.length,
+ }
+ activeDragRef.current = nextActiveDrag
+ setActiveDrag(nextActiveDrag)
+ setFlyBackOnDrop(true)
+ }
+
+ const onDragMove = ({ active, over, delta, activatorEvent }) => {
+ const currentDrag = activeDragRef.current
+ if (!currentDrag) {
+ return
+ }
+
+ /* The translated rect is what dnd-kit's collision detection
+ * uses, so unlike the raw pointer position it stays consistent
+ * with `over.rect` while the lists auto-scroll during a drag */
+ const translated = active.rect.current?.translated
+ const dragY = translated
+ ? translated.top + translated.height / 2
+ : activatorEvent.clientY + delta.y
+
+ const overData = over?.data.current
+ updateDropTarget(
+ resolveDropTarget({
+ activeSide: currentDrag.side,
+ overSide: overData?.side,
+ overType: overData?.type,
+ overValue: overData?.value,
+ overIndex: overData?.index,
+ overRect: over?.rect,
+ dragY,
+ draggedValues: currentDrag.values,
+ enableOrderChange,
+ filterActivePicked,
+ })
+ )
+ }
+
+ const finishDrag = () => {
+ activeDragRef.current = null
+ updateDropTarget(null)
+ setActiveDrag(null)
+ }
+
+ const onDragEnd = () => {
+ const currentDrag = activeDragRef.current
+ const currentDropTarget = dropTargetRef.current
+
+ if (currentDrag && currentDropTarget) {
+ setFlyBackOnDrop(false)
+ const { side, values: draggedValues } = currentDrag
+
+ const positionalIndex = resolvePositionalIndex(
+ currentDropTarget,
+ selected
+ )
+
+ if (currentDropTarget.side === 'source') {
+ // `draggedValues` is the picked highlight set to remove
+ removeIndividualPickedOptions({
+ selected,
+ highlightedPickedOptions: draggedValues,
+ onChange,
+ setHighlightedPickedOptions,
+ })
+ } else if (side === 'source') {
+ const changed = addSourceOptionsOnDrop({
+ selected,
+ draggedValues,
+ dropIndex: positionalIndex ?? undefined,
+ maxSelections,
+ onChange,
+ setHighlightedSourceOptions,
+ })
+ if (changed) {
+ reorderScrollTargetRef.current = draggedValues[0] ?? null
+ }
+ } else {
+ const changed = reorderPickedOptionsOnDrop({
+ selected,
+ draggedValues,
+ dropIndex: positionalIndex ?? selected.length,
+ onChange,
+ })
+ if (changed) {
+ reorderScrollTargetRef.current = draggedValues[0] ?? null
+ }
+ }
+ }
+
+ finishDrag()
+ }
+
+ const onDragCancel = () => {
+ finishDrag()
+ }
+
+ return {
+ sensors,
+ collisionDetection,
+ activeDrag,
+ flyBackOnDrop,
+ dropTarget,
+ onDragStart,
+ onDragMove,
+ onDragEnd,
+ onDragCancel,
+ }
+}
diff --git a/docs/docs/components/transfer.md b/docs/docs/components/transfer.md
index ce8b0fffa4..7ad701f3e9 100644
--- a/docs/docs/components/transfer.md
+++ b/docs/docs/components/transfer.md
@@ -16,7 +16,7 @@ A transfer is used to make complex selections from a list of options.
## Usage
-A transfer is made up of two lists. The _source list_ that shows the available options and the _picked list_ that shows the chosen options. Options can be transferred between lists with the middle buttons or by double-clicking an option.
+A transfer is made up of two lists. The _source list_ that shows the available options and the _picked list_ that shows the chosen options. Options can be transferred between lists with the middle buttons, by double-clicking an option, or by dragging an option to the other list.
### When to use
@@ -122,6 +122,18 @@ The footer component is as follows:
- Allow reordering if the order of the chosen options has meaning or consequences.
+### Drag and drop
+
+
+
+Options can always be dragged between the two lists with a pointer. Dragging a highlighted option drags the whole highlighted set. When `enableOrderChange` is set, the picked list can also be reordered by dragging, and options dropped onto it are inserted at the drop position; otherwise dropped options are appended at the end.
+
+- Drag and drop is a pointer-only convenience. The action buttons and reordering buttons remain the keyboard-accessible way to transfer and reorder options.
+- Disabled options can't be dragged.
+
### Filtering