Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add nested list stories #1524

Open
wants to merge 3 commits into
base: experimental
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dont-collide-with-child.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@dnd-kit/dom': patch
---

Track the path of the item to prevent a droppable from colliding with its own child.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {Meta, StoryObj} from '@storybook/react';

import {NestedLists} from './NestedLists.tsx';

const meta: Meta<typeof NestedLists> = {
title: 'React/Sortable/Nested lists',
component: NestedLists,
};

export default meta;
type Story = StoryObj<typeof NestedLists>;

export const Example: Story = {
name: 'Example',
args: {
debug: false,
},
};

export const Debug: Story = {
name: 'Debug',
args: {
debug: true,
},
};
225 changes: 225 additions & 0 deletions apps/stories/stories/react/Sortable/NestedLists/NestedLists.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import React, {useRef, useState} from 'react';
import type {PropsWithChildren} from 'react';
import {CollisionPriority} from '@dnd-kit/abstract';
import {DragDropProvider, useDroppable} from '@dnd-kit/react';
import {useSortable} from '@dnd-kit/react/sortable';
import {defaultPreset} from '@dnd-kit/dom';
import {Debug} from '@dnd-kit/dom/plugins/debug';

import {Actions, Container, Item, Handle} from '../../components/index.ts';
import {cloneDeep} from '../../../utilities/cloneDeep.ts';
import {deepMove} from './deepMove.ts';

interface Props {
debug?: boolean;
}

interface Card {
id: string;
type: 'card';
}

export interface Group {
id: string;
type: 'group';
items: Node[];
}

export type Root = {
id: 'root';
type: 'root';
items: Node[];
};

export type Node = Root | Card | Group;

interface SortableCardProps {
id: string;
group: string;
index: number;
style?: React.CSSProperties;
}

const COLORS: Record<string, string> = {
A: '#7193f1',
B: '#FF851B',
A1: '#2ECC40',
};

const DeepRender = ({content, group}: {content: Node[]; group: string}) => {
return content.map((item, index) => {
if (item.type === 'card') {
return (
<SortableCard key={item.id} id={item.id} group={group} index={index} />
);
}

return (
<SortableGroup
accentColor={COLORS[group]}
key={item.id}
id={item.id}
index={index}
group={group}
>
<DeepRender content={item.items} group={item.id} />
</SortableGroup>
);
});
};

export function NestedLists({debug}: Props) {
const [data, setData] = useState<Root>({
id: 'root',
type: 'root',
items: [
{
type: 'group',
id: 'A',
items: [
{
type: 'group',
id: 'A1',
items: [
{type: 'card', id: 'A1.1'},
{type: 'card', id: 'A1.2'},
{type: 'card', id: 'A1.3'},
],
},
{type: 'card', id: 'A2'},
{type: 'card', id: 'A3'},
],
},
{
type: 'group',
id: 'B',
items: [
{type: 'card', id: 'B1'},
{type: 'card', id: 'B2'},
{type: 'card', id: 'B3'},
],
},
],
});

const snapshot = useRef(cloneDeep(data));

return (
<DragDropProvider
plugins={debug ? [...defaultPreset.plugins, Debug] : undefined}
onDragStart={() => {
snapshot.current = cloneDeep(data);
}}
onDragOver={(event) => {
event.preventDefault();

setData((data) => {
return deepMove(data, event.operation) as Root;
});
}}
onDragEnd={(event) => {
if (event.canceled) {
setData(snapshot.current);
}
}}
>
<Root>
<DeepRender content={data.items} group={'root'} />
</Root>
</DragDropProvider>
);
}

function SortableCard({
id,
group,
index,
style,
}: PropsWithChildren<SortableCardProps>) {
const {ref, isDragSource} = useSortable({
id,
group,
accept: ['card', 'group'],
type: 'card',
feedback: 'clone',
index,
data: {group},
});

return (
<Item
ref={ref}
accentColor={COLORS[group]}
shadow={isDragSource}
style={style}
>
{id}
</Item>
);
}

