From 10a407d6a9e7fb3eb356644fd08090b2ccf9d96f Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Sun, 10 Nov 2024 17:13:47 +0000 Subject: [PATCH 1/3] docs: add nested list stories --- .../NestedLists/NestedLists.stories.tsx | 25 ++ .../Sortable/NestedLists/NestedLists.tsx | 225 ++++++++++++++++++ .../react/Sortable/NestedLists/deepMove.ts | 125 ++++++++++ .../components/Container/Container.module.css | 7 +- .../react/components/Container/Container.tsx | 3 + 5 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 apps/stories/stories/react/Sortable/NestedLists/NestedLists.stories.tsx create mode 100644 apps/stories/stories/react/Sortable/NestedLists/NestedLists.tsx create mode 100644 apps/stories/stories/react/Sortable/NestedLists/deepMove.ts diff --git a/apps/stories/stories/react/Sortable/NestedLists/NestedLists.stories.tsx b/apps/stories/stories/react/Sortable/NestedLists/NestedLists.stories.tsx new file mode 100644 index 00000000..a630af39 --- /dev/null +++ b/apps/stories/stories/react/Sortable/NestedLists/NestedLists.stories.tsx @@ -0,0 +1,25 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {NestedLists} from './NestedLists.tsx'; + +const meta: Meta = { + title: 'React/Sortable/Nested lists', + component: NestedLists, +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + name: 'Example', + args: { + debug: false, + }, +}; + +export const Debug: Story = { + name: 'Debug', + args: { + debug: true, + }, +}; diff --git a/apps/stories/stories/react/Sortable/NestedLists/NestedLists.tsx b/apps/stories/stories/react/Sortable/NestedLists/NestedLists.tsx new file mode 100644 index 00000000..aab457e5 --- /dev/null +++ b/apps/stories/stories/react/Sortable/NestedLists/NestedLists.tsx @@ -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 = { + A: '#7193f1', + B: '#FF851B', + A1: '#2ECC40', +}; + +const DeepRender = ({content, group}: {content: Node[]; group: string}) => { + return content.map((item, index) => { + if (item.type === 'card') { + return ( + + ); + } + + return ( + + + + ); + }); +}; + +export function NestedLists({debug}: Props) { + const [data, setData] = useState({ + 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 ( + { + 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); + } + }} + > + + + + + ); +} + +function SortableCard({ + id, + group, + index, + style, +}: PropsWithChildren) { + const {ref, isDragSource} = useSortable({ + id, + group, + accept: ['card', 'group'], + type: 'card', + feedback: 'clone', + index, + data: {group}, + }); + + return ( + + {id} + + ); +} + +interface SortableGroupProps { + accentColor?: string; + id: string; + index: number; + group: string; + scrollable?: boolean; + style?: React.CSSProperties; +} + +function SortableGroup({ + accentColor, + children, + id, + index, + group, + style, +}: PropsWithChildren) { + const {handleRef, ref} = useSortable({ + id, + accept: ['group', 'card'], + collisionPriority: CollisionPriority.Low, + type: 'group', + group, + feedback: 'clone', + index, + data: {group}, + }); + + return ( + + + + } + style={style} + > + {children} + + ); +} + +function Root({children}: PropsWithChildren<{}>) { + const {ref} = useDroppable({ + id: 'root', + collisionPriority: CollisionPriority.Low, + type: 'root', + disabled: true, + }); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/stories/stories/react/Sortable/NestedLists/deepMove.ts b/apps/stories/stories/react/Sortable/NestedLists/deepMove.ts new file mode 100644 index 00000000..517b4517 --- /dev/null +++ b/apps/stories/stories/react/Sortable/NestedLists/deepMove.ts @@ -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( + 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; +} diff --git a/apps/stories/stories/react/components/Container/Container.module.css b/apps/stories/stories/react/components/Container/Container.module.css index 4035bc0f..6d11ad37 100644 --- a/apps/stories/stories/react/components/Container/Container.module.css +++ b/apps/stories/stories/react/components/Container/Container.module.css @@ -7,7 +7,7 @@ appearance: none; outline: none; min-width: var(--min-width, 300px); - border-radius: 5px; + border-radius: 6px; min-height: 200px; transition-property: transform, background, box-shadow; transition-duration: 250ms; @@ -33,6 +33,7 @@ .Container > ul { display: grid; + border-top: 1px solid rgba(0, 0, 0, 0.08); gap: 16px; grid-template-columns: repeat(var(--columns, 1), 1fr); list-style: none; @@ -46,6 +47,7 @@ } .Header { + border-left: var(--accent-color) 3px solid; display: flex; min-height: 59px; padding: 8px 20px; @@ -55,12 +57,11 @@ background-color: #fff; border-top-left-radius: 5px; border-top-right-radius: 5px; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); font-family: var(--font-family); } :global(.dark) .Container { - border: none; + border-color: rgba(255, 255, 255, 0.05); background-color: rgb(76, 79, 80); &:hover, &.shadow { diff --git a/apps/stories/stories/react/components/Container/Container.tsx b/apps/stories/stories/react/components/Container/Container.tsx index 95294a63..62e367c7 100644 --- a/apps/stories/stories/react/components/Container/Container.tsx +++ b/apps/stories/stories/react/components/Container/Container.tsx @@ -6,6 +6,7 @@ import styles from './Container.module.css'; export interface Props { children: React.ReactNode; + accentColor?: string; actions?: React.ReactNode; columns?: number; label?: string; @@ -18,6 +19,7 @@ export interface Props { export const Container = forwardRef( ( { + accentColor = 'transparent', actions, children, columns = 1, @@ -39,6 +41,7 @@ export const Container = forwardRef( ...style, viewTransitionName: transitionId, '--columns': columns, + '--accent-color': accentColor, } as React.CSSProperties } className={classNames( From bd0c918e161d2e5f71bfe63ae64a340233eef372 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Sun, 10 Nov 2024 17:17:22 +0000 Subject: [PATCH 2/3] fix: prevent droppable from colliding with own child --- .changeset/dont-collide-with-child.md | 5 +++ .../abstract/src/core/collision/observer.ts | 17 ++++++++-- .../src/core/entities/droppable/droppable.ts | 9 +++++- .../src/core/entities/droppable/droppable.ts | 31 +++++++++++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 .changeset/dont-collide-with-child.md diff --git a/.changeset/dont-collide-with-child.md b/.changeset/dont-collide-with-child.md new file mode 100644 index 00000000..56899cf0 --- /dev/null +++ b/.changeset/dont-collide-with-child.md @@ -0,0 +1,5 @@ +--- +'@dnd-kit/dom': patch +--- + +Track the path of the item to prevent a droppable from colliding with its own child. diff --git a/packages/abstract/src/core/collision/observer.ts b/packages/abstract/src/core/collision/observer.ts index fd3c4a39..c410296d 100644 --- a/packages/abstract/src/core/collision/observer.ts +++ b/packages/abstract/src/core/collision/observer.ts @@ -73,7 +73,7 @@ export class CollisionObserver< return DEFAULT_VALUE; } - const collisions: Collision[] = []; + const collisionMap: Map = new Map(); for (const entry of entries ?? registry.droppables) { if (entry.disabled) { @@ -90,7 +90,6 @@ export class CollisionObserver< continue; } - entry.shape; const collision = untracked(() => detectCollision({ droppable: entry, @@ -103,10 +102,22 @@ export class CollisionObserver< collision.priority = entry.collisionPriority; } - collisions.push(collision); + collisionMap.set(entry, collision); } } + // Filter out collisions of items that contain other items + const collisions = Array.from(collisionMap.entries()) + .filter(([droppable]) => { + if (source && droppable.path.indexOf(source.id) !== -1) { + // Dragged item is parent of collision target. Filter out collision + return false; + } + + return true; + }) + .map(([_, collision]) => collision); + collisions.sort(sortCollisions); return collisions; diff --git a/packages/abstract/src/core/entities/droppable/droppable.ts b/packages/abstract/src/core/entities/droppable/droppable.ts index c9cad0dd..a5883a09 100644 --- a/packages/abstract/src/core/entities/droppable/droppable.ts +++ b/packages/abstract/src/core/entities/droppable/droppable.ts @@ -2,7 +2,12 @@ import {derived, effects, reactive, type Effect} from '@dnd-kit/state'; import type {Shape} from '@dnd-kit/geometry'; import {Entity} from '../entity/index.ts'; -import type {EntityInput, Data, Type} from '../entity/index.ts'; +import type { + EntityInput, + Data, + Type, + UniqueIdentifier, +} from '../entity/index.ts'; import { CollisionPriority, type CollisionDetector, @@ -90,4 +95,6 @@ export class Droppable< public get isDropTarget() { return this.manager?.dragOperation.target?.id === this.id; } + + public path: UniqueIdentifier[] = []; } diff --git a/packages/dom/src/core/entities/droppable/droppable.ts b/packages/dom/src/core/entities/droppable/droppable.ts index 2d68fe16..fd9bec53 100644 --- a/packages/dom/src/core/entities/droppable/droppable.ts +++ b/packages/dom/src/core/entities/droppable/droppable.ts @@ -2,6 +2,7 @@ import {Droppable as AbstractDroppable} from '@dnd-kit/abstract'; import type { Data, DroppableInput as AbstractDroppableInput, + UniqueIdentifier, } from '@dnd-kit/abstract'; import {defaultCollisionDetection} from '@dnd-kit/collision'; import type {CollisionDetector} from '@dnd-kit/collision'; @@ -19,6 +20,32 @@ export interface Input element?: Element; } +function getPathArray( + droppables: DragDropManager['registry']['droppables'], + target: Element +): UniqueIdentifier[] { + // Create a map from element to id for easy lookup + const elementMap = new Map(); + Array.from(droppables.value).forEach((item) => { + if (item?.element) { + elementMap.set(item.element, item.id); + } + }); + + const path: UniqueIdentifier[] = []; + let currentElement = target.parentElement; + + while (currentElement) { + const parentId = elementMap.get(currentElement); + if (parentId) { + path.unshift(parentId); + } + currentElement = currentElement.parentElement; + } + + return path; +} + export class Droppable extends AbstractDroppable< T, DragDropManager @@ -69,6 +96,10 @@ export class Droppable extends AbstractDroppable< !this.disabled && this.accepts(source); + this.path = element + ? getPathArray(manager.registry.droppables, element) + : []; + if (observePosition) { const positionObserver = new PositionObserver( element, From d111206b493b5927390759ac813c12625e60ba04 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Tue, 12 Nov 2024 15:35:29 +0000 Subject: [PATCH 3/3] refactor: introduce droppable parent and make path abstract --- .../src/core/entities/droppable/droppable.ts | 21 +++++- .../src/core/entities/droppable/droppable.ts | 69 ++++++++++--------- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/packages/abstract/src/core/entities/droppable/droppable.ts b/packages/abstract/src/core/entities/droppable/droppable.ts index a5883a09..e2479132 100644 --- a/packages/abstract/src/core/entities/droppable/droppable.ts +++ b/packages/abstract/src/core/entities/droppable/droppable.ts @@ -96,5 +96,24 @@ export class Droppable< return this.manager?.dragOperation.target?.id === this.id; } - public path: UniqueIdentifier[] = []; + @reactive + public accessor parent: UniqueIdentifier | undefined; + + @derived + public get path() { + const path = []; + + if (this.manager && this.parent) { + const {droppables} = this.manager.registry; + + let parent = droppables.get(this.parent); + + while (parent) { + path.unshift(parent.id); + parent = droppables.get(parent.parent); + } + } + + return path; + } } diff --git a/packages/dom/src/core/entities/droppable/droppable.ts b/packages/dom/src/core/entities/droppable/droppable.ts index fd9bec53..d8423a4a 100644 --- a/packages/dom/src/core/entities/droppable/droppable.ts +++ b/packages/dom/src/core/entities/droppable/droppable.ts @@ -6,7 +6,7 @@ import type { } from '@dnd-kit/abstract'; import {defaultCollisionDetection} from '@dnd-kit/collision'; import type {CollisionDetector} from '@dnd-kit/collision'; -import {reactive, untracked} from '@dnd-kit/state'; +import {derived, reactive, untracked} from '@dnd-kit/state'; import type {BoundingRectangle, Shape} from '@dnd-kit/geometry'; import {DOMRectangle, PositionObserver} from '@dnd-kit/dom/utilities'; @@ -20,32 +20,6 @@ export interface Input element?: Element; } -function getPathArray( - droppables: DragDropManager['registry']['droppables'], - target: Element -): UniqueIdentifier[] { - // Create a map from element to id for easy lookup - const elementMap = new Map(); - Array.from(droppables.value).forEach((item) => { - if (item?.element) { - elementMap.set(item.element, item.id); - } - }); - - const path: UniqueIdentifier[] = []; - let currentElement = target.parentElement; - - while (currentElement) { - const parentId = elementMap.get(currentElement); - if (parentId) { - path.unshift(parentId); - } - currentElement = currentElement.parentElement; - } - - return path; -} - export class Droppable extends AbstractDroppable< T, DragDropManager @@ -96,10 +70,6 @@ export class Droppable extends AbstractDroppable< !this.disabled && this.accepts(source); - this.path = element - ? getPathArray(manager.registry.droppables, element) - : []; - if (observePosition) { const positionObserver = new PositionObserver( element, @@ -143,4 +113,41 @@ export class Droppable extends AbstractDroppable< } public refreshShape: () => Shape | undefined; + + @derived + private get elementMap() { + const {manager} = this; + if (!manager) return; + + // Create a map from element to id for easy lookup + const elementMap = new Map(); + Array.from(manager.registry.droppables.value).forEach((item) => { + if (item?.element) { + elementMap.set(item.element, item.id); + } + }); + + return elementMap; + } + + public get parent() { + if (super.parent) { + return super.parent; + } + + const {element} = this; + if (!element || !this.elementMap) return; + + let currentElement = element.parentElement; + + while (currentElement) { + const parentId = this.elementMap.get(currentElement); + + if (parentId) { + return parentId; + } + + currentElement = currentElement.parentElement; + } + } }