diff --git a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts
index ad3f25577d00..4e9a6bf2b32b 100644
--- a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts
+++ b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts
@@ -4709,6 +4709,61 @@ export function defineCommonDropListTests(config: {
expect(event.stopPropagation).toHaveBeenCalled();
}));
});
+
+ describe('with an alternate element container', () => {
+ it('should move the placeholder into the alternate container of an empty list', fakeAsync(() => {
+ const fixture = createComponent(ConnectedDropZonesWithAlternateContainer);
+ fixture.detectChanges();
+
+ const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement);
+ const item = fixture.componentInstance.groupedDragItems[0][1];
+ const sourceContainer = dropZones[0].querySelector('.inner-container')!;
+ const targetContainer = dropZones[1].querySelector('.inner-container')!;
+ const targetRect = targetContainer.getBoundingClientRect();
+
+ startDraggingViaMouse(fixture, item.element.nativeElement);
+
+ const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!;
+
+ expect(placeholder).toBeTruthy();
+ expect(placeholder.parentNode)
+ .withContext('Expected placeholder to be inside the first container.')
+ .toBe(sourceContainer);
+
+ dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1);
+ fixture.detectChanges();
+
+ expect(placeholder.parentNode)
+ .withContext('Expected placeholder to be inside second container.')
+ .toBe(targetContainer);
+ }));
+
+ it('should throw if the items are not inside of the alternate container', fakeAsync(() => {
+ const fixture = createComponent(DraggableWithInvalidAlternateContainer);
+ fixture.detectChanges();
+
+ expect(() => {
+ const item = fixture.componentInstance.dragItems.first.element.nativeElement;
+ startDraggingViaMouse(fixture, item);
+ tick();
+ }).toThrowError(
+ /Invalid DOM structure for drop list\. All items must be placed directly inside of the element container/,
+ );
+ }));
+
+ it('should throw if the alternate container cannot be found', fakeAsync(() => {
+ const fixture = createComponent(DraggableWithMissingAlternateContainer);
+ fixture.detectChanges();
+
+ expect(() => {
+ const item = fixture.componentInstance.dragItems.first.element.nativeElement;
+ startDraggingViaMouse(fixture, item);
+ tick();
+ }).toThrowError(
+ /CdkDropList could not find an element container matching the selector "does-not-exist"/,
+ );
+ }));
+ });
}
export function assertStartToEndSorting(
@@ -5891,3 +5946,98 @@ class DraggableWithRadioInputsInDropZone {
{id: 3, checked: true},
];
}
+
+@Component({
+ encapsulation: ViewEncapsulation.ShadowDom,
+ styles: [...CONNECTED_DROP_ZONES_STYLES, `.inner-container {min-height: 50px;}`],
+ template: `
+
+
+ @for (item of todo; track item) {
+
{{item}}
+ }
+
+
+
+
+
+ @for (item of done; track item) {
+
{{item}}
+ }
+
+
+ `,
+ standalone: true,
+ imports: [CdkDropList, CdkDrag],
+})
+class ConnectedDropZonesWithAlternateContainer extends ConnectedDropZones {
+ override done: string[] = [];
+}
+
+@Component({
+ template: `
+
+
+
+ @for (item of items; track $index) {
+
{{item}}
+ }
+
+ `,
+ standalone: true,
+ imports: [CdkDropList, CdkDrag],
+})
+class DraggableWithInvalidAlternateContainer {
+ @ViewChildren(CdkDrag) dragItems: QueryList;
+ @ViewChild(CdkDropList) dropInstance: CdkDropList;
+ items = ['Zero', 'One', 'Two', 'Three'];
+}
+
+@Component({
+ template: `
+
+ @for (item of items; track $index) {
+
{{item}}
+ }
+
+ `,
+ standalone: true,
+ imports: [CdkDropList, CdkDrag],
+})
+class DraggableWithMissingAlternateContainer {
+ @ViewChildren(CdkDrag) dragItems: QueryList;
+ @ViewChild(CdkDropList) dropInstance: CdkDropList;
+ items = ['Zero', 'One', 'Two', 'Three'];
+}
diff --git a/src/cdk/drag-drop/directives/drop-list.ts b/src/cdk/drag-drop/directives/drop-list.ts
index 41e0d16fb1c4..db13704b368b 100644
--- a/src/cdk/drag-drop/directives/drop-list.ts
+++ b/src/cdk/drag-drop/directives/drop-list.ts
@@ -127,6 +127,22 @@ export class CdkDropList implements OnDestroy {
@Input('cdkDropListAutoScrollStep')
autoScrollStep: NumberInput;
+ /**
+ * Selector that will be used to resolve an alternate element container for the drop list.
+ * Passing an alternate container is useful for the cases where one might not have control
+ * over the parent node of the draggable items within the list (e.g. due to content projection).
+ * This allows for usages like:
+ *
+ * ```
+ *
+ * ```
+ */
+ @Input('cdkDropListElementContainer') elementContainerSelector: string | null;
+
/** Emits when the user drops an item inside the container. */
@Output('cdkDropListDropped')
readonly dropped: EventEmitter> = new EventEmitter>();
@@ -295,6 +311,18 @@ export class CdkDropList implements OnDestroy {
this._scrollableParentsResolved = true;
}
+ if (this.elementContainerSelector) {
+ const container = this.element.nativeElement.querySelector(this.elementContainerSelector);
+
+ if (!container && (typeof ngDevMode === 'undefined' || ngDevMode)) {
+ throw new Error(
+ `CdkDropList could not find an element container matching the selector "${this.elementContainerSelector}"`,
+ );
+ }
+
+ ref.withElementContainer(container as HTMLElement);
+ }
+
ref.disabled = this.disabled;
ref.lockAxis = this.lockAxis;
ref.sortingDisabled = this.sortingDisabled;
diff --git a/src/cdk/drag-drop/drop-list-ref.ts b/src/cdk/drag-drop/drop-list-ref.ts
index a1bf214aeb42..b4540e803d6c 100644
--- a/src/cdk/drag-drop/drop-list-ref.ts
+++ b/src/cdk/drag-drop/drop-list-ref.ts
@@ -141,6 +141,9 @@ export class DropListRef {
/** Arbitrary data that can be attached to the drop list. */
data: T;
+ /** Element that is the direct parent of the drag items. */
+ private _container: HTMLElement;
+
/** Whether an item in the list is being dragged. */
private _isDragging = false;
@@ -184,7 +187,7 @@ export class DropListRef {
private _document: Document;
/** Elements that can be scrolled while the user is dragging. */
- private _scrollableElements: HTMLElement[];
+ private _scrollableElements: HTMLElement[] = [];
/** Initial value for the element's `scroll-snap-type` style. */
private _initialScrollSnap: string;
@@ -199,9 +202,9 @@ export class DropListRef {
private _ngZone: NgZone,
private _viewportRuler: ViewportRuler,
) {
- this.element = coerceElement(element);
+ const coercedElement = (this.element = coerceElement(element));
this._document = _document;
- this.withScrollableParents([this.element]).withOrientation('vertical');
+ this.withOrientation('vertical').withElementContainer(coercedElement);
_dragDropRegistry.registerDropContainer(this);
this._parentPositions = new ParentPositionTracker(_document);
}
@@ -358,20 +361,14 @@ export class DropListRef {
*/
withOrientation(orientation: DropListOrientation): this {
if (orientation === 'mixed') {
- this._sortStrategy = new MixedSortStrategy(
- coerceElement(this.element),
- this._document,
- this._dragDropRegistry,
- );
+ this._sortStrategy = new MixedSortStrategy(this._document, this._dragDropRegistry);
} else {
- const strategy = new SingleAxisSortStrategy(
- coerceElement(this.element),
- this._dragDropRegistry,
- );
+ const strategy = new SingleAxisSortStrategy(this._dragDropRegistry);
strategy.direction = this._direction;
strategy.orientation = orientation;
this._sortStrategy = strategy;
}
+ this._sortStrategy.withElementContainer(this._container);
this._sortStrategy.withSortPredicate((index, item) => this.sortPredicate(index, item, this));
return this;
}
@@ -381,7 +378,7 @@ export class DropListRef {
* @param elements Elements that can be scrolled.
*/
withScrollableParents(elements: HTMLElement[]): this {
- const element = coerceElement(this.element);
+ const element = this._container;
// We always allow the current element to be scrollable
// so we need to ensure that it's in the array.
@@ -390,6 +387,51 @@ export class DropListRef {
return this;
}
+ /**
+ * Configures the drop list so that a different element is used as the container for the
+ * dragged items. This is useful for the cases when one might not have control over the
+ * full DOM that sets up the dragging.
+ * Note that the alternate container needs to be a descendant of the drop list.
+ * @param container New element container to be assigned.
+ */
+ withElementContainer(container: HTMLElement): this {
+ if (container === this._container) {
+ return this;
+ }
+
+ const element = coerceElement(this.element);
+
+ if (
+ (typeof ngDevMode === 'undefined' || ngDevMode) &&
+ container !== element &&
+ !element.contains(container)
+ ) {
+ throw new Error(
+ 'Invalid DOM structure for drop list. Alternate container element must be a descendant of the drop list.',
+ );
+ }
+
+ const oldContainerIndex = this._scrollableElements.indexOf(this._container);
+ const newContainerIndex = this._scrollableElements.indexOf(container);
+
+ if (oldContainerIndex > -1) {
+ this._scrollableElements.splice(oldContainerIndex, 1);
+ }
+
+ if (newContainerIndex > -1) {
+ this._scrollableElements.splice(newContainerIndex, 1);
+ }
+
+ if (this._sortStrategy) {
+ this._sortStrategy.withElementContainer(container);
+ }
+
+ this._cachedShadowRoot = null;
+ this._scrollableElements.unshift(container);
+ this._container = container;
+ return this;
+ }
+
/** Gets the scrollable parents that are registered with this drop container. */
getScrollableParents(): readonly HTMLElement[] {
return this._scrollableElements;
@@ -526,10 +568,25 @@ export class DropListRef {
/** Starts the dragging sequence within the list. */
private _draggingStarted() {
- const styles = coerceElement(this.element).style as DragCSSStyleDeclaration;
+ const styles = this._container.style as DragCSSStyleDeclaration;
this.beforeStarted.next();
this._isDragging = true;
+ if (
+ (typeof ngDevMode === 'undefined' || ngDevMode) &&
+ // Prevent the check from running on apps not using an alternate container. Ideally we
+ // would always run it, but introducing it at this stage would be a breaking change.
+ this._container !== coerceElement(this.element)
+ ) {
+ for (const drag of this._draggables) {
+ if (!drag.isDragging() && drag.getVisibleElement().parentNode !== this._container) {
+ throw new Error(
+ 'Invalid DOM structure for drop list. All items must be placed directly inside of the element container.',
+ );
+ }
+ }
+ }
+
// We need to disable scroll snapping while the user is dragging, because it breaks automatic
// scrolling. The browser seems to round the value based on the snapping points which means
// that we can't increment/decrement the scroll position.
@@ -543,19 +600,17 @@ export class DropListRef {
/** Caches the positions of the configured scrollable parents. */
private _cacheParentPositions() {
- const element = coerceElement(this.element);
this._parentPositions.cache(this._scrollableElements);
// The list element is always in the `scrollableElements`
// so we can take advantage of the cached `DOMRect`.
- this._domRect = this._parentPositions.positions.get(element)!.clientRect!;
+ this._domRect = this._parentPositions.positions.get(this._container)!.clientRect!;
}
/** Resets the container to its initial state. */
private _reset() {
this._isDragging = false;
-
- const styles = coerceElement(this.element).style as DragCSSStyleDeclaration;
+ const styles = this._container.style as DragCSSStyleDeclaration;
styles.scrollSnapType = styles.msScrollSnapType = this._initialScrollSnap;
this._siblings.forEach(sibling => sibling._stopReceiving(this));
@@ -632,15 +687,13 @@ export class DropListRef {
return false;
}
- const nativeElement = coerceElement(this.element);
-
// The `DOMRect`, that we're using to find the container over which the user is
// hovering, doesn't give us any information on whether the element has been scrolled
// out of the view or whether it's overlapping with other containers. This means that
// we could end up transferring the item into a container that's invisible or is positioned
// below another one. We use the result from `elementFromPoint` to get the top-most element
// at the pointer position and to find whether it's one of the intersecting drop containers.
- return elementFromPoint === nativeElement || nativeElement.contains(elementFromPoint);
+ return elementFromPoint === this._container || this._container.contains(elementFromPoint);
}
/**
@@ -709,7 +762,7 @@ export class DropListRef {
*/
private _getShadowRoot(): RootNode {
if (!this._cachedShadowRoot) {
- const shadowRoot = _getShadowRoot(coerceElement(this.element));
+ const shadowRoot = _getShadowRoot(this._container);
this._cachedShadowRoot = (shadowRoot || this._document) as RootNode;
}
diff --git a/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts b/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts
index f6574811bfe6..04d76e62a9ce 100644
--- a/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts
+++ b/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts
@@ -29,6 +29,7 @@ export interface DropListSortStrategy {
enter(item: DragRef, pointerX: number, pointerY: number, index?: number): void;
withItems(items: readonly DragRef[]): void;
withSortPredicate(predicate: SortPredicate): void;
+ withElementContainer(container: HTMLElement): void;
reset(): void;
getActiveItemsSnapshot(): readonly DragRef[];
getItemIndex(item: DragRef): number;
diff --git a/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts b/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts
index ab728b64aed1..2bcbf4264180 100644
--- a/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts
+++ b/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts
@@ -18,6 +18,9 @@ import type {DragRef} from '../drag-ref';
* @docs-private
*/
export class MixedSortStrategy implements DropListSortStrategy {
+ /** Root element container of the drop list. */
+ private _element: HTMLElement;
+
/** Function used to determine if an item can be sorted into a specific index. */
private _sortPredicate: SortPredicate;
@@ -50,7 +53,6 @@ export class MixedSortStrategy implements DropListSortStrategy {
private _relatedNodes: [node: Node, nextSibling: Node | null][] = [];
constructor(
- private _element: HTMLElement,
private _document: Document,
private _dragDropRegistry: DragDropRegistry,
) {}
@@ -231,6 +233,13 @@ export class MixedSortStrategy implements DropListSortStrategy {
});
}
+ withElementContainer(container: HTMLElement): void {
+ if (container !== this._element) {
+ this._element = container;
+ this._rootNode = undefined;
+ }
+ }
+
/**
* Gets the index of an item in the drop container, based on the position of the user's pointer.
* @param item Item that is being sorted.
diff --git a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts
index 24cb859bf97c..28ed95c6517b 100644
--- a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts
+++ b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts
@@ -35,6 +35,9 @@ interface CachedItemPosition {
* @docs-private
*/
export class SingleAxisSortStrategy implements DropListSortStrategy {
+ /** Root element container of the drop list. */
+ private _element: HTMLElement;
+
/** Function used to determine if an item can be sorted into a specific index. */
private _sortPredicate: SortPredicate;
@@ -54,10 +57,7 @@ export class SingleAxisSortStrategy implements DropListSortStrategy {
/** Layout direction of the drop list. */
direction: Direction;
- constructor(
- private _element: HTMLElement,
- private _dragDropRegistry: DragDropRegistry,
- ) {}
+ constructor(private _dragDropRegistry: DragDropRegistry) {}
/**
* Keeps track of the item that was last swapped with the dragged item, as well as what direction
@@ -235,7 +235,7 @@ export class SingleAxisSortStrategy implements DropListSortStrategy {
/** Resets the strategy to its initial state before dragging was started. */
reset() {
// TODO(crisbeto): may have to wait for the animations to finish.
- this._activeDraggables.forEach(item => {
+ this._activeDraggables?.forEach(item => {
const rootElement = item.getRootElement();
if (rootElement) {
@@ -293,6 +293,10 @@ export class SingleAxisSortStrategy implements DropListSortStrategy {
});
}
+ withElementContainer(container: HTMLElement): void {
+ this._element = container;
+ }
+
/** Refreshes the position cache of the items and sibling containers. */
private _cacheItemPositions() {
const isHorizontal = this.orientation === 'horizontal';
diff --git a/tools/public_api_guard/cdk/drag-drop.md b/tools/public_api_guard/cdk/drag-drop.md
index c4ea1653eeb3..82b57b513f0d 100644
--- a/tools/public_api_guard/cdk/drag-drop.md
+++ b/tools/public_api_guard/cdk/drag-drop.md
@@ -251,6 +251,7 @@ export class CdkDropList implements OnDestroy {
_dropListRef: DropListRef>;
readonly dropped: EventEmitter>;
element: ElementRef;
+ elementContainerSelector: string | null;
readonly entered: EventEmitter>;
enterPredicate: (drag: CdkDrag, drop: CdkDropList) => boolean;
readonly exited: EventEmitter>;
@@ -271,7 +272,7 @@ export class CdkDropList implements OnDestroy {
sortingDisabled: boolean;
sortPredicate: (index: number, drag: CdkDrag, drop: CdkDropList) => boolean;
// (undocumented)
- static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkDropList], cdk-drop-list", ["cdkDropList"], { "connectedTo": { "alias": "cdkDropListConnectedTo"; "required": false; }; "data": { "alias": "cdkDropListData"; "required": false; }; "orientation": { "alias": "cdkDropListOrientation"; "required": false; }; "id": { "alias": "id"; "required": false; }; "lockAxis": { "alias": "cdkDropListLockAxis"; "required": false; }; "disabled": { "alias": "cdkDropListDisabled"; "required": false; }; "sortingDisabled": { "alias": "cdkDropListSortingDisabled"; "required": false; }; "enterPredicate": { "alias": "cdkDropListEnterPredicate"; "required": false; }; "sortPredicate": { "alias": "cdkDropListSortPredicate"; "required": false; }; "autoScrollDisabled": { "alias": "cdkDropListAutoScrollDisabled"; "required": false; }; "autoScrollStep": { "alias": "cdkDropListAutoScrollStep"; "required": false; }; }, { "dropped": "cdkDropListDropped"; "entered": "cdkDropListEntered"; "exited": "cdkDropListExited"; "sorted": "cdkDropListSorted"; }, never, never, true, never>;
+ static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkDropList], cdk-drop-list", ["cdkDropList"], { "connectedTo": { "alias": "cdkDropListConnectedTo"; "required": false; }; "data": { "alias": "cdkDropListData"; "required": false; }; "orientation": { "alias": "cdkDropListOrientation"; "required": false; }; "id": { "alias": "id"; "required": false; }; "lockAxis": { "alias": "cdkDropListLockAxis"; "required": false; }; "disabled": { "alias": "cdkDropListDisabled"; "required": false; }; "sortingDisabled": { "alias": "cdkDropListSortingDisabled"; "required": false; }; "enterPredicate": { "alias": "cdkDropListEnterPredicate"; "required": false; }; "sortPredicate": { "alias": "cdkDropListSortPredicate"; "required": false; }; "autoScrollDisabled": { "alias": "cdkDropListAutoScrollDisabled"; "required": false; }; "autoScrollStep": { "alias": "cdkDropListAutoScrollStep"; "required": false; }; "elementContainerSelector": { "alias": "cdkDropListElementContainer"; "required": false; }; }, { "dropped": "cdkDropListDropped"; "entered": "cdkDropListEntered"; "exited": "cdkDropListExited"; "sorted": "cdkDropListSorted"; }, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration, [null, null, null, null, { optional: true; }, { optional: true; skipSelf: true; }, { optional: true; }]>;
}
@@ -543,6 +544,7 @@ export class DropListRef {
_stopReceiving(sibling: DropListRef): void;
_stopScrolling(): void;
withDirection(direction: Direction): this;
+ withElementContainer(container: HTMLElement): this;
withItems(items: DragRef[]): this;
withOrientation(orientation: DropListOrientation): this;
withScrollableParents(elements: HTMLElement[]): this;