From 5d6c583c6b573e16de07500df5958b746722b737 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 4 Jun 2025 09:53:28 +0200 Subject: [PATCH] feat(cdk/drag-drop): add opt-in indicator of pick-up position Currently we create a placeholder element to indicate where an item will be dropped. The placeholder gets moved around between drop containers as the user is dragging. In some cases this might not be desirable, because the data representing the dragged item might be copied, rather than moved. These changes address this use case by adding the `cdkDropListHasAnchor` input. When enabled, it'll tell the drop list to leave an anchor element, representing the dragged item, inside the original list. The anchor differs from the placeholder in that it will stay in the original container and won't move to any subsequent containers. Fixes #13906. --- goldens/cdk/drag-drop/index.api.md | 7 +- .../directives/drop-list-shared.spec.ts | 168 +++++++++++++++++- src/cdk/drag-drop/directives/drop-list.ts | 15 ++ src/cdk/drag-drop/drag-drop.md | 19 ++ src/cdk/drag-drop/drag-ref.ts | 75 ++++++-- src/cdk/drag-drop/drop-list-ref.ts | 15 ++ .../sorting/drop-list-sort-strategy.ts | 1 + .../drag-drop/sorting/mixed-sort-strategy.ts | 5 + .../sorting/single-axis-sort-strategy.ts | 22 ++- .../cdk-drag-drop-copy-list-example.css | 50 ++++++ .../cdk-drag-drop-copy-list-example.html | 31 ++++ .../cdk-drag-drop-copy-list-example.ts | 35 ++++ .../cdk/drag-drop/index.ts | 1 + 13 files changed, 417 insertions(+), 27 deletions(-) create mode 100644 src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.css create mode 100644 src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.html create mode 100644 src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.ts diff --git a/goldens/cdk/drag-drop/index.api.md b/goldens/cdk/drag-drop/index.api.md index 23201eb6c083..2cc3e5d36cab 100644 --- a/goldens/cdk/drag-drop/index.api.md +++ b/goldens/cdk/drag-drop/index.api.md @@ -257,6 +257,7 @@ export class CdkDropList implements OnDestroy { enterPredicate: (drag: CdkDrag, drop: CdkDropList) => boolean; readonly exited: EventEmitter>; getSortedItems(): CdkDrag[]; + hasAnchor: boolean; id: string; lockAxis: DragAxis; // (undocumented) @@ -264,6 +265,8 @@ export class CdkDropList implements OnDestroy { // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_hasAnchor: unknown; + // (undocumented) static ngAcceptInputType_sortingDisabled: unknown; // (undocumented) ngOnDestroy(): void; @@ -273,7 +276,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; }; "elementContainerSelector": { "alias": "cdkDropListElementContainer"; "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; }; "hasAnchor": { "alias": "cdkDropListHasAnchor"; "required": false; }; }, { "dropped": "cdkDropListDropped"; "entered": "cdkDropListEntered"; "exited": "cdkDropListExited"; "sorted": "cdkDropListSorted"; }, never, never, true, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -512,9 +515,11 @@ export class DropListRef { item: DragRef; container: DropListRef; }>; + getItemAtIndex(index: number): DragRef | null; getItemIndex(item: DragRef): number; getScrollableParents(): readonly HTMLElement[]; _getSiblingContainerFromPosition(item: DragRef, x: number, y: number): DropListRef | undefined; + hasAnchor: boolean; isDragging(): boolean; _isOverContainer(x: number, y: number): boolean; isReceiving(): boolean; 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 a84fc9ebb63a..a3ed399ffd21 100644 --- a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts +++ b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts @@ -806,7 +806,7 @@ export function defineCommonDropListTests(config: { startDraggingViaMouse(fixture, item); const anchor = Array.from(list.childNodes).find( - node => node.textContent === 'cdk-drag-anchor', + node => node.textContent === 'cdk-drag-marker', ); expect(anchor).toBeTruthy(); @@ -4740,6 +4740,166 @@ export function defineCommonDropListTests(config: { ); })); }); + + describe('with an anchor', () => { + function getAnchor(container: HTMLElement) { + return container.querySelector('.cdk-drag-anchor'); + } + + function getPlaceholder(container: HTMLElement) { + return container.querySelector('.cdk-drag-placeholder'); + } + + it('should create and manage the anchor element when the item is moved into a new container', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.componentInstance.hasAnchor.set(true); + fixture.detectChanges(); + + const groups = fixture.componentInstance.groupedDragItems; + const [sourceContainer, targetContainer] = Array.from( + fixture.nativeElement.querySelectorAll('.cdk-drop-list'), + ); + const item = groups[0][1]; + const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); + const x = targetRect.left + 1; + const y = targetRect.top + 1; + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + + startDraggingViaMouse(fixture, item.element.nativeElement); + expect(getAnchor(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeTruthy(); + + dispatchMouseEvent(document, 'mousemove', x, y); + fixture.detectChanges(); + const anchor = getAnchor(sourceContainer)!; + expect(anchor).toBeTruthy(); + expect(anchor.textContent).toContain('One'); + expect(anchor.classList).toContain('cdk-drag-anchor'); + expect(anchor.classList).not.toContain('cdk-drag-placeholder'); + expect(getAnchor(targetContainer)).toBeFalsy(); + expect(getPlaceholder(targetContainer)).toBeTruthy(); + + dispatchMouseEvent(document, 'mouseup', x, y); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + })); + + it('should remove the anchor when the item is returned to the initial container', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.componentInstance.hasAnchor.set(true); + fixture.detectChanges(); + + const groups = fixture.componentInstance.groupedDragItems; + const [sourceContainer, targetContainer] = Array.from( + fixture.nativeElement.querySelectorAll('.cdk-drop-list'), + ); + const item = groups[0][1]; + const sourceRect = sourceContainer.getBoundingClientRect(); + const targetRect = targetContainer.getBoundingClientRect(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + + startDraggingViaMouse(fixture, item.element.nativeElement); + expect(getAnchor(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeTruthy(); + + // Move into the second container. + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); + expect(getAnchor(sourceContainer)).toBeTruthy(); + expect(getAnchor(targetContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(targetContainer)).toBeTruthy(); + + // Move back into the source container. + dispatchMouseEvent(document, 'mousemove', sourceRect.left + 1, sourceRect.top + 1); + fixture.detectChanges(); + expect(getAnchor(sourceContainer)).toBeFalsy(); + expect(getAnchor(targetContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeTruthy(); + expect(getPlaceholder(targetContainer)).toBeFalsy(); + + dispatchMouseEvent(document, 'mouseup', sourceRect.left + 1, sourceRect.top + 1); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + })); + + it('should keep the anchor inside the initial container as the item is moved between containers', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); + + // By default the drop zones are stacked on top of each other. + // Lay them out horizontally so the coordinates aren't changing while dragging. + fixture.nativeElement.style.display = 'flex'; + fixture.nativeElement.style.alignItems = 'flex-start'; + + // The extra zone isn't connected to the others by default. + fixture.componentInstance.todoConnectedTo.set([ + fixture.componentInstance.dropInstances.get(1)!, + fixture.componentInstance.dropInstances.get(2)!, + ]); + fixture.componentInstance.hasAnchor.set(true); + fixture.detectChanges(); + + const groups = fixture.componentInstance.groupedDragItems; + const [sourceContainer, secondContainer, thirdContainer] = Array.from( + fixture.nativeElement.querySelectorAll('.cdk-drop-list'), + ); + const item = groups[0][1]; + const secondRect = secondContainer.getBoundingClientRect(); + const thirdRect = thirdContainer.getBoundingClientRect(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + + startDraggingViaMouse(fixture, item.element.nativeElement); + expect(getAnchor(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeTruthy(); + + // Move to the second container. + dispatchMouseEvent(document, 'mousemove', secondRect.left + 1, secondRect.top + 1); + fixture.detectChanges(); + expect(getAnchor(sourceContainer)).toBeTruthy(); + expect(getAnchor(secondContainer)).toBeFalsy(); + expect(getAnchor(thirdContainer)).toBeFalsy(); + + expect(getPlaceholder(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(secondContainer)).toBeTruthy(); + expect(getPlaceholder(thirdContainer)).toBeFalsy(); + + // Move to the third container. + dispatchMouseEvent(document, 'mousemove', thirdRect.left + 1, thirdRect.top + 1); + fixture.detectChanges(); + expect(getAnchor(sourceContainer)).toBeTruthy(); + expect(getAnchor(secondContainer)).toBeFalsy(); + expect(getAnchor(thirdContainer)).toBeFalsy(); + + expect(getPlaceholder(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(secondContainer)).toBeFalsy(); + expect(getPlaceholder(thirdContainer)).toBeTruthy(); + + // Drop the item. + dispatchMouseEvent(document, 'mouseup', thirdRect.left + 1, thirdRect.top + 1); + fixture.detectChanges(); + + flush(); + fixture.detectChanges(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + })); + }); } export function assertStartToEndSorting( @@ -5326,6 +5486,7 @@ const CONNECTED_DROP_ZONES_TEMPLATE = ` #todoZone="cdkDropList" [cdkDropListData]="todo" [cdkDropListConnectedTo]="todoConnectedTo() || [doneZone]" + [cdkDropListHasAnchor]="hasAnchor()" (cdkDropListDropped)="droppedSpy($event)" (cdkDropListEntered)="enteredSpy($event)"> @for (item of todo; track item) { @@ -5341,6 +5502,7 @@ const CONNECTED_DROP_ZONES_TEMPLATE = ` #doneZone="cdkDropList" [cdkDropListData]="done" [cdkDropListConnectedTo]="doneConnectedTo() || [todoZone]" + [cdkDropListHasAnchor]="hasAnchor()" (cdkDropListDropped)="droppedSpy($event)" (cdkDropListEntered)="enteredSpy($event)"> @for (item of done; track item) { @@ -5356,6 +5518,7 @@ const CONNECTED_DROP_ZONES_TEMPLATE = ` #extraZone="cdkDropList" [cdkDropListData]="extra" [cdkDropListConnectedTo]="extraConnectedTo()!" + [cdkDropListHasAnchor]="hasAnchor()" (cdkDropListDropped)="droppedSpy($event)" (cdkDropListEntered)="enteredSpy($event)"> @for (item of extra; track item) { @@ -5381,13 +5544,14 @@ export class ConnectedDropZones implements AfterViewInit { groupedDragItems: CdkDrag[][] = []; todo = ['Zero', 'One', 'Two', 'Three']; done = ['Four', 'Five', 'Six']; - extra = []; + extra: string[] = []; droppedSpy = jasmine.createSpy('dropped spy'); enteredSpy = jasmine.createSpy('entered spy'); itemEnteredSpy = jasmine.createSpy('item entered spy'); todoConnectedTo = signal<(CdkDropList | string)[] | undefined>(undefined); doneConnectedTo = signal<(CdkDropList | string)[] | undefined>(undefined); extraConnectedTo = signal<(CdkDropList | string)[] | undefined>(undefined); + hasAnchor = signal(false); ngAfterViewInit() { this.dropInstances.forEach((dropZone, index) => { diff --git a/src/cdk/drag-drop/directives/drop-list.ts b/src/cdk/drag-drop/directives/drop-list.ts index ce1008009475..06f235c3aca9 100644 --- a/src/cdk/drag-drop/directives/drop-list.ts +++ b/src/cdk/drag-drop/directives/drop-list.ts @@ -150,6 +150,20 @@ export class CdkDropList implements OnDestroy { */ @Input('cdkDropListElementContainer') elementContainerSelector: string | null; + /** + * By default when an item leaves its initial container, its placeholder will be transferred + * to the new container. If that's not desirable for your use case, you can enable this option + * which will clone the placeholder and leave it inside the original container. If the item is + * returned to the initial container, the anchor element will be removed automatically. + * + * The cloned placeholder can be styled by targeting the `cdk-drag-anchor` class. + * + * This option is useful in combination with `cdkDropListSortingDisabled` to implement copying + * behavior in a drop list. + */ + @Input({alias: 'cdkDropListHasAnchor', transform: booleanAttribute}) + hasAnchor: boolean; + /** Emits when the user drops an item inside the container. */ @Output('cdkDropListDropped') readonly dropped: EventEmitter> = new EventEmitter>(); @@ -339,6 +353,7 @@ export class CdkDropList implements OnDestroy { ref.sortingDisabled = this.sortingDisabled; ref.autoScrollDisabled = this.autoScrollDisabled; ref.autoScrollStep = coerceNumberProperty(this.autoScrollStep, 2); + ref.hasAnchor = this.hasAnchor; ref .connectedTo(siblings.filter(drop => drop && drop !== this).map(list => list._dropListRef)) .withOrientation(this.orientation); diff --git a/src/cdk/drag-drop/drag-drop.md b/src/cdk/drag-drop/drag-drop.md index 9832b56558fb..44d9f791d23d 100644 --- a/src/cdk/drag-drop/drag-drop.md +++ b/src/cdk/drag-drop/drag-drop.md @@ -82,6 +82,7 @@ by the directives: | `.cdk-drag-handle` | Class that is added to the host element of the cdkDragHandle directive. | | `.cdk-drag-preview` | This is the element that will be rendered next to the user's cursor as they're dragging an item in a sortable list. By default the element looks exactly like the element that is being dragged. | | `.cdk-drag-placeholder` | This is element that will be shown instead of the real element as it's being dragged inside a `cdkDropList`. By default this will look exactly like the element that is being sorted. | +| `.cdk-drag-anchor` | Only relevant when `cdkDropListHasAnchor` is enabled. Element indicating the position from which the dragged item started the drag sequence. | | `.cdk-drop-list-dragging` | A class that is added to `cdkDropList` while the user is dragging an item. | | `.cdk-drop-list-disabled` | A class that is added to `cdkDropList` when it is disabled. | | `.cdk-drop-list-receiving`| A class that is added to `cdkDropList` when it can receive an item that is being dragged inside a connected drop list. | @@ -173,6 +174,24 @@ sorting action. +### Copying items from one list to another +When the user starts dragging an item in a sortable list, by default the `cdkDropList` directive +will render out a placeholder element to show where the item will be dropped. If the item is dragged +into another list, the placeholder will be moved into the new list together with the item. + +If your use case calls for the item to remain in the original list, you can set the +`cdkDropListHasAnchor` input which will tell the `cdkDropList` to create an "anchor" element. The +anchor differs from the placeholder in that it will stay in the original container and won't move +to any subsequent containers that the item is dragged into. If the user moves the item back into +the original container, the anchor will be removed automatically. It can be styled by targeting +the `cdk-drag-anchor` CSS class. + +Combining `cdkDropListHasAnchor` and `cdkDropListSortingDisabled` makes it possible to construct a +list that user copies items from, but doesn't necessarily transfer out of (e.g. a product list and +a shopping cart). + + + ### Restricting movement within an element If you want to stop the user from being able to drag a `cdkDrag` element outside of another element, diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index 175e52348d27..7ec72ab499b7 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -85,6 +85,9 @@ const activeCapturingEventOptions = { */ const MOUSE_EVENT_IGNORE_TIME = 800; +/** Class applied to the drag placeholder. */ +const PLACEHOLDER_CLASS = 'cdk-drag-placeholder'; + // TODO(crisbeto): add an API for moving a draggable up/down the // list programmatically. Useful for keyboard controls. @@ -147,10 +150,15 @@ export class DragRef { private _pickupPositionOnPage: Point; /** - * Anchor node used to save the place in the DOM where the element was + * Marker node used to save the place in the DOM where the element was * picked up so that it can be restored at the end of the drag sequence. */ - private _anchor: Comment; + private _marker: Comment; + + /** + * Element indicating the position from which the item was picked up initially. + */ + private _anchor: HTMLElement | null = null; /** * CSS `transform` applied to the element when it isn't being dragged. We need a @@ -506,7 +514,7 @@ export class DragRef { this._rootElement?.remove(); } - this._anchor?.remove(); + this._marker?.remove(); this._destroyPreview(); this._destroyPlaceholder(); this._dragDropRegistry.removeDragItem(this); @@ -529,7 +537,7 @@ export class DragRef { this._ownerSVGElement = this._placeholderTemplate = this._previewTemplate = - this._anchor = + this._marker = this._parentDragRef = null!; } @@ -682,9 +690,10 @@ export class DragRef { /** Destroys the placeholder element and its ViewRef. */ private _destroyPlaceholder() { + this._anchor?.remove(); this._placeholder?.remove(); this._placeholderRef?.destroy(); - this._placeholder = this._placeholderRef = null!; + this._placeholder = this._anchor = this._placeholderRef = null!; } /** Handler for the `mousedown`/`touchstart` events. */ @@ -872,14 +881,14 @@ export class DragRef { const element = this._rootElement; const parent = element.parentNode as HTMLElement; const placeholder = (this._placeholder = this._createPlaceholderElement()); - const anchor = (this._anchor = - this._anchor || + const marker = (this._marker = + this._marker || this._document.createComment( - typeof ngDevMode === 'undefined' || ngDevMode ? 'cdk-drag-anchor' : '', + typeof ngDevMode === 'undefined' || ngDevMode ? 'cdk-drag-marker' : '', )); - // Insert an anchor node so that we can restore the element's position in the DOM. - parent.insertBefore(anchor, element); + // Insert a marker node so that we can restore the element's position in the DOM. + parent.insertBefore(marker, element); // There's no risk of transforms stacking when inside a drop container so // we can keep the initial transform up to date any time dragging starts. @@ -1012,7 +1021,7 @@ export class DragRef { // can throw off `NgFor` which does smart diffing and re-creates elements only when necessary, // while moving the existing elements in all other cases. toggleVisibility(this._rootElement, true, dragImportantProperties); - this._anchor.parentNode!.replaceChild(this._rootElement, this._anchor); + this._marker.parentNode!.replaceChild(this._rootElement, this._marker); this._destroyPreview(); this._destroyPlaceholder(); @@ -1081,19 +1090,23 @@ export class DragRef { if (newContainer && newContainer !== this._dropContainer) { this._ngZone.run(() => { + const exitIndex = this._dropContainer!.getItemIndex(this); + const nextItemElement = + this._dropContainer!.getItemAtIndex(exitIndex + 1)?.getVisibleElement() || null; + // Notify the old container that the item has left. this.exited.next({item: this, container: this._dropContainer!}); this._dropContainer!.exit(this); + this._conditionallyInsertAnchor(newContainer, this._dropContainer!, nextItemElement); // Notify the new container that the item has entered. this._dropContainer = newContainer!; this._dropContainer.enter( this, x, y, - newContainer === this._initialContainer && - // If we're re-entering the initial container and sorting is disabled, - // put item the into its starting index to begin with. - newContainer.sortingDisabled + // If we're re-entering the initial container and sorting is disabled, + // put item the into its starting index to begin with. + newContainer === this._initialContainer && newContainer.sortingDisabled ? this._initialIndex : undefined, ); @@ -1193,7 +1206,7 @@ export class DragRef { // Stop pointer events on the preview so the user can't // interact with it while the preview is animating. placeholder.style.pointerEvents = 'none'; - placeholder.classList.add('cdk-drag-placeholder'); + placeholder.classList.add(PLACEHOLDER_CLASS); return placeholder; } @@ -1585,6 +1598,36 @@ export class DragRef { return event.target && (event.target === handle || handle.contains(event.target as Node)); }); } + + /** Inserts the anchor element, if it's valid. */ + private _conditionallyInsertAnchor( + newContainer: DropListRef, + exitContainer: DropListRef, + nextItemElement: HTMLElement | null, + ) { + // Remove the anchor when returning to the initial container. + if (newContainer === this._initialContainer) { + this._anchor?.remove(); + this._anchor = null; + } else if (exitContainer === this._initialContainer && exitContainer.hasAnchor) { + // Insert the anchor when leaving the initial container. + const anchor = (this._anchor ??= deepCloneNode(this._placeholder)); + anchor.classList.remove(PLACEHOLDER_CLASS); + anchor.classList.add('cdk-drag-anchor'); + + // Clear the transform since the single-axis strategy uses transforms to sort the items. + anchor.style.transform = ''; + + // When the item leaves the initial container, the container's DOM will be restored to + // its original state, except for the dragged item which is removed. Insert the anchor in + // the position from which the item left so that the list looks consistent. + if (nextItemElement) { + nextItemElement.before(anchor); + } else { + coerceElement(exitContainer.element).appendChild(anchor); + } + } + } } /** Clamps a value between a minimum and a maximum. */ diff --git a/src/cdk/drag-drop/drop-list-ref.ts b/src/cdk/drag-drop/drop-list-ref.ts index ee94e4617793..f62ff93616ec 100644 --- a/src/cdk/drag-drop/drop-list-ref.ts +++ b/src/cdk/drag-drop/drop-list-ref.ts @@ -74,6 +74,11 @@ export class DropListRef { /** Number of pixels to scroll for each frame when auto-scrolling an element. */ autoScrollStep: number = 2; + /** + * Whether the items in the list should leave an anchor node when leaving the initial container. + */ + hasAnchor: boolean = false; + /** * Function that is used to determine whether an item * is allowed to be moved into a drop container. @@ -440,6 +445,16 @@ export class DropListRef { : this._draggables.indexOf(item); } + /** + * Gets the item at a specific index. + * @param index Index at which to retrieve the item. + */ + getItemAtIndex(index: number): DragRef | null { + return this._isDragging + ? this._sortStrategy.getItemAtIndex(index) + : this._draggables[index] || null; + } + /** * Whether the list is able to receive the item that * is currently being dragged inside a connected drop list. 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 51b820ee8c24..4be396ab4143 100644 --- a/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts @@ -33,5 +33,6 @@ export interface DropListSortStrategy { reset(): void; getActiveItemsSnapshot(): readonly DragRef[]; getItemIndex(item: DragRef): number; + getItemAtIndex(index: number): DragRef | null; updateOnScroll(topDifference: number, leftDifference: number): void; } diff --git a/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts b/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts index 9fbe5d4f3a16..f5cb575d8686 100644 --- a/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts @@ -222,6 +222,11 @@ export class MixedSortStrategy implements DropListSortStrategy { return this._activeItems.indexOf(item); } + /** Gets the item at a specific index. */ + getItemAtIndex(index: number): DragRef | null { + return this._activeItems[index] || null; + } + /** Used to notify the strategy that the scroll position has changed. */ updateOnScroll(): void { this._activeItems.forEach(item => { 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 b3c5858234f3..68c9c39e94d7 100644 --- a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts @@ -263,15 +263,12 @@ export class SingleAxisSortStrategy implements DropListSortStrategy { /** Gets the index of a specific item. */ getItemIndex(item: DragRef): number { - // Items are sorted always by top/left in the cache, however they flow differently in RTL. - // The rest of the logic still stands no matter what orientation we're in, however - // we need to invert the array when determining the index. - const items = - this.orientation === 'horizontal' && this.direction === 'rtl' - ? this._itemPositions.slice().reverse() - : this._itemPositions; + return this._getVisualItemPositions().findIndex(currentItem => currentItem.drag === item); + } - return items.findIndex(currentItem => currentItem.drag === item); + /** Gets the item at a specific index. */ + getItemAtIndex(index: number): DragRef | null { + return this._getVisualItemPositions()[index]?.drag || null; } /** Used to notify the strategy that the scroll position has changed. */ @@ -320,6 +317,15 @@ export class SingleAxisSortStrategy implements DropListSortStrategy { }); } + private _getVisualItemPositions() { + // Items are sorted always by top/left in the cache, however they flow differently in RTL. + // The rest of the logic still stands no matter what orientation we're in, however + // we need to invert the array when determining the index. + return this.orientation === 'horizontal' && this.direction === 'rtl' + ? this._itemPositions.slice().reverse() + : this._itemPositions; + } + /** * Gets the offset in pixels by which the item that is being dragged should be moved. * @param currentPosition Current position of the item. diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.css b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.css new file mode 100644 index 000000000000..831122b5e934 --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.css @@ -0,0 +1,50 @@ +.example-container { + width: 400px; + max-width: 100%; + margin: 0 25px 25px 0; + display: inline-block; + vertical-align: top; +} + +.example-list { + border: solid 1px #ccc; + min-height: 60px; + background: white; + border-radius: 4px; + overflow: hidden; + display: block; +} + +.example-box { + padding: 20px 10px; + border-bottom: solid 1px #ccc; + color: rgba(0, 0, 0, 0.87); + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + cursor: move; + background: white; + font-size: 14px; +} + +.cdk-drag-preview { + box-sizing: border-box; + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.example-box:last-child { + border: none; +} + +.example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.html b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.html new file mode 100644 index 000000000000..c229537adcc8 --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.html @@ -0,0 +1,31 @@ +
+

