diff --git a/.changeset/fix-nested-droppable-target-state.md b/.changeset/fix-nested-droppable-target-state.md new file mode 100644 index 000000000..e39d70a2e --- /dev/null +++ b/.changeset/fix-nested-droppable-target-state.md @@ -0,0 +1,5 @@ +--- +'@dnd-kit/dom': patch +--- + +Mark DOM droppable ancestors as drop targets when a nested droppable is the active target. diff --git a/packages/dom/src/core/entities/droppable/droppable.ts b/packages/dom/src/core/entities/droppable/droppable.ts index 450776734..61d1db50d 100644 --- a/packages/dom/src/core/entities/droppable/droppable.ts +++ b/packages/dom/src/core/entities/droppable/droppable.ts @@ -5,7 +5,7 @@ import type { } from '@dnd-kit/abstract'; import {defaultCollisionDetection} from '@dnd-kit/collision'; import type {CollisionDetector} from '@dnd-kit/collision'; -import {reactive, signal, untracked} from '@dnd-kit/state'; +import {derived, reactive, signal, untracked} from '@dnd-kit/state'; import type {BoundingRectangle, Shape} from '@dnd-kit/geometry'; import {DOMRectangle, PositionObserver} from '@dnd-kit/dom/utilities'; @@ -118,5 +118,27 @@ export class Droppable extends AbstractDroppable< return this.proxy ?? this.#element; } + @derived + public override get isDropTarget() { + if (super.isDropTarget) { + return true; + } + + const {element, manager} = this; + const {source, target} = manager?.dragOperation ?? {}; + + if (!element) return false; + if (this.disabled) return false; + if (!source) return false; + if (!this.accepts(source)) return false; + if (!(target instanceof Droppable)) return false; + + const targetElement = target.element; + + if (!targetElement) return false; + + return element !== targetElement && element.contains(targetElement); + } + public refreshShape: () => Shape | undefined; } diff --git a/packages/dom/tests/drop-target-state.test.ts b/packages/dom/tests/drop-target-state.test.ts new file mode 100644 index 000000000..f191c840f --- /dev/null +++ b/packages/dom/tests/drop-target-state.test.ts @@ -0,0 +1,101 @@ +import {describe, expect, it} from 'bun:test'; +import { + CollisionPriority, + CollisionType, + DragDropManager, + Draggable, +} from '@dnd-kit/abstract'; + +import {Droppable} from '../src/core/entities/droppable/droppable.ts'; + +function createElement(contains: (node: Node | null) => boolean = () => false) { + return {contains} as Element; +} + +describe('Drop target state', () => { + it('marks ancestor droppables as drop targets when a descendant is targeted', () => { + const manager = new DragDropManager(); + const source = new Draggable( + {id: 'source', register: false, type: 'item'}, + manager + ); + const childElement = createElement(); + const parentElement = createElement((node) => node === childElement); + const siblingElement = createElement(); + const parent = new Droppable( + { + id: 'parent', + accept: 'item', + element: parentElement, + register: false, + }, + manager + ); + const child = new Droppable( + {id: 'child', accept: 'item', element: childElement, register: false}, + manager + ); + const sibling = new Droppable( + {id: 'sibling', element: siblingElement, register: false}, + manager + ); + const rejectedParent = new Droppable( + { + id: 'rejected-parent', + accept: 'other', + element: parentElement, + register: false, + }, + manager + ); + const disabledParent = new Droppable( + { + id: 'disabled-parent', + disabled: true, + element: parentElement, + register: false, + }, + manager + ); + + source.register(); + parent.register(); + child.register(); + sibling.register(); + rejectedParent.register(); + disabledParent.register(); + + manager.dragOperation.sourceIdentifier = 'source'; + manager.dragOperation.targetIdentifier = 'child'; + Object.defineProperty(manager.collisionObserver, 'collisions', { + configurable: true, + value: [ + { + id: 'sibling', + priority: CollisionPriority.Normal, + type: CollisionType.PointerIntersection, + value: 1, + }, + ], + }); + + expect(child.isDropTarget).toBe(true); + expect(parent.isDropTarget).toBe(true); + expect(sibling.isDropTarget).toBe(false); + expect(rejectedParent.isDropTarget).toBe(false); + expect(disabledParent.isDropTarget).toBe(false); + + manager.dragOperation.targetIdentifier = null; + + expect(child.isDropTarget).toBe(false); + expect(parent.isDropTarget).toBe(false); + + source.destroy(); + parent.destroy(); + child.destroy(); + sibling.destroy(); + rejectedParent.destroy(); + disabledParent.destroy(); + manager.destroy(); + }); +});