From b5e30156c110b67fa5633062227b8767fe601532 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 19 Jun 2024 19:28:29 +0200 Subject: [PATCH] feat(cdk/drag-drop): add the ability to specify an alternate drop list container (#29283) Adds the new `cdkDropListElementContainer` input that allows users to specify a different element that should be considered the root of the drop list. This is useful in the cases where the user might not have full control over the DOM between the drop list and the items, like when making a tab list draggable. Fixes #29140. --- .../directives/drop-list-shared.spec.ts | 150 ++++++++++++++++++ src/cdk/drag-drop/directives/drop-list.ts | 28 ++++ src/cdk/drag-drop/drop-list-ref.ts | 97 ++++++++--- .../sorting/drop-list-sort-strategy.ts | 1 + .../drag-drop/sorting/mixed-sort-strategy.ts | 11 +- .../sorting/single-axis-sort-strategy.ts | 14 +- tools/public_api_guard/cdk/drag-drop.md | 4 +- 7 files changed, 276 insertions(+), 29 deletions(-) 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;