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 />);

Custom handle position and includeContainerClass prop​

By default the drag handle is a small area on the left of the item. You can change the position of the handle by setting the handlePosition prop to top, center, or bottom. The container will also apply its own styling that can be removed by setting the includeContainerClass prop to false.

Result
Loading...
Live Editor
<DragAndDrop
	type="list"
	theme="borderless"
	handlePosition="top"
	includeContainerClass={false}
	items={{
		'my-container-theme': [
			{
				id: '1-theme',
				props: {
					children: (
						<ListGroup className="w-100" collapse={false}>
							<ListGroup.Item>
								<Checkbox label="Item 1" />
							</ListGroup.Item>
						</ListGroup>
					),
				},
			},
			{
				id: '2-theme',
				props: {
					children: (
						<ListGroup className="w-100" collapse={false}>
							<ListGroup.Item>
								<Checkbox label="Item 2" />
							</ListGroup.Item>
							<ListGroup.Item className="k-ms-4">
								<Checkbox label="Item 2a" />
							</ListGroup.Item>
							<ListGroup.Item className="k-ms-4">
								<div
									className="d-grid align-items-center flex-wrap"
									style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}
								>
									<div>
										<Checkbox label="Item 2b" />
									</div>
									<div>
										<Select defaultSelectedKey="three">
											<Item key="one">One</Item>
											<Item key="two">Two</Item>
											<Item key="three">Three</Item>
											<Item key="four">Four</Item>
										</Select>
									</div>
								</div>
								<div
									className="d-grid align-items-center flex-wrap"
									style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}
								>
									<div></div>
									<div className="k-mt-1">
										<Switch label="Expanded" />
									</div>
								</div>
							</ListGroup.Item>
							<ListGroup.Item className="k-ms-4">
								<Checkbox label="Item 2c" />
							</ListGroup.Item>
						</ListGroup>
					),
				},
			},
			{
				id: '3-theme',
				props: {
					children: (
						<ListGroup className="w-100" collapse={false}>
							<ListGroup.Item>
								<Checkbox label="Item 2" />
							</ListGroup.Item>
							<ListGroup.Item className="k-ms-4">
								<Checkbox label="Item 2a" />
							</ListGroup.Item>
							<ListGroup.Item className="k-ms-4">
								<Checkbox label="Item 2b" />
							</ListGroup.Item>
							<ListGroup.Item className="k-ms-4">
								<Checkbox label="Item 2c" />
							</ListGroup.Item>
						</ListGroup>
					),
				},
			},
		],
	}}
>
	{(items) => {
		return (
			<DragAndDropContainer id="my-container-theme">
				{items['my-container-theme'].map(({ id, props }) => (
					<DragAndDropItem
						key={id}
						id={id}
						renderIcon={() => <KyberIcons.IconDotsGrabber />}
						className="border-top border-1"
						{...props}
					>
						{props.children}
					</DragAndDropItem>
				))}
			</DragAndDropContainer>
		);
	}}
</DragAndDrop>

Table rows​

Set type="table" to sort table rows. Only table elements are rendered in this mode, so the markup stays valid:

  • Render the DragAndDropContainer as the table body with as={TableBody} (or as="tbody").
  • Each DragAndDropItem renders as a tr (pass as={TableRow} to use the Kyber TableRow). Its children should be table cells.
  • The drag handle is rendered in its own cell at the start of each row, so add an empty header cell for it.
  • While dragging, a copy of the row is shown in an overlay that keeps the original column widths.
  • A disabled row cannot be dragged and nothing inside it can be interacted with.
  • Use handleProps to give each handle a label that describes the row, and provide announcements so screen reader users hear row names instead of ids. See Screen reader announcements.

stacked tables are not supported. When using fixedColumns, the handle cell counts as the first column.

