From 545353622ac06e9b4aa89530ee4cea431392db20 Mon Sep 17 00:00:00 2001 From: aparzi Date: Sun, 15 Mar 2026 23:46:10 +0100 Subject: [PATCH] fix(cdk/drag-drop): Support for nested drop containers Fixes a two issues: - The first fixed issue is that the elements of cdkDropListConnectedTo previously had to be ordered correctly to support moving items into an inner drop container. Sepcifically, inner drop containers had to be listed before their outer ancestors. - The second fixed issue is that items of an inner container could not be re-ordered without moving the item out of the container and then back in. Fixes #16671 --- src/cdk/drag-drop/directives/drop-list.ts | 4 + src/cdk/drag-drop/drop-list-ref.ts | 109 +++++++++++++++++++++- src/dev-app/drag-drop/drag-drop-demo.html | 83 ++++++++++++---- src/dev-app/drag-drop/drag-drop-demo.ts | 45 ++++++++- 4 files changed, 218 insertions(+), 23 deletions(-) diff --git a/src/cdk/drag-drop/directives/drop-list.ts b/src/cdk/drag-drop/directives/drop-list.ts index 8ff7eb3654fb..aeb0b42e7c3e 100644 --- a/src/cdk/drag-drop/directives/drop-list.ts +++ b/src/cdk/drag-drop/directives/drop-list.ts @@ -363,6 +363,10 @@ export class CdkDropList implements OnDestroy { /** Handles events from the underlying DropListRef. */ private _handleEvents(ref: DropListRef) { + merge(ref.receivingStarted, ref.receivingStopped) + .pipe(takeUntil(this._destroyed)) + .subscribe(() => this._changeDetectorRef.markForCheck()); + ref.beforeStarted.subscribe(() => { this._syncItemsWithRef(this.getSortedItems().map(item => item._dragRef)); this._changeDetectorRef.markForCheck(); diff --git a/src/cdk/drag-drop/drop-list-ref.ts b/src/cdk/drag-drop/drop-list-ref.ts index 000b3c188691..0b0bd16cf0e0 100644 --- a/src/cdk/drag-drop/drop-list-ref.ts +++ b/src/cdk/drag-drop/drop-list-ref.ts @@ -211,6 +211,12 @@ export class DropListRef { /** Direction of the list's layout. */ private _direction: Direction = 'ltr'; + /** + * Cache of all connected lists (including self) ordered by DOM hierarchy — outer containers + * first, inner containers last. Invalidated whenever siblings change. + */ + private _cachedSortedSiblings: DropListRef[] | null = null; + constructor( element: ElementRef | HTMLElement, private _dragDropRegistry: DragDropRegistry, @@ -240,6 +246,7 @@ export class DropListRef { this._activeSiblings.clear(); this._scrollNode = null!; this._parentPositions.clear(); + this._cachedSortedSiblings = null; this._dragDropRegistry.removeDropContainer(this); } @@ -368,6 +375,8 @@ export class DropListRef { */ connectedTo(connectedTo: DropListRef[]): this { this._siblings = connectedTo.slice(); + // Invalidate the hierarchy cache since siblings have changed. + this._cachedSortedSiblings = null; return this; } @@ -443,6 +452,8 @@ export class DropListRef { } this._cachedShadowRoot = null; + // Invalidate the hierarchy cache since the DOM structure may have changed. + this._cachedSortedSiblings = null; this._scrollableElements.unshift(container); this._container = container; return this; @@ -687,7 +698,101 @@ export class DropListRef { * @param y Position of the item along the Y axis. */ _getSiblingContainerFromPosition(item: DragRef, x: number, y: number): DropListRef | undefined { - return this._siblings.find(sibling => sibling._canReceive(item, x, y)); + // Possible targets include siblings and 'this'. + const targets = [this, ...this._siblings]; + + // Only consider targets where the drag position is within the client rect + // (this avoids calling enterPredicate on each possible target). + const matchingTargets = targets.filter( + ref => ref._domRect && isInsideClientRect(ref._domRect, x, y), + ); + + // Stop if no targets match the coordinates. + if (matchingTargets.length === 0) { + return undefined; + } + + // Use the cached hierarchy order, computing it only when siblings have changed. + // This avoids rebuilding the DOM tree on every pointermove event. + if (!this._cachedSortedSiblings) { + this._cachedSortedSiblings = this._orderByHierarchy([this, ...this._siblings]); + } + + // Filter the pre-ordered list to retain only the matching targets, + // preserving the hierarchy order without re-sorting. + const orderedMatchingTargets = this._cachedSortedSiblings.filter(ref => + matchingTargets.includes(ref), + ); + + // The drop target is the last matching target in the ordered list, + // i.e. the innermost container in the DOM hierarchy. + const matchingTarget = orderedMatchingTargets[orderedMatchingTargets.length - 1]; + + // Only return the matching target if it is a sibling, not 'this'. + if (matchingTarget === this) { + return undefined; + } + + // Can the matching target receive the item? + if (!matchingTarget._canReceive(item, x, y)) { + return undefined; + } + + return matchingTarget; + } + + /** + * Sorts a list of DropListRefs such that for every nested pair of drop containers, + * the outer drop container appears before the inner drop container. + * @param refs List of DropListRefs to sort. + */ + private _orderByHierarchy(refs: DropListRef[]): DropListRef[] { + // Build a map from HTMLElement to DropListRef for fast ancestor lookup. + const refsByElement = new Map(); + refs.forEach(ref => refsByElement.set(ref._container, ref)); + + // Finds the closest ancestor DropListRef of a given ref, if any. + const findAncestor = (ref: DropListRef): DropListRef | undefined => { + let ancestor = ref._container.parentElement; + while (ancestor) { + if (refsByElement.has(ancestor)) { + return refsByElement.get(ancestor); + } + ancestor = ancestor.parentElement; + } + return undefined; + }; + + // Node type for the tree structure. + type NodeType = {ref: DropListRef; parent?: NodeType; children: NodeType[]}; + + // Create a tree node for each ref. + const tree = new Map(); + refs.forEach(ref => tree.set(ref, {ref, children: []})); + + // Build parent-child relationships. + refs.forEach(ref => { + const parent = findAncestor(ref); + if (parent) { + const node = tree.get(ref)!; + const parentNode = tree.get(parent)!; + node.parent = parentNode; + parentNode.children.push(node); + } + }); + + // Find root nodes (those without a parent among the refs). + const roots = Array.from(tree.values()).filter(node => !node.parent); + + // Recursively build the ordered list: parent before children. + const buildOrderedList = (nodes: NodeType[], list: DropListRef[]) => { + list.push(...nodes.map(node => node.ref)); + nodes.forEach(node => buildOrderedList(node.children, list)); + }; + + const ordered: DropListRef[] = []; + buildOrderedList(roots, ordered); + return ordered; } /** @@ -863,7 +968,7 @@ function getElementScrollDirections( // Note that we here we do some extra checks for whether the element is actually scrollable in // a certain direction and we only assign the scroll direction if it is. We do this so that we - // can allow other elements to be scrolled, if the current element can't be scrolled anymore. + // can allow other elements to be scrolled, if the current element can't be scrollated anymore. // This allows us to handle cases where the scroll regions of two scrollable elements overlap. if (computedVertical) { const scrollTop = element.scrollTop; diff --git a/src/dev-app/drag-drop/drag-drop-demo.html b/src/dev-app/drag-drop/drag-drop-demo.html index 104101ae2496..ee3488b48664 100644 --- a/src/dev-app/drag-drop/drag-drop-demo.html +++ b/src/dev-app/drag-drop/drag-drop-demo.html @@ -1,3 +1,44 @@ +
+
+