Products

+ +
+ @for (product of products; track $index) { +
{{product}}
+ } +
+
+ +
+

Shopping cart

+ +
+ @for (product of cart; track $index) { +
{{product}}
+ } +
+
+ diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.ts b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.ts new file mode 100644 index 000000000000..ab979b7f4bf5 --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.ts @@ -0,0 +1,35 @@ +import {Component} from '@angular/core'; +import { + CdkDragDrop, + moveItemInArray, + copyArrayItem, + CdkDrag, + CdkDropList, +} from '@angular/cdk/drag-drop'; + +/** + * @title Drag&Drop copy between lists + */ +@Component({ + selector: 'cdk-drag-drop-copy-list-example', + templateUrl: 'cdk-drag-drop-copy-list-example.html', + styleUrl: 'cdk-drag-drop-copy-list-example.css', + imports: [CdkDropList, CdkDrag], +}) +export class CdkDragDropCopyListExample { + products = ['Bananas', 'Oranges', 'Bread', 'Butter', 'Soda', 'Eggs']; + cart = ['Tomatoes']; + + drop(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + } else { + copyArrayItem( + event.previousContainer.data, + event.container.data, + event.previousIndex, + event.currentIndex, + ); + } + } +} diff --git a/src/components-examples/cdk/drag-drop/index.ts b/src/components-examples/cdk/drag-drop/index.ts index 37df41d57c2b..e54c8b6a3f8d 100644 --- a/src/components-examples/cdk/drag-drop/index.ts +++ b/src/components-examples/cdk/drag-drop/index.ts @@ -18,3 +18,4 @@ export {CdkDragDropSortPredicateExample} from './cdk-drag-drop-sort-predicate/cd export {CdkDragDropTableExample} from './cdk-drag-drop-table/cdk-drag-drop-table-example'; export {CdkDragDropMixedSortingExample} from './cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example'; export {CdkDragDropTabsExample} from './cdk-drag-drop-tabs/cdk-drag-drop-tabs-example'; +export {CdkDragDropCopyListExample} from './cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example';