Skip to content

feat(cdk/drag-drop): add opt-in indicator of pick-up position #31288

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion goldens/cdk/drag-drop/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,16 @@ export class CdkDropList<T = any> implements OnDestroy {
enterPredicate: (drag: CdkDrag, drop: CdkDropList) => boolean;
readonly exited: EventEmitter<CdkDragExit<T>>;
getSortedItems(): CdkDrag[];
hasAnchor: boolean;
id: string;
lockAxis: DragAxis;
// (undocumented)
static ngAcceptInputType_autoScrollDisabled: unknown;
// (undocumented)
static ngAcceptInputType_disabled: unknown;
// (undocumented)
static ngAcceptInputType_hasAnchor: unknown;
// (undocumented)
static ngAcceptInputType_sortingDisabled: unknown;
// (undocumented)
ngOnDestroy(): void;
Expand All @@ -273,7 +276,7 @@ export class CdkDropList<T = any> implements OnDestroy {
sortingDisabled: boolean;
sortPredicate: (index: number, drag: CdkDrag, drop: CdkDropList) => boolean;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<CdkDropList<any>, "[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<any>, "[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<CdkDropList<any>, never>;
}
Expand Down Expand Up @@ -512,9 +515,11 @@ export class DropListRef<T = any> {
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;
Expand Down
168 changes: 166 additions & 2 deletions src/cdk/drag-drop/directives/drop-list-shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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<HTMLElement>(
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<HTMLElement>(
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<HTMLElement>(
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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) => {
Expand Down
15 changes: 15 additions & 0 deletions src/cdk/drag-drop/directives/drop-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ export class CdkDropList<T = any> 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<CdkDragDrop<T, any>> = new EventEmitter<CdkDragDrop<T, any>>();
Expand Down Expand Up @@ -339,6 +353,7 @@ export class CdkDropList<T = any> 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);
Expand Down
19 changes: 19 additions & 0 deletions src/cdk/drag-drop/drag-drop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -173,6 +174,24 @@ sorting action.

<!-- example(cdk-drag-drop-mixed-sorting) -->

### 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).

<!-- example(cdk-drag-drop-copy-list) -->

### Restricting movement within an element

If you want to stop the user from being able to drag a `cdkDrag` element outside of another element,
Expand Down
Loading
Loading