Add SortableList component (#34237)

* Add initial draggable list component

* Add changelog entry

* Add drag and drop index

* Add util for checking target drop index

* Add handle component

* Rename component to SortableList

* Use dragged list item for placeholder height

* Add onOrderChange prop to subscribde to updates

* Throttle the drag over event

* Add base styles

* Add utils tests

* Add component tests

* Export component

* Update changelog entry

* Add readme

* Escape pipe in readme
This commit is contained in:
Joshua T Flowers 2022-08-12 11:45:26 -07:00 committed by GitHub
parent f171913c93
commit 19853e1577
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 571 additions and 0 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add SortableList component

View File

@ -37,6 +37,7 @@ export { default as SectionHeader } from './section-header';
export { default as SegmentedSelection } from './segmented-selection';
export { default as SelectControl } from './select-control';
export { default as ScrollTo } from './scroll-to';
export { SortableList } from './sortable-list';
export { default as Spinner } from './spinner';
export { default as Stepper } from './stepper';
export { default as SummaryList } from './summary';

View File

@ -0,0 +1,27 @@
SortableList
===
This component provides a wrapper to allow dragging and sorting of items.
## Usage
This component accepts any valid JSX elements as children. Adding a `key` to elements will provide a way to later identify the order of these elements in callbacks.
```jsx
<SortableList onOrderChange={ ( items ) => console.log( 'Items have been reordered:', items ) }>
<div key="item-1">List item 1</div>
<div key="item-2">List item 2</div>
<div key="item-3">List item 3</div>
</SortableList>
```
### Props
Name | Type | Default | Description
--- | --- | --- | ---
`children` | JSX.Element \| JSX.Element[] | `undefined` | The draggable items in the list
`onDragEnd` | Function | `() => null` | A callback when an item is no longer being dragged
`onDragOver` | Function | `() => null` | A callback when an item is being dragged over by another item
`onDragStart` | Function | `() => null` | A callback when an item starts being dragged
`onOrderChange` | Function | `() => null` | A callback when the order of the items has been updated
`shouldRenderHandles` | Boolean | `true` | Whether or not the default handles should be added with the list item

View File

@ -0,0 +1,21 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
export const DraggableIcon = () => (
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="2" height="2" fill="#757575" />
<rect y="6" width="2" height="2" fill="#757575" />
<rect y="12" width="2" height="2" fill="#757575" />
<rect x="6" width="2" height="2" fill="#757575" />
<rect x="6" y="6" width="2" height="2" fill="#757575" />
<rect x="6" y="12" width="2" height="2" fill="#757575" />
</svg>
);

View File

@ -0,0 +1,33 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createElement } from '@wordpress/element';
import { DragEventHandler } from 'react';
/**
* Internal dependencies
*/
import { DraggableIcon } from './draggable-icon';
type HandleProps = {
children?: React.ReactNode;
onDragStart?: DragEventHandler< HTMLDivElement >;
onDragEnd?: DragEventHandler< HTMLDivElement >;
};
export const Handle = ( {
children,
onDragStart = () => null,
onDragEnd = () => null,
}: HandleProps ) => (
<span
className="woocommerce-sortable-list__handle"
draggable
onDragStart={ onDragStart }
onDragEnd={ onDragEnd }
aria-label={ __( 'Move this item', 'woocommerce' ) }
>
{ children ? children : <DraggableIcon /> }
</span>
);

View File

@ -0,0 +1 @@
export * from './sortable-list';

View File

