Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add drag-and-drop to ListEntries #208

Closed
wants to merge 13 commits into from
31 changes: 28 additions & 3 deletions packages/pelagos/__tests__/listInput/ListEntries-test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import {useEffect, useRef} from 'react';
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
import {shallow} from 'enzyme';
import debounce from 'lodash-es/debounce';
import {useEffect} from 'react';

import moveListItem from '../../src/functions/moveListItem';
import scrollIntoView from '../../src/functions/scrollIntoView';
import useReorder from '../../src/hooks/useReorder';
import ListEntries from '../../src/listInput/ListEntries';
import renderListItem from '../../src/listItems/renderListItem';
import scrollIntoView from '../../src/functions/scrollIntoView';

jest.unmock('../../src/listInput/ListEntries');

jest.mock('lodash-es/debounce', () => jest.fn((f) => ((f.cancel = jest.fn()), f)));
const anyFunction = expect.any(Function);
matt8100 marked this conversation as resolved.
Show resolved Hide resolved

const list = [
{id: '0', name: 'test0'},
{id: '1', name: 'test1'},
];
const getId = (i) => i.id;
const getName = (i) => i.name;

Expand All @@ -18,6 +25,7 @@ global.document = {querySelector};

describe('ListEntries', () => {
describe('rendering', () => {
beforeEach(() => useReorder.mockReturnValueOnce([]));
it('renders expected elements', () => {
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
const wrapper = shallow(
<ListEntries
Expand Down Expand Up @@ -88,7 +96,7 @@ describe('ListEntries', () => {
const onRemoveClick = jest.fn();
const item = {id: '0', name: 'test'};
const live = {};
useRef.mockReturnValueOnce({current: live});
useReorder.mockReturnValueOnce([null, {current: live}]);
const wrapper = shallow(
<ListEntries
id="test"
Expand All @@ -105,6 +113,7 @@ describe('ListEntries', () => {
});

it('does not call onRemoveClick when the list is clicked outside', () => {
useReorder.mockReturnValueOnce([]);
const onRemoveClick = jest.fn();
const item = {id: '0', name: 'test'};
const wrapper = shallow(
Expand All @@ -122,6 +131,7 @@ describe('ListEntries', () => {
});

it('calls scrollIntoView when highlightKey is set and the element is found', () => {
useReorder.mockReturnValueOnce([]);
const onHighlightClear = jest.fn();
const element = {};
querySelector.mockReturnValue(element);
Expand All @@ -147,6 +157,7 @@ describe('ListEntries', () => {
});

it('does not call scrollIntoView when highlightKey is set and the element is not found', () => {
useReorder.mockReturnValueOnce([]);
const onHighlightClear = jest.fn();
querySelector.mockReturnValue(null);
shallow(
Expand All @@ -167,6 +178,7 @@ describe('ListEntries', () => {
});

it('does not call querySelector when highlightKey is not set', () => {
useReorder.mockReturnValueOnce([]);
const onHighlightClear = jest.fn();
shallow(
<ListEntries
Expand All @@ -182,5 +194,18 @@ describe('ListEntries', () => {
expect(querySelector).not.toHaveBeenCalled();
expect(onHighlightClear).not.toHaveBeenCalled();
});

it('adds a reorder handler', () => {
useReorder.mockReturnValueOnce([]);
shallow(
<ListEntries list={list} getItemKey={getId} getItemName={getName} renderItem={(i) => <div>{i.name}</div>} />
);
expect(useReorder.mock.calls).toEqual([
['.ListEntries__item', '.ListEntries__name', 2, anyFunction, anyFunction],
]);
expect(useReorder.mock.calls[0][3]({dataset: {index: '0'}})).toBe('test0');
useReorder.mock.calls[0][4](0, 1);
expect(moveListItem.mock.calls).toEqual([[list, 0, 1]]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ exports[`ListEntries rendering renders expected elements 1`] = `
<div
className="ListEntries__name"
>
test
<div>
test
</div>
</div>
<button
aria-label="Remove test"
Expand Down Expand Up @@ -54,7 +56,9 @@ exports[`ListEntries rendering renders expected elements when column is set 1`]
<div
className="ListEntries__name"
>
test
<div>
test
</div>
</div>
<button
aria-label="Remove test"
Expand Down Expand Up @@ -89,7 +93,9 @@ exports[`ListEntries rendering renders expected elements when highlightKey is se
<div
className="ListEntries__name"
>
test
<div>
test
</div>
</div>
<button
aria-label="Remove test"
Expand Down Expand Up @@ -124,7 +130,9 @@ exports[`ListEntries rendering renders expected elements when renderItem is not
<div
className="ListEntries__name"
>
test
<div>
test
</div>
</div>
<button
aria-label="Remove test"
Expand Down Expand Up @@ -157,9 +165,13 @@ exports[`ListEntries rendering renders expected elements when the item has class
data-testid="list-item"
>
<div
className="TestClass ListEntries__name"
className="ListEntries__name"
>
test
<div
className="TestClass"
>
test
</div>
</div>
<button
aria-label="Remove test"
Expand Down
49 changes: 38 additions & 11 deletions packages/pelagos/src/listInput/ListEntries.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {cloneElement, useCallback, useEffect, useMemo, useRef} from 'react';
import PropTypes from 'prop-types';
import debounce from 'lodash-es/debounce';
import {t} from '@bluecateng/l10n.macro';
import Close from '@carbon/icons-react/es/Close';
import Draggable from '@carbon/icons-react/es/Draggable';
import debounce from 'lodash-es/debounce';
import PropTypes from 'prop-types';
import {useCallback, useEffect, useMemo} from 'react';

import Layer from '../components/Layer';
import renderListItem from '../listItems/renderListItem';
import moveListItem from '../functions/moveListItem';
import scrollIntoView from '../functions/scrollIntoView';
import useReorder from '../hooks/useReorder';
import renderListItem from '../listItems/renderListItem';

import './ListEntries.less';

Expand All @@ -16,14 +19,24 @@ const ListEntries = ({
className,
highlightKey,
list,
reorderable,
column,
getItemKey,
getItemName,
renderItem,
onReorder = moveListItem,
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
onRemoveClick,
onHighlightClear,
}) => {
const liveRef = useRef(null);
const getElementName = useCallback((element) => getItemName(list[+element.dataset.index]), [list, getItemName]);
const updateList = useCallback((fromIndex, toIndex) => onReorder(list, fromIndex, toIndex), [list, onReorder]);
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
const [reorderRef, liveRef] = useReorder(
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
'.ListEntries__item',
'.ListEntries__name',
list.length,
getElementName,
updateList
);

const clearHighlight = useMemo(() => onHighlightClear && debounce(onHighlightClear, 1000), [onHighlightClear]);

Expand All @@ -38,7 +51,7 @@ const ListEntries = ({
onRemoveClick(item, index);
}
},
[list, onRemoveClick, getItemName]
[list, getItemName, liveRef, onRemoveClick]
);

useEffect(() => {
Expand All @@ -52,26 +65,36 @@ const ListEntries = ({
return clearHighlight && clearHighlight.cancel;
}, [highlightKey, clearHighlight]);

const operationId = `${id}-operation`;
return (
<>
<div className="sr-only" aria-live="polite" ref={liveRef} />
{reorderable && (
<div id={operationId} className="sr-only">
{t`Press space bar to reorder`}
</div>
)}
<ul
ref={reorderRef}
id={id}
className={`ListEntries ListEntries--${column ? 'column' : 'grid'}${className ? ` ${className}` : ''}`}
className={`ListEntries ListEntries--${column || reorderable ? 'column' : 'grid'}${className ? ` ${className}` : ''}`}
onClick={handleClick}>
{list.map((item, i) => {
const name = getItemName(item);
const element = renderItem ? renderItem(item) : renderListItem(name);
let className = element.props.className;
className = className ? `${className} ListEntries__name` : 'ListEntries__name';
const itemKey = getItemKey(item, i);
return (
<Layer
key={itemKey}
as="li"
className={`ListEntries__item${itemKey === highlightKey ? ' ListEntries__item--highlight' : ''}`}
tabIndex={reorderable ? 0 : undefined}
aria-describedby={reorderable ? operationId : undefined}
data-testid="list-item">
{cloneElement(element, {className})}
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
<div className={`ListEntries__name${reorderable ? ' draggable' : ''}`}>
jwertkin marked this conversation as resolved.
Show resolved Hide resolved
{reorderable && <Draggable className="ListEntries__grip" />}
{element}
</div>
<button
className="ListEntries__icon"
type="button"
Expand All @@ -97,14 +120,18 @@ ListEntries.propTypes = {
highlightKey: PropTypes.string,
/** The data for the list. */
list: PropTypes.array,
/** Whether items are listed as columns. */
/** Whether items are reorderable. */
reorderable: PropTypes.bool,
/** Whether items are listed as columns. Defaults to true if reorderable */
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
column: PropTypes.bool,
/** Function invoked to get each item's key. */
getItemKey: PropTypes.func,
/** Function invoked to get each item's name. */
getItemName: PropTypes.func,
/** Function invoked to render each list item. */
renderItem: PropTypes.func,
/** Function invoked when an item is reordered. */
onReorder: PropTypes.func,
/** Function invoked when the remove button is clicked. */
onRemoveClick: PropTypes.func,
/** Function invoked to clear the highlight key. */
Expand Down
59 changes: 37 additions & 22 deletions packages/pelagos/src/listInput/ListEntries.less
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import '../../less/breakpoints';
@import '../../less/spacing';
@import '../../less/utils';
@import '../../less/focus';

@layer pelagos {
.ListEntries {
Expand Down Expand Up @@ -29,33 +30,47 @@
grid-template-columns: repeat(8, 1fr)
});
}
}

.ListEntries__item {
display: flex;
flex-direction: row;
align-items: center;
gap: @sp-12;
overflow: hidden;
background-color: var(--field);
min-height: 32px;
padding: 0 @sp-12 0 @sp-16;
}
&__item {
@focus-visible();
display: flex;
flex-direction: row;
align-items: center;
gap: @sp-12;
overflow: hidden;
background-color: var(--field);
min-height: 32px;
padding: 0 @sp-12 0 @sp-16;

.ListEntries__item--highlight {
animation: highlight-fade 2s ease-out;
}
&--highlight {
animation: highlight-fade 2s ease-out;
}
}

.ListEntries__icon {
@icon-button();
padding: @sp-04;
&__icon {
@icon-button();
padding: @sp-04;

.ListEntries__item--highlight > & {
color: var(--link-primary);
.ListEntries__item--highlight > & {
color: var(--link-primary);
}
}

&__name {
flex: 1;
flex-direction: row;
align-items: center;
}
}

.ListEntries__name {
flex: 1;
&__grip {
margin-right: @sp-04;
color: var(--icon-secondary);
opacity: 0.5;
transition: opacity 0.15s ease-out;

:hover > & {
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
opacity: 1;
}
}
}
}
2 changes: 2 additions & 0 deletions packages/pelagos/src/listInput/ListEntries.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export default {
export const Grid = {args: {id: 'grid', list, getItemKey, getItemName}};

export const Column = {args: {id: 'column', list, column: true, getItemKey, getItemName}};

export const Reorderable = {args: {id: 'reorderable', list, reorderable: true, getItemKey, getItemName}};
6 changes: 5 additions & 1 deletion packages/pelagos/src/listInput/ListInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const ListInput = ({
list,
helperText,
error,
reorderable,
column,
getSuggestions,
getSuggestionText,
Expand Down Expand Up @@ -181,6 +182,7 @@ const ListInput = ({
className="ListInput__list"
highlightKey={highlightKey}
list={list}
reorderable={reorderable}
column={column}
getItemKey={getItemKey}
getItemName={getItemName}
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -212,7 +214,9 @@ ListInput.propTypes = {
helperText: PropTypes.string,
/** The error text. */
error: PropTypes.string,
/** Whether the list should be displayed as columns. */
/** Whether the list should be reorderable. */
reorderable: PropTypes.bool,
/** Whether the list should be displayed as columns. Defaults to true if reorderable */
matt8100 marked this conversation as resolved.
Show resolved Hide resolved
column: PropTypes.bool,
/** Function invoked to provide a list of suggestions. */
getSuggestions: PropTypes.func,
Expand Down