Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import {
Draggable,
DualListSelectorListItem,
Tooltip,
} from '@patternfly/react-core';

const TOOLTIP_ENTRY_DELAY = 500;
const TOOLTIP_EXIT_DELAY = 100;

const DualListOptionItem = ({
label,
isSelected,
id,
onOptionSelect,
isDraggable,
isDisabled,
showTooltip,
}) => {
const [tooltipTrigger, setTooltipTrigger] = useState(null);
const [tooltipVisible, setTooltipVisible] = useState(false);
const showTimerRef = useRef();
const hideTimerRef = useRef();
const isDraggingRef = useRef(false);

useEffect(
() => () => {
clearTimeout(showTimerRef.current);
clearTimeout(hideTimerRef.current);
},
[]
);

useEffect(() => {
if (!tooltipTrigger || !showTooltip || !isDraggable) {
return undefined;
}

const onDragStart = () => {
isDraggingRef.current = true;
clearTimeout(showTimerRef.current);
clearTimeout(hideTimerRef.current);
setTooltipVisible(false);
};

const onDragEnd = () => {
isDraggingRef.current = false;
};

tooltipTrigger.addEventListener('dragstart', onDragStart);
document.addEventListener('mouseup', onDragEnd);

return () => {
tooltipTrigger.removeEventListener('dragstart', onDragStart);
document.removeEventListener('mouseup', onDragEnd);
};
}, [tooltipTrigger, showTooltip, isDraggable]);

const showTooltipHandler = () => {
if (!showTooltip || isDraggingRef.current) {
return;
}
clearTimeout(hideTimerRef.current);
showTimerRef.current = setTimeout(() => {
if (!isDraggingRef.current) {
setTooltipVisible(true);
}
}, TOOLTIP_ENTRY_DELAY);
};

const hideTooltipHandler = () => {
clearTimeout(showTimerRef.current);
hideTimerRef.current = setTimeout(
() => setTooltipVisible(false),
TOOLTIP_EXIT_DELAY
);
};

const listItem = (
<DualListSelectorListItem
ref={setTooltipTrigger}
isSelected={isSelected}
id={id}
onOptionSelect={onOptionSelect}
onMouseEnter={showTooltipHandler}
onMouseLeave={hideTooltipHandler}
isDraggable={isDraggable}
isDisabled={isDisabled}
>
{label}
</DualListSelectorListItem>
);

return (
<>
{isDraggable ? <Draggable hasNoWrapper>{listItem}</Draggable> : listItem}
{showTooltip && tooltipTrigger && (
<Tooltip
content={label}
trigger="manual"
isVisible={tooltipVisible}
triggerRef={() => tooltipTrigger}
entryDelay={0}
exitDelay={0}
aria="none"
/>
)}
</>
);
};

DualListOptionItem.propTypes = {
label: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
id: PropTypes.string.isRequired,
onOptionSelect: PropTypes.func.isRequired,
isDraggable: PropTypes.bool,
isDisabled: PropTypes.bool,
showTooltip: PropTypes.bool,
};

DualListOptionItem.defaultProps = {
isDraggable: false,
isDisabled: false,
showTooltip: true,
};

export default DualListOptionItem;
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
DragDrop,
Droppable,
DualListSelector,
DualListSelectorPane,
DualListSelectorList,
DualListSelectorControlsWrapper,
DualListSelectorControl,
} from '@patternfly/react-core';
import {
AngleDoubleLeftIcon,
AngleLeftIcon,
AngleDoubleRightIcon,
AngleRightIcon,
} from '@patternfly/react-icons';

import { translate as __, sprintf } from '../../../common/I18n';
import { noop } from '../../../common/helpers';
import DualListOptionItem from './DualListOptionItem';
import {
appendUniqueOptions,
moveItems,
normalizeOptions,
optionsToNames,
reorderChosen,
} from './helpers';

const defaultSelectState = { availableSelected: [], chosenSelected: [] };

