useDragAndDrop
An abstraction around react-dnd
's useDrag
and useDrop
hooks. It provides a simple API to handle drag and drop
events maintaining the same behaviour across the application e.g. when we consider the item to be above a new drop zone.
This hook also wraps an internal hook useKeyboardDragAndDrop
which implements keyboard accessibile drag and drop by
returning an onKeyDown handler to be passed to the component's drag icon button.
Usage
The following examples assume that you have already set up the DndProvider
with HTML5Backend
in your application and
that you are somewhat familiar with @strapi/design-system
components.
Basic usage
Below is a basic example usage where we're not interested in rendering custom previews in the DragLayer. However, we do replace the current item with a placeholder.
import { Box, Flex, IconButton } from '@strapi/design-system';
import { Drag } from '@strapi/icons';
import { useDragAndDrop } from 'path/to/hooks';
import { composeRefs } from 'path/to/utils';
import { Placeholder } from './Placeholder';
const MyComponent = ({ onMoveItem }) => {
const [{ handlerId, isDragging, handleKeyDown }, myRef, dropRef, dragRef] = useDragAndDrop(true, {
type: 'my-type',
index,
onMoveItem,
});
const composedRefs = composeRefs(myRef, dragRef);
return (
<Box ref={dropRef} cursor={'all-scroll'}>
{isDragging ? (
<Placeholder />
) : (
<Flex ref={composedRefs} data-handler-id={handlerId}>
<IconButton
forwardedAs="div"
role="button"
tabIndex={0}
aria-label="Drag"
noBorder
onKeyDown={handleKeyDown}
>
<Drag />
</IconButton>
{'My item'}
</Flex>
)}
</Box>
);
};
Using custom previews
The only really difference between the previous example and this one is
that we're using the getEmptyImage
function from react-dnd-html5-backend
.
import { getEmptyImage } from 'react-dnd-html5-backend';
import { Box, Flex, IconButton } from '@strapi/design-system';
import { Drag } from '@strapi/icons';
import { useDragAndDrop } from 'path/to/hooks';
import { composeRefs } from 'path/to/utils';
import { Placeholder } from './Placeholder';
const MyComponent = ({ onMoveItem }) => {
const [{ handlerId, isDragging, handleKeyDown }, myRef, dropRef, dragRef, dragPreviewRef] =
useDragAndDrop(true, {
type: 'my-type',
index,
onMoveItem,
});
useEffect(() => {
dragPreviewRef(getEmptyImage());
}, [dragPreviewRef]);
const composedRefs = composeRefs(myRef, dragRef);
return (
<Box ref={dropRef} cursor={'all-scroll'}>
{isDragging ? (
<Placeholder />
) : (
<Flex ref={composedRefs} data-handler-id={handlerId}>
<IconButton
forwardedAs="div"
role="button"
tabIndex={0}
aria-label="Drag"
noBorder
onKeyDown={handleKeyDown}
>
<Drag />
</IconButton>
{'My item'}
</Flex>
)}
</Box>
);
};
Typescript
import { Identifier } from 'dnd-core';
import { ConnectDropTarget, ConnectDragSource, ConnectDragPreview } from 'react-dnd';
interface UseDragAndDropOptions {
index: number;
onMoveItem: (newIndex: number, currentIndex: number) => void;
/**
* @default "regular"
* Defines whether the change in index should be immediately over another
* dropzone or half way over it (regular).
*/
dropSensitivity?: 'immediate' | 'regular';
item?: object;
/**
* @default 'STRAPI_DND'
*/
type?: string;
onCancel?: (index: number) => void;
onDropItem?: (index: number) => void;
onEnd?: () => void;
onGrabItem?: (index: number) => void;
onStart?: () => void;
}
type UseDragAndDropReturn = [
props: {
handlerId: Identifier;
isDragging: boolean;
handleKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
},
objectRef: React.RefObject<HTMLElement>,
dropRef: ConnectDropTarget,
dragRef: ConnectDragSource,
dragPreviewRef: ConnectDragPreview
];
type UseDragAndDrop = (active: boolean, options: UseDragAndDropOptions) => UseDragAndDropReturn;
Accessibility
Its advised to implement a live text region in the
parent component holding your individual dnd children. This should be done to inform the user of the current state of the drag and drop.
To implement this, you need to pass the onDropItem
, onGrabItem
and onCancel
callbacks to the useDragAndDrop
hook which are fired
only with the purpose of updating the live region, hence why they're optional. You would also update the live region as part of your
onMoveItem
callback. There are generic messages that can be used in the intl
provider, an example of using this may look like:
setLiveText(
formatMessage(
{
id: getTrad('dnd.drop-item'),
defaultMessage: `{item}, dropped. Final position in list: {position}.`,
},
{
item: 'my item',
position: 1,
}
)
);
Further Reading
Troubleshooting
Firefox quirks
You might notice in the basic usage section this piece of code:
<IconButton
forwardedAs="div"
role="button"
tabIndex={0}
aria-label="Drag"
noBorder
onKeyDown={handleKeyDown}
>
<Drag />
</IconButton>
In firefox
the drag handler will not work if you click and drag when the element is a button
, this is known bug in the browser.
Therefore the workaround is to use the forwardedAs
prop to render a div
instead of a button
and add the role
and tabIndex
props to make this accessible. The actual IconButton
component
adds an accessible lable from the aria-label
prop. So we don't have to concern ourselves with that.