Result
Loading...
Live Editor
function Example() {
	const [items, setItems] = useState({
		'funding-sources': [
			{
				id: 'fs-1',
				props: {
					account: 'Frank 401(k)',
					assetType: 'Qualified Retirement',
					available: '$25,000',
					max: 'No Max',
				},
			},
			{
				id: 'fs-2',
				props: {
					account: 'Joanna Roth IRA',
					assetType: 'Roth IRA',
					available: '$15,000',
					max: 'No Max',
				},
			},
			{
				id: 'fs-3',
				props: {
					account: 'Michael 401(k)',
					assetType: 'Traditional 401(k)',
					available: '$20,000',
					max: '$58,000',
				},
			},
			{
				id: 'fs-4',
				props: {
					account: 'Angela SEP IRA',
					assetType: 'SEP IRA',
					available: '$10,000',
					max: 'No Max',
					disabled: true,
				},
			},
			{
				id: 'fs-5',
				props: { account: 'David Lee HSA', assetType: 'HSA', available: '$7,000', max: 'No Max' },
			},
		],
	});

	const rows = items['funding-sources'];
	const getAccount = (id) => rows.find((row) => row.id === id)?.props.account;
	const getPosition = (id) => rows.findIndex((row) => row.id === id) + 1;

	const announcements = {
		onDragStart: (id) =>
			`Picked up ${getAccount(id)}, position ${getPosition(id)} of ${rows.length}.`,
		onDragOver: (id, overId) =>
			overId
				? `${getAccount(id)} moved to position ${getPosition(overId)} of ${rows.length}.`
				: undefined,
		onDragEnd: (id, overId) =>
			overId
				? `${getAccount(id)} dropped at position ${getPosition(overId)} of ${rows.length}.`
				: `${getAccount(id)} dropped.`,
		onDragCancel: (id) =>
			`Moving was cancelled. ${getAccount(id)} returned to position ${getPosition(id)}.`,
	};

	const handleRemove = (id) => {
		setItems((previousItems) => ({
			'funding-sources': previousItems['funding-sources'].filter((row) => row.id !== id),
		}));
	};

	return (
		<div data-testid="pw-draganddrop-table">
			<DragAndDrop
				type="table"
				items={items}
				onDragEnd={(event, nextItems) => setItems(nextItems)}
				announcements={announcements}
			>
				{(sortedItems) => (
					<Table size="sm">
						<TableHeader>
							<TableRow>
								<TableCell>
									<span className="visually-hidden">Reorder</span>
								</TableCell>
								<TableCell>Account</TableCell>
								<TableCell>Asset Type</TableCell>
								<TableCell align="right">Available</TableCell>
								<TableCell align="right">Max Amount</TableCell>
								<TableCell>
									<span className="visually-hidden">Actions</span>
								</TableCell>
							</TableRow>
						</TableHeader>
						<DragAndDropContainer id="funding-sources" as={TableBody}>
							{sortedItems['funding-sources'].map(({ id, props }) => (
								<DragAndDropItem
									key={id}
									id={id}
									as={TableRow}
									disabled={props.disabled}
									renderIcon={() => <KyberIcons.IconDotsGrabber />}
									handleProps={{ 'aria-label': `Reorder ${props.account}` }}
								>
									<TableCell>{props.account}</TableCell>
									<TableCell>{props.assetType}</TableCell>
									<TableCell align="right">{props.available}</TableCell>
									<TableCell align="right">{props.max}</TableCell>
									<TableCell className="text-nowrap">
										<Button variant="link" aria-label={`Edit ${props.account}`} className="py-0">
											<KyberIcons.IconEdit05 />
										</Button>
										<Button
											variant="link"
											aria-label={`Remove ${props.account}`}
											onClick={() => handleRemove(id)}
											className="py-0"
										>
											<KyberIcons.IconTrash01 />
										</Button>
									</TableCell>
								</DragAndDropItem>
							))}
						</DragAndDropContainer>
					</Table>
				)}
			</DragAndDrop>
		</div>
	);
}

Best Practices​

Accessibility​

The DragAndDrop component uses ARIA roles and properties so that screen readers can understand the drag-and-drop functionality, and items can be sorted with the keyboard (space bar to pick up and drop, arrow keys to move, escape to cancel). 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".
  • Provide announcements that use readable item names. See Screen reader announcements below.
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>

Screen reader announcements​

While an item is being dragged, its progress is announced to screen reader users through a live region. By default these announcements refer to items by their id, for example "Picked up draggable item ally-1" and "Draggable item ally-1 was dropped over droppable area ally-2". Ids are rarely meaningful to users, so in every type you should pass the announcements prop to DragAndDrop with messages that use readable names and positions.

announcements is an object of functions that each receive the dragged item's id, and where relevant the id of the item it is over, and return the message to announce:

  • onDragStart(id)
  • onDragOver(id, overId)
  • onDragEnd(id, overId)
  • onDragCancel(id)

The instructions read when a handle receives focus can also be replaced with the screenReaderInstructions prop, for example to translate them: { draggable: 'To pick up an item, press the space bar…' }.

The Table rows example also uses announcements.

Result
Loading...
Live Editor
function Example() {
	const [items, setItems] = useState({
		'announcements-container': [
			{ id: 'announce-1', props: { children: 'Groceries' } },
			{ id: 'announce-2', props: { children: 'Rent' } },
			{ id: 'announce-3', props: { children: 'Utilities' } },
		],
	});

	const list = items['announcements-container'];
	const getName = (id) => list.find((item) => item.id === id)?.props.children;
	const getPosition = (id) => list.findIndex((item) => item.id === id) + 1;

	const announcements = {
		onDragStart: (id) => `Picked up ${getName(id)}, position ${getPosition(id)} of ${list.length}.`,
		onDragOver: (id, overId) =>
			overId
				? `${getName(id)} moved to position ${getPosition(overId)} of ${list.length}.`
				: undefined,
		onDragEnd: (id, overId) =>
			overId
				? `${getName(id)} dropped at position ${getPosition(overId)} of ${list.length}.`
				: `${getName(id)} dropped.`,
		onDragCancel: (id) =>
			`Moving was cancelled. ${getName(id)} returned to position ${getPosition(id)}.`,
	};

	return (
		<DragAndDrop
			type="list"
			items={items}
			onDragEnd={(event, nextItems) => setItems(nextItems)}
			announcements={announcements}
		>
			{(sortedItems) => (
				<DragAndDropContainer id="announcements-container">
					{sortedItems['announcements-container'].map(({ id, props }) => (
						<DragAndDropItem
							key={id}
							id={id}
							handleProps={{ 'aria-label': `Reorder ${props.children}` }}
						>
							{props.children}
						</DragAndDropItem>
					))}
				</DragAndDropContainer>
			)}
		</DragAndDrop>
	);
}

Props​

DragAndDrop​

DragAndDropContainer​

DragAndDropItem​