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;