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