Skip to main content

DragAndDrop

Result
Loading...
Live Editor
<div data-testid="pw-draganddrop">
	<DragAndDrop
		type="list"
		items={{
			'my-container': [
				{
					id: '1',
					props: {
						children: 'Item 1',
					},
				},
				{
					id: '2',
					props: {
						children: (
							<div>
								<div>Item 2</div>
								<div>With Other Content</div>
							</div>
						),
					},
				},
				{
					id: '3',
					props: {
						children: (
							<div>
								<div>Item 3</div>
								<div>With Other Content</div>
								<div>With Other Content</div>
							</div>
						),
					},
				},
				{
					id: '4',
					props: {
						children: (
							<div>
								<div>Item 4</div>
								<div>With Other Content</div>
								<div>With Other Content</div>
								<div>With Other Content</div>
							</div>
						),
					},
				},
			],
		}}
	>
		{(items) => {
			return (
				<DragAndDropContainer id="my-container">
					{items['my-container'].map(({ id, props: { children, ...restProps }, handleProps }) => (
						<DragAndDropItem key={id} id={id} {...restProps} handleProps={handleProps}>
							{children}
						</DragAndDropItem>
					))}
				</DragAndDropContainer>
			);
		}}
	</DragAndDrop>
</div>

Horizontal dragging

The DragAndDrop component supports setting the type prop to column which will lock the drag behavior to the horizontal axis and change the drag icon. The DragAndDropContainer styles will need to be updated to incorporate a preferred column style.

Result
Loading...
Live Editor
<DragAndDrop
	type="column"
	items={{
		'horizontal-container': [
			{
				id: '1',
				props: {
					children: 'Item 1',
				},
			},
			{
				id: '2',
				props: {
					children: `Item 2`,
				},
			},
			{
				id: '3',
				props: {
					children: 'Item 3',
				},
			},
			{
				id: '4',
				props: {
					children: 'Item 4',
				},
			},
		],
	}}
>
	{(items) => {
		return (
			<DragAndDropContainer
				id="horizontal-container"
				style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gridGap: '1rem' }}
			>
				{items['horizontal-container'].map(({ id, props }) => (
					<DragAndDropItem key={id} id={id}>
						{props.children}
					</DragAndDropItem>
				))}
			</DragAndDropContainer>
		);
	}}
</DragAndDrop>

Theme

Setting the theme prop to border displays a border around each DragAndDropItem.

Result
Loading...
Live Editor
<DragAndDrop
	type="list"
	theme="border"
	items={{
		'my-container-theme': [
			{
				id: '1-theme',
				props: {
					children: 'Item 1',
				},
			},
			{
				id: '2-theme',
				props: {
					children: `Item 2`,
				},
			},
			{
				id: '3-theme',
				props: {
					children: 'Item 3',
				},
			},
			{
				id: '4-theme',
				props: {
					children: 'Item 4',
				},
			},
		],
	}}
>
	{(items) => {
		return (
			<DragAndDropContainer id="my-container-theme">
				{items['my-container-theme'].map(({ id, props }) => (
					<DragAndDropItem key={id} id={id}>
						{props.children}
					</DragAndDropItem>
				))}
			</DragAndDropContainer>
		);
	}}
</DragAndDrop>

Multiple DragAndDropContainers

To allow items to move between multiple DragAndDropContainers you will need to:

  • Set the DragAndDrop component's type prop to be canvas.
  • Add two or more DragAndDropContainer components inside a single DragAndDrop component
  • Give each DragAndDropContainer a unique ID that matches the container key used in the data passed to the DragAndDrop component's items prop.
  • The DragAndDrop component's child works as a function that will provide the sorted items data.
  • You can use the unique container id to render the DragAndDropItems into each DragAndDropContainer.
Result
Loading...
Live Editor
<div data-testid="pw-draganddrop-multiple-draganddropcontainers">
	<DragAndDrop
		type="canvas"
		items={{
			'container-a': [
				{
					id: '1-a',
					props: {
						children: 'Item 1 a',
					},
				},
				{
					id: '2-a',
					props: {
						children: 'Item 2 a',
					},
				},
				{
					id: '3-a',
					props: {
						children: 'Item 3 a',
					},
				},
			],
			'container-b': [],
		}}
	>
		{(items) => {
			return (
				<div className="row">
					<DragAndDropContainer id="container-a" className="col-6">
						<section>
							<h1>Container A</h1>
							{items['container-a'].length ? (
								items['container-a'].map(({ id, props }) => (
									<DragAndDropItem key={id} id={id}>
										{props.children}
									</DragAndDropItem>
								))
							) : (
								<p className="text-center p-3" style={{ border: '1px dashed var(--bs-blue-300)' }}>
									Place an item here
								</p>
							)}
						</section>
					</DragAndDropContainer>

					<DragAndDropContainer id="container-b" className="col-6">
						<section>
							<h1>Container B</h1>
							{items['container-b'].length ? (
								items['container-b'].map(({ id, props }) => (
									<DragAndDropItem key={id} id={id}>
										{props.children}
									</DragAndDropItem>
								))
							) : (
								<p className="text-center p-3" style={{ border: '1px dashed var(--bs-blue-300)' }}>
									Place an item here
								</p>
							)}
						</section>
					</DragAndDropContainer>
				</div>
			);
		}}
	</DragAndDrop>