interface SortableGroupProps {
accentColor?: string;
id: string;
index: number;
group: string;
scrollable?: boolean;
style?: React.CSSProperties;
}

function SortableGroup({
accentColor,
children,
id,
index,
group,
style,
}: PropsWithChildren<SortableGroupProps>) {
const {handleRef, ref} = useSortable({
id,
accept: ['group', 'card'],
collisionPriority: CollisionPriority.Low,
type: 'group',
group,
feedback: 'clone',
index,
data: {group},
});

return (
<Container
accentColor={accentColor}
ref={ref}
label={`${id}`}
actions={
<Actions>
<Handle ref={handleRef} />
</Actions>
}
style={style}
>
{children}
</Container>
);
}

function Root({children}: PropsWithChildren<{}>) {
const {ref} = useDroppable({
id: 'root',
collisionPriority: CollisionPriority.Low,
type: 'root',
disabled: true,
});

return (
<div
style={{
display: 'flex',
gap: 20,
}}
ref={ref}
>
{children}
</div>
);
}
125 changes: 125 additions & 0 deletions apps/stories/stories/react/Sortable/NestedLists/deepMove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {DragOperation} from '@dnd-kit/abstract';
import {cloneDeep} from '../../../utilities/cloneDeep.ts';
import {Group, Node, Root} from './NestedLists.tsx';

export function deepMove<T extends Root = Root>(
root: T,
operation: DragOperation
): T {
const {source, target} = operation;
if (!source || !target) return root;

const sourceGroupId = source.data.group;
const targetGroupId = target.data.group;

console.debug(
`Moving ${source.id} from ${sourceGroupId} to ${target.id} in ${targetGroupId}`
);

if (sourceGroupId === targetGroupId && source.id === target.id) {
return root;
}

if (source.id === targetGroupId) {
return root;
}

const clone = cloneDeep(root);

// Helper function to find a group by its ID
function findGroup(node: Node, groupId: string): Root | Group | null {
if (node.type === 'group' || node.type === 'root') {
if (node.id === groupId) {
return node;
}

for (const child of node.items) {
const result = findGroup(child, groupId);
if (result) {
return result;
}
}
}

return null;
}

// Find the source group
const sourceGroup = findGroup(clone, sourceGroupId);
if (!sourceGroup) {
throw new Error(`Source group ${sourceGroupId} not found`);
}

// Find and remove the source item from the source group
const sourceIndex = sourceGroup.items.findIndex(
(item) => item.id === source.id
);
if (sourceIndex === -1) {
throw new Error(
`Source item ${source.id} not found in group ${sourceGroupId}`
);
}
const [sourceItem] = sourceGroup.items.splice(sourceIndex, 1);

if (!targetGroupId) {
if (target.type === 'root') {
clone.items.splice(clone.items.length, 0, sourceItem);
} else if (target.type === 'group') {
const targetGroup = findGroup(clone, target.id as string);

if (!targetGroup) {
throw new Error(`Target group ${target.id} not found`);
}

targetGroup.items.splice(targetGroup.items.length, 0, sourceItem);
}

return clone;
}

// Find the target group
const targetGroup = findGroup(clone, targetGroupId);
if (!targetGroup) {
return root;
}

// Find the index of the target item in the target group
const targetIndex = targetGroup.items.findIndex(
(item) => item.id === target.id
);

if (targetIndex === -1) {
throw new Error(
`Target item ${target.id} not found in group ${targetGroupId}`
);
}

const position = operation.position.current;

let isBelowTarget = false;

// Because of the nested nature of groups and cards, we need to use special positioning logic
if (target.shape) {
if (targetGroupId === 'root') {
if (target.type === 'group') {
isBelowTarget = position.x < target.shape.center.x;
} else {
isBelowTarget = position.x > target.shape.center.x;
}
} else {
if (target.type === 'group') {
isBelowTarget = position.y < target.shape.center.y;
} else {
isBelowTarget = position.y > target.shape.center.y;
}
}
}

targetGroup.items.splice(
isBelowTarget ? targetIndex : targetIndex + 1,
0,
sourceItem
);

return clone;
}
Loading