/**
* PF5 composable dual list selector with drag-and-drop reordering on the chosen pane.
* The parent owns available and chosen option lists and receives updates via onListChange.
* Options may be strings or { label, value } objects.
*/
const OrderableDualListSelector = ({
id,
availableOptions,
chosenOptions,
onListChange,
availableOptionsTitle,
chosenOptionsTitle,
isDisabled,
showTooltips,
}) => {
const available = normalizeOptions(availableOptions);
const chosen = normalizeOptions(chosenOptions);
const returnStrings =
(availableOptions.length > 0 || chosenOptions.length > 0) &&
[...availableOptions, ...chosenOptions].every(
option => typeof option === 'string'
);
const [selectState, setSelectState] = useState(defaultSelectState);
const [ignoreNextOptionSelect, setIgnoreNextOptionSelect] = useState(false);
const availableSelectedSet = new Set(selectState.availableSelected);
const chosenSelectedSet = new Set(selectState.chosenSelected);
const selectedSets = {
availableSelected: availableSelectedSet,
chosenSelected: chosenSelectedSet,
};

const emitListChange = (nextAvailable, nextChosen) => {
if (returnStrings) {
onListChange(optionsToNames(nextAvailable), optionsToNames(nextChosen));
return;
}

onListChange(nextAvailable, nextChosen);
};

const isItemSelected = stateName => value =>
selectedSets[stateName].has(String(value));

const onItemClick = stateName => value => () => {
if (ignoreNextOptionSelect) {
setIgnoreNextOptionSelect(false);
return;
}

const valueKey = String(value);
if (isItemSelected(stateName)(valueKey)) {
setSelectState({
...selectState,
[stateName]: selectState[stateName].filter(item => item !== valueKey),
});
} else {
setSelectState({
...selectState,
[stateName]: [...selectState[stateName], valueKey],
});
}
};

const onMoveSelected = fromAvailable => {
if (fromAvailable) {
const [newAvailable, newChosen] = moveItems(
available,
chosen,
selectState.availableSelected
);
setSelectState({ ...selectState, availableSelected: [] });
emitListChange(newAvailable, newChosen);
} else {
const [newChosen, newAvailable] = moveItems(
chosen,
available,
selectState.chosenSelected
);
setSelectState({ ...selectState, chosenSelected: [] });
emitListChange(newAvailable, newChosen);
}
};

const onMoveAll = fromAvailable => {
if (fromAvailable) {
setSelectState({ ...selectState, availableSelected: [] });
emitListChange([], appendUniqueOptions(chosen, available));
} else {
setSelectState({ ...selectState, chosenSelected: [] });
emitListChange(appendUniqueOptions(available, chosen), []);
}
};

const onDrop = (source, dest) => {
if (!dest) {
return false;
}

const nextChosen = reorderChosen(chosen, source.index, dest.index);
emitListChange(available, nextChosen);
return true;
};

return (
<DualListSelector id={id}>
<DualListSelectorPane
title={availableOptionsTitle}
status={sprintf(
__('%s of %s items selected'),
selectState.availableSelected.length,
available.length
)}
isDisabled={isDisabled}
>
<DualListSelectorList>
{available.map(option => (
<DualListOptionItem
key={option.value}
label={option.label}
isSelected={isItemSelected('availableSelected')(option.value)}
id={`${id}-available-${option.value}`}
onOptionSelect={onItemClick('availableSelected')(option.value)}
isDisabled={isDisabled}
showTooltip={showTooltips}
/>
))}
</DualListSelectorList>
</DualListSelectorPane>
<DualListSelectorControlsWrapper>
<DualListSelectorControl
isDisabled={isDisabled || selectState.availableSelected.length === 0}
onClick={() => onMoveSelected(true)}
aria-label={__('Add selected')}
>
<AngleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={isDisabled || available.length === 0}
onClick={() => onMoveAll(true)}
aria-label={__('Add all')}
>
<AngleDoubleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={isDisabled || chosen.length === 0}
onClick={() => onMoveAll(false)}
aria-label={__('Remove all')}
>
<AngleDoubleLeftIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={isDisabled || selectState.chosenSelected.length === 0}
onClick={() => onMoveSelected(false)}
aria-label={__('Remove selected')}
>
<AngleLeftIcon />
</DualListSelectorControl>
</DualListSelectorControlsWrapper>
<DragDrop
onDrag={() => {
setIgnoreNextOptionSelect(true);
return true;
}}
onDrop={onDrop}
>
<DualListSelectorPane
title={chosenOptionsTitle}
status={sprintf(
__('%s of %s items selected'),
selectState.chosenSelected.length,
chosen.length
)}
isChosen
isDisabled={isDisabled}
>
<Droppable hasNoWrapper>
<DualListSelectorList>
{chosen.map(option => (
<DualListOptionItem
key={option.value}
label={option.label}
isSelected={isItemSelected('chosenSelected')(option.value)}
id={`${id}-chosen-${option.value}`}
onOptionSelect={onItemClick('chosenSelected')(option.value)}
isDraggable={!isDisabled}
isDisabled={isDisabled}
showTooltip={showTooltips}
/>
))}
</DualListSelectorList>
</Droppable>
</DualListSelectorPane>
</DragDrop>
</DualListSelector>
);
};

OrderableDualListSelector.propTypes = {
id: PropTypes.string.isRequired,
availableOptions: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
}),
])
).isRequired,
chosenOptions: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
}),
])
).isRequired,
onListChange: PropTypes.func,
availableOptionsTitle: PropTypes.string,
chosenOptionsTitle: PropTypes.string,
isDisabled: PropTypes.bool,
showTooltips: PropTypes.bool,
};

OrderableDualListSelector.defaultProps = {
onListChange: noop,
availableOptionsTitle: __('Available options'),
chosenOptionsTitle: __('Chosen options'),
isDisabled: false,
showTooltips: true,
};

export default OrderableDualListSelector;
Loading
Loading