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:
parent
f171913c93
commit
19853e1577
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add SortableList component
|
|
@ -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';
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export * from './sortable-list';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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' );
|
||||
} );
|
||||
} );
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1 @@
|
|||
export type SortableListChild = JSX.Element;
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue