Skip to content

Commit dc9c4cf

Browse files
committed
docs: add nested list stories
1 parent 4b97ec4 commit dc9c4cf

File tree

5 files changed

+382
-3
lines changed

5 files changed

+382
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type {Meta, StoryObj} from '@storybook/react';
2+
3+
import {NestedLists} from './NestedLists.tsx';
4+
5+
const meta: Meta<typeof NestedLists> = {
6+
title: 'React/Sortable/Nested lists',
7+
component: NestedLists,
8+
};
9+
10+
export default meta;
11+
type Story = StoryObj<typeof NestedLists>;
12+
13+
export const Example: Story = {
14+
name: 'Example',
15+
args: {
16+
debug: false,
17+
},
18+
};
19+
20+
export const Debug: Story = {
21+
name: 'Debug',
22+
args: {
23+
debug: true,
24+
},
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import React, {useRef, useState} from 'react';
2+
import type {PropsWithChildren} from 'react';
3+
import {CollisionPriority} from '@dnd-kit/abstract';
4+
import {DragDropProvider, useDroppable} from '@dnd-kit/react';
5+
import {useSortable} from '@dnd-kit/react/sortable';
6+
import {defaultPreset} from '@dnd-kit/dom';
7+
import {Debug} from '@dnd-kit/dom/plugins/debug';
8+
9+
import {Actions, Container, Item, Handle} from '../../components/index.ts';
10+
import {cloneDeep} from '../../../utilities/cloneDeep.ts';
11+
import {deepMove} from './deepMove.ts';
12+
13+
interface Props {
14+
debug?: boolean;
15+
}
16+
17+
interface Card {
18+
id: string;
19+
type: 'card';
20+
}
21+
22+
export interface Group {
23+
id: string;
24+
type: 'group';
25+
items: Node[];
26+
}
27+
28+
export type Root = {
29+
id: 'root';
30+
type: 'root';
31+
items: Node[];
32+
};
33+
34+
export type Node = Root | Card | Group;
35+
36+
interface SortableCardProps {
37+
id: string;
38+
group: string;
39+
index: number;
40+
style?: React.CSSProperties;
41+
}
42+
43+
const COLORS: Record<string, string> = {
44+
A: '#7193f1',
45+
B: '#FF851B',
46+
A1: '#2ECC40',
47+
};
48+
49+
const DeepRender = ({content, group}: {content: Node[]; group: string}) => {
50+
return content.map((item, index) => {
51+
if (item.type === 'card') {
52+
return (
53+
<SortableCard key={item.id} id={item.id} group={group} index={index} />
54+
);
55+
}
56+
57+
return (
58+
<SortableGroup
59+
accentColor={COLORS[group]}
60+
key={item.id}
61+
id={item.id}
62+
index={index}
63+
group={group}
64+
>
65+
<DeepRender content={item.items} group={item.id} />
66+
</SortableGroup>
67+
);
68+
});
69+
};
70+
71+
export function NestedLists({debug}: Props) {
72+
const [data, setData] = useState<Root>({
73+
id: 'root',
74+
type: 'root',
75+
items: [
76+
{
77+
type: 'group',
78+
id: 'A',
79+
items: [
80+
{
81+
type: 'group',
82+
id: 'A1',
83+
items: [
84+
{type: 'card', id: 'A1.1'},
85+
{type: 'card', id: 'A1.2'},
86+
{type: 'card', id: 'A1.3'},
87+
],
88+
},
89+
{type: 'card', id: 'A2'},
90+
{type: 'card', id: 'A3'},
91+
],
92+
},
93+
{
94+
type: 'group',
95+
id: 'B',
96+
items: [
97+
{type: 'card', id: 'B1'},
98+
{type: 'card', id: 'B2'},
99+
{type: 'card', id: 'B3'},
100+
],
101+
},
102+
],
103+
});
104+
105+
const snapshot = useRef(cloneDeep(data));
106+
107+
return (
108+
<DragDropProvider
109+
plugins={debug ? [...defaultPreset.plugins, Debug] : undefined}
110+
onDragStart={() => {
111+
snapshot.current = cloneDeep(data);
112+
}}
113+
onDragOver={(event) => {
114+
event.preventDefault();
115+
116+
setData((data) => {
117+
return deepMove(data, event.operation) as Root;
118+
});
119+
}}
120+
onDragEnd={(event) => {
121+
if (event.canceled) {
122+
setData(snapshot.current);
123+
}
124+
}}
125+
>
126+
<Root>
127+
<DeepRender content={data.items} group={'root'} />
128+
</Root>
129+
</DragDropProvider>
130+
);
131+
}
132+
133+
function SortableCard({
134+
id,
135+
group,
136+
index,
137+
style,
138+
}: PropsWithChildren<SortableCardProps>) {
139+
const {ref, isDragSource} = useSortable({
140+
id,
141+
group,
142+
accept: ['card', 'group'],
143+
type: 'card',
144+
feedback: 'clone',
145+
index,
146+
data: {group},
147+
});
148+
149+
return (
150+
<Item
151+
ref={ref}
152+
accentColor={COLORS[group]}
153+
shadow={isDragSource}
154+
style={style}
155+
>
156+
{id}
157+
</Item>
158+
);
159+
}
160+
161+
interface SortableGroupProps {
162+
accentColor?: string;
163+
id: string;
164+
index: number;
165+
group: string;
166+
scrollable?: boolean;
167+
style?: React.CSSProperties;
168+
}
169+
170+
function SortableGroup({
171+
accentColor,
172+
children,
173+
id,
174+
index,
175+
group,
176+
style,
177+
}: PropsWithChildren<SortableGroupProps>) {
178+
const {handleRef, ref} = useSortable({
179+
id,
180+
accept: ['group', 'card'],
181+
collisionPriority: CollisionPriority.Low,
182+
type: 'group',
183+
group,
184+
feedback: 'clone',
185+
index,
186+
data: {group},
187+
});
188+
189+
return (
190+
<Container
191+
accentColor={accentColor}
192+
ref={ref}
193+
label={`${id}`}
194+
actions={
195+
<Actions>
196+
<Handle ref={handleRef} />
197+
</Actions>
198+
}
199+
style={style}
200+
>
201+
{children}
202+
</Container>
203+
);
204+
}
205+
206+
function Root({children}: PropsWithChildren<{}>) {
207+
const {ref} = useDroppable({
208+
id: 'root',
209+
collisionPriority: CollisionPriority.Low,
210+
type: 'root',
211+
disabled: true,
212+
});
213+
214+
return (
215+
<div
216+
style={{
217+
display: 'flex',
218+
gap: 20,
219+
}}
220+
ref={ref}
221+
>
222+
{children}
223+
</div>
224+
);
225+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {DragOperation} from '@dnd-kit/abstract';
2+
import {cloneDeep} from '../../../utilities/cloneDeep.ts';
3+
import {Group, Node, Root} from './NestedLists.tsx';
4+
5+
export function deepMove<T extends Root = Root>(
6+
root: T,
7+
operation: DragOperation
8+
): T {
9+
const {source, target} = operation;
10+
if (!source || !target) return root;
11+
12+
const sourceGroupId = source.data.group;
13+
const targetGroupId = target.data.group;
14+
15+
console.debug(
16+
`Moving ${source.id} from ${sourceGroupId} to ${target.id} in ${targetGroupId} (${target.path})`
17+
);
18+
19+
if (sourceGroupId === targetGroupId && source.id === target.id) {
20+
return root;
21+
}
22+
23+
if (source.id === targetGroupId) {
24+
return root;
25+
}
26+
27+
const clone = cloneDeep(root);
28+
29+
// Helper function to find a group by its ID
30+
function findGroup(node: Node, groupId: string): Root | Group | null {
31+
if (node.type === 'group' || node.type === 'root') {
32+
if (node.id === groupId) {
33+
return node;
34+
}
35+
36+
for (const child of node.items) {
37+
const result = findGroup(child, groupId);
38+
if (result) {
39+
return result;
40+
}
41+
}
42+
}
43+
44+
return null;
45+
}
46+
47+
// Find the source group
48+
const sourceGroup = findGroup(clone, sourceGroupId);
49+
if (!sourceGroup) {
50+
throw new Error(`Source group ${sourceGroupId} not found`);
51+
}
52+
53+
// Find and remove the source item from the source group
54+
const sourceIndex = sourceGroup.items.findIndex(
55+
(item) => item.id === source.id
56+
);
57+
if (sourceIndex === -1) {
58+
throw new Error(
59+
`Source item ${source.id} not found in group ${sourceGroupId}`
60+
);
61+
}
62+
const [sourceItem] = sourceGroup.items.splice(sourceIndex, 1);
63+
64+
if (!targetGroupId) {
65+
if (target.type === 'root') {
66+
clone.items.splice(clone.items.length, 0, sourceItem);
67+
} else if (target.type === 'group') {
68+
const targetGroup = findGroup(clone, target.id as string);
69+
70+
if (!targetGroup) {
71+
throw new Error(`Target group ${target.id} not found`);
72+
}
73+
74+
targetGroup.items.splice(targetGroup.items.length, 0, sourceItem);
75+
}
76+
77+
return clone;
78+
}
79+
80+
// Find the target group
81+
const targetGroup = findGroup(clone, targetGroupId);
82+
if (!targetGroup) {
83+
return root;
84+
}
85+
86+
// Find the index of the target item in the target group
87+
const targetIndex = targetGroup.items.findIndex(
88+
(item) => item.id === target.id
89+
);
90+
91+
if (targetIndex === -1) {
92+
throw new Error(
93+
`Target item ${target.id} not found in group ${targetGroupId}`
94+
);
95+
}
96+
97+
const position = operation.position.current;
98+
99+
let isBelowTarget = false;
100+
101+
// Because of the nested nature of groups and cards, we need to use special positioning logic
102+
if (target.shape) {
103+
if (targetGroupId === 'root') {
104+
if (target.type === 'group') {
105+
isBelowTarget = position.x < target.shape.center.x;
106+
} else {
107+
isBelowTarget = position.x > target.shape.center.x;
108+
}
109+
} else {
110+
if (target.type === 'group') {
111+
isBelowTarget = position.y < target.shape.center.y;
112+
} else {
113+
isBelowTarget = position.y > target.shape.center.y;
114+
}
115+
}
116+
}
117+
118+
targetGroup.items.splice(
119+
isBelowTarget ? targetIndex : targetIndex + 1,
120+
0,
121+
sourceItem
122+
);
123+
124+
return clone;
125+
}

0 commit comments

Comments
 (0)