Skip to content

Commit

Permalink
docs: add nested list stories
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisvxd committed Nov 11, 2024
1 parent 4b97ec4 commit 10a407d
Show file tree
Hide file tree
Showing 5 changed files with 382 additions and 3 deletions.
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

0 comments on commit 10a407d

Please sign in to comment.