-
-
Notifications
You must be signed in to change notification settings - Fork 651
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
382 additions
and
3 deletions.
There are no files selected for viewing
25 changes: 25 additions & 0 deletions
25
apps/stories/stories/react/Sortable/NestedLists/NestedLists.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
225
apps/stories/stories/react/Sortable/NestedLists/NestedLists.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
125
apps/stories/stories/react/Sortable/NestedLists/deepMove.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.