</div>

Controlled state for Inputs inside DragAndDropItems

To use form inputs inside the DragAndDropItems you will need to:

  • Add your items data into state.
  • Pass the items state down to the DragAndDrop component's items prop.
  • Use the DragAndDrop component's onDragEnd prop to set the items back into state when items are sorted.
  • Update the item state using the input's onChange event which will cause a re-render with the new data.
Result
Loading...
Live Editor
function Example() {
	const [items, setItems] = useState({
		'input-container': [
			{
				id: 'input-1',
				props: {
					value: 'Item 1',
				},
			},
			{
				id: 'input-2',
				props: {
					value: 'Item 2',
				},
			},
			{
				id: 'input-3',
				props: {
					value: 'Item 3',
				},
			},
			{
				id: 'input-4',
				props: {
					value: 'Item 4',
				},
			},
		],
	});

	// update the items in state when an input is changed
	const handleChange = (containerId, itemIndex, value) => {
		setItems((previousItems) => {
			// create new objects/arrays so we don't mutate the original
			const newItems = {
				...previousItems,
				[containerId]: [...previousItems[containerId]],
			};

			// update the props for the changed item
			newItems[containerId][itemIndex].props.value = value;

			return newItems;
		});
	};

	// update the items in state whenever they are sorted
	const handleDragEnd = (event, nextItems) => {
		setItems(nextItems);
	};

	return (
		<DragAndDrop onDragEnd={handleDragEnd} type="list" items={items}>
			{(items) => {
				return (
					<DragAndDropContainer id="input-container">
						{items['input-container'].map(({ id, props }, itemIndex) => (
							<DragAndDropItem key={id} id={id}>
								<TextField
									id={`${id}-field`}
									aria-label={`text field ${id}`}
									{...props}
									// pass the container id, item index, and value to the change handler
									onChange={(value) => handleChange('input-container', itemIndex, value)}
								/>
							</DragAndDropItem>
						))}
					</DragAndDropContainer>
				);
			}}
		</DragAndDrop>
	);
}

Removable items

To be able to delete DragAndDropItems you will need to:

  • Add your items data into state.
  • Pass the items state down to the DragAndDrop component's items prop.
  • Use the DragAndDrop component's onDragEnd prop to set the items back into state when items are sorted.
  • When you custom delete button is clicked remove the item from state.
Result
Loading...
Live Editor
function Example() {
	const [items, setItems] = useState({
		'removable-item-container': [
			{
				id: 'removable-1',
				props: {
					children: 'Item 1',
				},
			},
			{
				id: 'removable-2',
				props: {
					children: 'Item 2',
				},
			},
			{
				id: 'removable-3',
				props: {
					children: 'Item 3',
				},
			},
			{
				id: 'removable-4',
				props: {
					children: 'Item 4',
				},
			},
		],
	});

	const handleClick = (containerId, itemIndex) => {
		setItems((previousItems) => {
			// create new object so we don't mutate the original
			const newItems = {
				...previousItems,
			};

			newItems[containerId].splice(itemIndex, 1);

			return newItems;
		});
	};

	const handleDragEnd = (event, nextItems) => {
		setItems(nextItems);
	};

	return (
		<div>
			<style>
				{`.item-content {
          display: flex;
          justify-content: space-between;
          flex: 1;
          align-items: center;
        }

        .delete-button {
          font-size: 20px;
          color: #cccccc !important;
        }

        .delete-button:hover {
          color: #444444 !important;
        }

        .delete-button:active {
          color: #0d6efd !important;
        }
        `}
			</style>

			<DragAndDrop onDragEnd={handleDragEnd} type="list" items={items}>
				{(items) => {
					return (
						<DragAndDropContainer id="removable-item-container">
							{items['removable-item-container'].map(({ id, props }, itemIndex) => (
								<DragAndDropItem key={id} id={id}>
									<div className="item-content">
										<span>{props.children}</span>
										<Button
											id={`${id}-delete-button`}
											variant="link"
											onClick={() => handleClick('removable-item-container', itemIndex)}
											className="delete-button"
											aria-label="delete item"
										>
											<span className="icon-x-circle" />
										</Button>
									</div>
								</DragAndDropItem>
							))}
						</DragAndDropContainer>
					);
				}}
			</DragAndDrop>
		</div>
	);
}

