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';