@ -0,0 +1,81 @@
/**
* External dependencies
*/
import { DragEvent, DragEventHandler, LegacyRef, ReactNode } from 'react';
import classnames from 'classnames';
import { cloneElement, createElement, Fragment } from '@wordpress/element';
import { Draggable } from '@wordpress/components';
/**
* Internal dependencies
*/
import { Handle } from './handle';
import { SortableListChild } from './types';
export type ListItemProps = {
id: string | number;
children: SortableListChild;
isDragging?: boolean;
isDraggingOver?: boolean;
onDragStart?: DragEventHandler< HTMLDivElement >;
onDragEnd?: DragEventHandler< HTMLDivElement >;
onDragOver?: DragEventHandler< HTMLLIElement >;
shouldRenderHandle?: boolean;
style?: React.CSSProperties;
};
export const ListItem = ( {
id,
children,
isDragging = false,
isDraggingOver = false,
onDragStart = () => null,
onDragEnd = () => null,
onDragOver = () => null,
shouldRenderHandle = true,
style,
}: ListItemProps ) => {
const handleDragStart = ( event: DragEvent< HTMLDivElement > ) => {
onDragStart( event );
};
const handleDragEnd = ( event: DragEvent< HTMLDivElement > ) => {
onDragEnd( event );
};
return (
<li
className={ classnames( 'woocommerce-sortable-list__item', {
'is-dragging': isDragging,
'is-dragging-over': isDraggingOver,
} ) }
id={ `woocommerce-sortable-list__item-${ id }` }
onDragOver={ onDragOver }
style={ style }
>
<Draggable
elementId={ `woocommerce-sortable-list__item-${ id }` }
transferData={ {} }
onDragStart={ handleDragStart as () => void }
onDragEnd={ handleDragEnd as () => void }
>
{ ( { onDraggableStart, onDraggableEnd } ) => {
return (
<>
{ shouldRenderHandle && (
<Handle
onDragEnd={ onDraggableEnd }
onDragStart={ onDraggableStart }
/>
) }
{ cloneElement( children, {
onDraggableStart,
onDraggableEnd,
} ) }
</>
);
} }
</Draggable>
</li>
);
};

View File

@ -0,0 +1,130 @@
/**
* External dependencies
*/
import { DragEvent, DragEventHandler } from 'react';
import classnames from 'classnames';
import {
createElement,
useCallback,
useEffect,
useState,
} from '@wordpress/element';
import { throttle } from 'lodash';
/**
* Internal dependencies
*/
import { ListItem } from './list-item';
import { isUpperHalf, moveIndex } from './utils';
import { SortableListChild } from './types';
export type SortableListProps = {
children: SortableListChild | SortableListChild[];
onDragEnd?: DragEventHandler< HTMLDivElement >;
onDragOver?: DragEventHandler< HTMLLIElement >;
onDragStart?: DragEventHandler< HTMLDivElement >;
onOrderChange?: ( items: SortableListChild[] ) => void;
shouldRenderHandles?: boolean;
};
export const SortableList = ( {
children,
onDragEnd = () => null,
onDragOver = () => null,
onDragStart = () => null,
onOrderChange = () => null,
shouldRenderHandles = true,
}: SortableListProps ) => {
const [ items, setItems ] = useState< SortableListChild[] >( [] );
const [ dragIndex, setDragIndex ] = useState< number | null >( null );
const [ dragHeight, setDragHeight ] = useState< number >( 0 );
const [ dropIndex, setDropIndex ] = useState< number | null >( null );
useEffect( () => {
setItems( Array.isArray( children ) ? children : [ children ] );
}, [ children ] );
const handleDragStart = (
event: DragEvent< HTMLDivElement >,
index: number
) => {
const target = event.target as HTMLElement;
const listItem = target.closest(
'.woocommerce-sortable-list__item'
) as HTMLElement;
setDragHeight( listItem.offsetHeight );
setDropIndex( index );
setDragIndex( index );
onDragStart( event );
};
const handleDragEnd = (
event: DragEvent< HTMLDivElement >,
index: number
) => {
if (
dropIndex !== null &&
dragIndex !== null &&
dropIndex !== dragIndex
) {
const nextItems = moveIndex( dragIndex, dropIndex, items );
setItems( nextItems as JSX.Element[] );
onOrderChange( nextItems );
}
setDragIndex( null );
setDropIndex( null );
onDragEnd( event );
};
const handleDragOver = (
event: DragEvent< HTMLLIElement >,
index: number
) => {
if ( dragIndex === null ) {
return;
}
const targetIndex = isUpperHalf( event ) ? index : index + 1;
setDropIndex( targetIndex );
onDragOver( event );
};
const throttledHandleDragOver = useCallback(
throttle( handleDragOver, 16 ),
[ dragIndex ]
);
return (
<ul
className={ classnames( 'woocommerce-sortable-list', {
'is-dragging': dragIndex !== null,
} ) }
>
{ items.map( ( child, index ) => (
<ListItem
key={ index }
shouldRenderHandle={ shouldRenderHandles }
id={ index }
isDragging={ index === dragIndex }
isDraggingOver={ index === dropIndex }
onDragEnd={ ( event ) => handleDragEnd( event, index ) }
onDragStart={ ( event ) => handleDragStart( event, index ) }
onDragOver={ ( event ) =>
throttledHandleDragOver( event, index )
}
style={
dropIndex !== null && dropIndex <= index
? {
transform: `translate(0, ${ dragHeight }px)`,
}
: {}
}
>
{ child }
</ListItem>
) ) }
</ul>
);
};