Using ListGroup

It's possible to create a ListGroup styled DragAndDrop using the as props of DragAndDropContainer and DragAndDropItem. Using the ListGroup.Item.Header and ListGroup.Item.Body components to match the visual style of the ListGroup component.

Result
Loading...
Live Editor
function Example() {
	return (
		<div data-testid="pw-draganddrop-listgroup">
			<DragAndDrop
				type="list"
				items={{
					'my-container': [
						{
							id: '1',
							props: {
								title: 'Item 1',
								icon: <span className="icon-chevron-right" />,
								prefix: <Switch aria-label="switch in item" />,
							},
						},
						{
							id: '2',
							props: {
								title: 'Item 2',
								icon: <span className="icon-chevron-right" />,
								prefix: <Switch aria-label="switch in item" />,
								description: 'With Other Content',
							},
						},
						{
							id: '3',
							props: {
								title: 'Item 3',
								icon: <span className="icon-chevron-right" />,
								prefix: <Switch aria-label="switch in item" />,
								description: 'With Other Content',
								footer: 'With Footer Content',
							},
						},
						{
							id: '4',
							props: {
								title: 'Item 4',
								icon: <span className="icon-chevron-right" />,
								prefix: <Switch aria-label="switch in item" />,
								description: 'With Other Content',
								footer: 'With Footer Content',
								alert: (
									<Alert variant="info" size="sm">
										Alert
									</Alert>
								),
							},
						},
						{
							id: '5',
							props: {
								title: 'Item 5',
								icon: <span className="icon-chevron-right" />,
								prefix: <Switch aria-label="switch in item" />,
								description: 'With Other Content',
								footer: 'With Footer Content',
								alert: (
									<Alert variant="info" size="sm">
										Alert
									</Alert>
								),
								actions: (
									<div className="d-flex gap-3">
										<Button size="sm" variant="outline-primary">
											Edit
										</Button>
										<Button size="sm" variant="outline-danger">
											Delete
										</Button>
									</div>
								),
							},
						},
					],
				}}
			>
				{(items) => {
					return (
						<DragAndDropContainer id="my-container" as={ListGroup} collapse={false}>
							{items['my-container'].map(
								({
									id,
									props: { title, icon, description, footer, actions, alert, prefix, ...restProps },
								}) => (
									<DragAndDropItem as={ListGroup.Item} key={id} id={id} {...restProps}>
										<ListGroupItemTemplate
											prefix={prefix}
											header={<ListGroup.Item.Header title={title} icon={icon} />}
											body={
												(description || alert || actions || footer) && (
													<ListGroup.Item.Body
														description={description}
														alert={alert}
														actions={actions}
														footer={footer}
													/>
												)
											}
										/>
									</DragAndDropItem>
								),
							)}
						</DragAndDropContainer>
					);
				}}
			</DragAndDrop>
		</div>
	);
}
render(<Example />);

Best Practices

Accessibility

The DragAndDrop component is designed to be accessible by default. It uses ARIA roles and properties to ensure that screen readers can understand the drag-and-drop functionality. However, here are some best practices to follow:

  • The accessible name of a DragAndDropItem is computed from the contents. This can be overridden by providing aria-label via the handleProps prop of DragAndDropItem.
  • Each DragAndDropItem will have an element with a label of "Draggable handle".
Result
Loading...
Live Editor
<DragAndDrop
	type="list"
	items={{
		'my-container': [
			{
				id: 'ally-1',
				props: {
					children: 'Item 1',
				},
				handleProps: {
					'aria-label': 'The first item',
				},
			},
			{
				id: 'ally-2',
				props: {
					children: 'Item 2',
				},
				handleProps: {
					'aria-label': 'The second item',
				},
			},
		],
	}}
>
	{(items) => {
		return (
			<DragAndDropContainer id="my-container">
				{items['my-container'].map(({ id, props: { children, ...restProps }, handleProps }) => (
					<DragAndDropItem key={id} id={id} handleProps={handleProps} {...restProps}>
						{children}
					</DragAndDropItem>
				))}
			</DragAndDropContainer>
		);
	}}
</DragAndDrop>

Props

DragAndDrop

DragAndDropContainer

DragAndDropItem