To do

+ +
+ @for (item of todo2; track item) { +
+ @if (isArray(item)) { +
+
+ @for (innerItem of item; track innerItem) { +
+ {{innerItem}} + +
+ } +
+
+ } @else { +
+ {{item}} + +
+ } +
+ } +
+
+
+

To do

@@ -5,7 +46,8 @@

To do

cdkDropList (cdkDropListDropped)="drop($event)" [cdkDropListLockAxis]="axisLock" - [cdkDropListData]="todo"> + [cdkDropListData]="todo" + > @for (item of todo; track item) {
{{item}} @@ -21,10 +63,11 @@

Done

cdkDropList (cdkDropListDropped)="drop($event)" [cdkDropListLockAxis]="axisLock" - [cdkDropListData]="done"> + [cdkDropListData]="done" + > @for (item of done; track item) {
- {{item}} + {{ item }}
} @@ -40,10 +83,11 @@

Ages

cdkDropListOrientation="horizontal" (cdkDropListDropped)="drop($event)" [cdkDropListLockAxis]="axisLock" - [cdkDropListData]="ages"> + [cdkDropListData]="ages" + > @for (item of ages; track item) {
- {{item}} + {{ item }}
} @@ -57,10 +101,11 @@

Preferred Ages

cdkDropListOrientation="horizontal" (cdkDropListDropped)="drop($event)" [cdkDropListLockAxis]="axisLock" - [cdkDropListData]="preferredAges"> + [cdkDropListData]="preferredAges" + > @for (item of preferredAges; track item) {
- {{item}} + {{ item }}
} @@ -78,16 +123,18 @@

Mixed orientation

+ [class.demo-list-horizontal]="mixedWrap" + >
+ [cdkDropListData]="mixedTodo" + > @for (item of mixedTodo; track item) {
- {{item}} + {{ item }}
} @@ -97,16 +144,18 @@

Mixed orientation

+ [class.demo-list-horizontal]="mixedWrap" + >
+ [cdkDropListData]="mixedDone" + > @for (item of mixedDone; track item) {
- {{item}} + {{ item }}
} @@ -159,10 +208,10 @@

Drag with box boundary and custom constrain

Data

-
{{todo.join(', ')}}
-
{{done.join(', ')}}
-
{{ages.join(', ')}}
-
{{preferredAges.join(', ')}}
+
{{ todo.join(', ') }}
+
{{ done.join(', ') }}
+
{{ ages.join(', ') }}
+
{{ preferredAges.join(', ') }}
diff --git a/src/dev-app/drag-drop/drag-drop-demo.ts b/src/dev-app/drag-drop/drag-drop-demo.ts index d4bd63ea6601..421eedc19e64 100644 --- a/src/dev-app/drag-drop/drag-drop-demo.ts +++ b/src/dev-app/drag-drop/drag-drop-demo.ts @@ -6,16 +6,24 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, ViewEncapsulation, ChangeDetectionStrategy, inject} from '@angular/core'; +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + inject, + QueryList, + AfterViewInit, + ViewChildren, +} from '@angular/core'; import {MatIconModule, MatIconRegistry} from '@angular/material/icon'; import {DomSanitizer} from '@angular/platform-browser'; import { - CdkDragDrop, DragDropModule, moveItemInArray, transferArrayItem, Point, DragRef, + CdkDropList, } from '@angular/cdk/drag-drop'; import {FormsModule} from '@angular/forms'; import {MatFormFieldModule} from '@angular/material/form-field'; @@ -39,7 +47,7 @@ import {MatCheckbox} from '@angular/material/checkbox'; MatCheckbox, ], }) -export class DragAndDropDemo { +export class DragAndDropDemo implements AfterViewInit { axisLock!: 'x' | 'y'; dragStartDelay = 0; todo = ['Go out for Lunch', 'Make a cool app', 'Watch TV', 'Eat a healthy dinner', 'Go to sleep']; @@ -50,6 +58,17 @@ export class DragAndDropDemo { ages = ['Stone age', 'Bronze age', 'Iron age', 'Middle ages']; preferredAges = ['Modern period', 'Renaissance']; + dls: CdkDropList[] = []; + + todo2 = [ + 'Get to work', + ['Get up', 'Brush teeth', 'Take a shower', 'Check e-mail', 'Walk dog'], + ['Preare for work', 'Drive to office', 'Üark car'], + 'Pick up groceries', + 'Go home', + 'Fall asleep', + ]; + @ViewChildren(CdkDropList) dlq: QueryList | undefined; constructor() { const iconRegistry = inject(MatIconRegistry); @@ -69,7 +88,21 @@ export class DragAndDropDemo { ); } - drop(event: CdkDragDrop) { + ngAfterViewInit() { + const updateList = () => { + const ldls: CdkDropList[] = []; + this.dlq?.forEach(dl => ldls.push(dl)); + queueMicrotask(() => { + this.dls = ldls; + }); + }; + + updateList(); + this.dlq?.changes.subscribe(() => updateList()); + } + + // CdkDragDrop + drop(event: any) { if (event.previousContainer === event.container) { moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); } else { @@ -88,4 +121,8 @@ export class DragAndDropDemo { y -= pickup.y; return {x, y}; } + + isArray(item: any): boolean { + return Array.isArray(item); + } }