View File

@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { createElement, Fragment } from '@wordpress/element';
import React, { DragEventHandler } from 'react';
import { Icon, wordpress } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { SortableList } from '..';
import { Handle } from '../handle';
export const Basic = () => {
return (
<SortableList
onOrderChange={ ( items ) =>
// eslint-disable-next-line no-alert
alert( 'Order changed: ' + items.map( ( item ) => item.key ) )
}
>
<Fragment key={ 'item-1' }>Item 1</Fragment>
<Fragment key={ 'item-2' }>Item 2</Fragment>
<Fragment key={ 'item-3' }>Item 3</Fragment>
<Fragment key={ 'item-4' }>Item 4</Fragment>
<Fragment key={ 'item-5' }>Item 5</Fragment>
</SortableList>
);
};
export const CustomHandle = () => {
type CustomListItemProps = {
children: React.ReactNode;
onDraggableEnd?: DragEventHandler< Element >;
onDraggableStart?: DragEventHandler< Element >;
};
const CustomListItem = ( {
children,
onDraggableStart,
onDraggableEnd,
}: CustomListItemProps ) => {
return (
<>
<Handle
onDragEnd={ onDraggableEnd }
onDragStart={ onDraggableStart }
>
<Icon icon={ wordpress } size={ 16 } />
</Handle>
{ children }
</>
);
};
return (
<SortableList shouldRenderHandles={ false }>
<CustomListItem key="item-1">Item 1</CustomListItem>
<CustomListItem key="item-2">Item 2</CustomListItem>
<CustomListItem key="item-3">Item 3</CustomListItem>
<CustomListItem key="item-4">Item 4</CustomListItem>
<CustomListItem key="item-5">Item 5</CustomListItem>
</SortableList>
);
};
export default {
title: 'WooCommerce Admin/components/SortableList',
component: SortableList,
};

View File

@ -0,0 +1,24 @@
.woocommerce-sortable-list {
&.is-dragging .woocommerce-sortable-list__item {
transition: transform 0.1s ease-in;
}
}
.woocommerce-sortable-list__item {
background: $studio-white;
border: 1px solid $gray-400;
margin: 0;
padding: $gap;
display: flex;
align-items: center;
&.is-dragging {
display: none;
}
}
.woocommerce-sortable-list__handle {
cursor: grab;
margin-right: $gap;
display: inline-flex;
}

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
import React, { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { Handle } from '../handle';
import { SortableList } from '../sortable-list';
describe( 'SortableList', () => {
it( 'should render the list items', () => {
const { queryByText } = render(
<SortableList>
<div>Item 1</div>
<div>Item 2</div>
</SortableList>
);
expect( queryByText( 'Item 1' ) ).toBeInTheDocument();
expect( queryByText( 'Item 2' ) ).toBeInTheDocument();
} );
it( 'should render the list handles', () => {
const { getByLabelText } = render(
<SortableList>
<div>Item 1</div>
</SortableList>
);
expect( getByLabelText( 'Move this item' ) ).toBeInTheDocument();
} );
it( 'should render the custom list handles', () => {
const { queryAllByLabelText } = render(
<SortableList shouldRenderHandles={ false }>
<div>
<Handle>Custom handle</Handle>
Item 1
</div>
</SortableList>
);
const handles = queryAllByLabelText( 'Move this item' );
expect( handles.length ).toBe( 1 );
expect( handles[ 0 ].textContent ).toBe( 'Custom handle' );
} );
} );

View File

@ -0,0 +1,89 @@
/**
* External dependencies
*/
import { DragEvent } from 'react';
/**
* Internal dependencies
*/
import { moveIndex, isUpperHalf } from '../utils';
describe( 'utils', () => {
it( 'should move the from index to a higher index', () => {
const arr = [ 'apple', 'orange', 'banana' ];
const newArr = moveIndex( 0, 2, arr );
expect( newArr ).toEqual( [ 'orange', 'apple', 'banana' ] );
} );
it( 'should move the from index to the last index', () => {
const arr = [ 'apple', 'orange', 'banana' ];
const newArr = moveIndex( 0, 3, arr );
expect( newArr ).toEqual( [ 'orange', 'banana', 'apple' ] );
} );
it( 'should move the from index to a lower index', () => {
const arr = [ 'apple', 'orange', 'banana' ];
const newArr = moveIndex( 2, 0, arr );
expect( newArr ).toEqual( [ 'banana', 'apple', 'orange' ] );
} );
it( 'should return true when the cursor is in the upper half of an element', () => {
const event = {
clientY: 0,
target: {
offsetHeight: 100,
getBoundingClientRect: () => ( {
top: 0,
} ),
},
} as unknown;
expect(
isUpperHalf( event as DragEvent< HTMLLIElement > )
).toBeTruthy();
} );
it( 'should return true when the element is placed lower in the page', () => {
const event = {
clientY: 0,
target: {
offsetHeight: 100,
getBoundingClientRect: () => ( {
top: 70,
} ),
},
} as unknown;
expect(
isUpperHalf( event as DragEvent< HTMLLIElement > )
).toBeTruthy();
} );
it( 'should return false when the cursor is more than half way down', () => {
const event = {
clientY: 60,
target: {
offsetHeight: 100,
getBoundingClientRect: () => ( {
top: 0,
} ),
},
} as unknown;
expect(
isUpperHalf( event as DragEvent< HTMLLIElement > )
).toBeFalsy();
} );
it( 'should return false when the element is lower in the page', () => {
const event = {
clientY: 152,
target: {
offsetHeight: 100,
getBoundingClientRect: () => ( {
top: 100,
} ),
},
} as unknown;
expect(
isUpperHalf( event as DragEvent< HTMLLIElement > )
).toBeFalsy();
} );
} );

View File

@ -0,0 +1 @@
export type SortableListChild = JSX.Element;

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import { DragEvent } from 'react';
/**
* Move an item from an index in an array to a new index.s
*
* @param fromIndex Index to move the item from.
* @param toIndex Index to move the item to.
* @param arr The array to copy.
* @return array
*/
export const moveIndex = < T >(
fromIndex: number,
toIndex: number,
arr: T[]
) => {
const newArr = [ ...arr ];
const item = arr[ fromIndex ];
newArr.splice( fromIndex, 1 );
// Splicing the array reduces the array size by 1 after removal.
// Lower index items affect the position of where the item should be inserted.
newArr.splice( fromIndex < toIndex ? toIndex - 1 : toIndex, 0, item );
return newArr;
};
/**
* Check whether the mouse is over the lower or upper half of the event target.
*
* @param event Drag event.
* @return boolean
*/
export const isUpperHalf = ( event: DragEvent< HTMLLIElement > ) => {
const target = event.target as HTMLElement;
const middle = target.offsetHeight / 2;
const rect = target.getBoundingClientRect();
const relativeY = event.clientY - rect.top;
return relativeY < middle;
};

View File

@ -31,6 +31,7 @@
@import 'section-header/style.scss';
@import 'segmented-selection/style.scss';
@import 'select-control/style.scss';
@import 'sortable-list/style.scss';
@import 'split-dropdown/style.scss';
@import 'stepper/style.scss';
@import 'spinner/style.scss';