diff --git a/src/cdk/drag-drop/drag.spec.ts b/src/cdk/drag-drop/drag.spec.ts
index 10e8cce7f8d..c56a3526b64 100644
--- a/src/cdk/drag-drop/drag.spec.ts
+++ b/src/cdk/drag-drop/drag.spec.ts
@@ -567,6 +567,17 @@ describe('CdkDrag', () => {
expect(dragElement.style.transform).toBe('translate3d(13px, 37px, 0px)');
}));
+ it('should allow for dragging to be constrained to an element', fakeAsync(() => {
+ const fixture = createComponent(StandaloneDraggable);
+ fixture.componentInstance.boundarySelector = '.wrapper';
+ fixture.detectChanges();
+ const dragElement = fixture.componentInstance.dragElement.nativeElement;
+
+ expect(dragElement.style.transform).toBeFalsy();
+ dragElementViaMouse(fixture, dragElement, 300, 300);
+ expect(dragElement.style.transform).toBe('translate3d(100px, 100px, 0px)');
+ }));
+
});
describe('draggable with a handle', () => {
@@ -1057,6 +1068,29 @@ describe('CdkDrag', () => {
expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM');
}));
+ it('should be able to constrain the preview position', fakeAsync(() => {
+ const fixture = createComponent(DraggableInDropZone);
+ fixture.componentInstance.boundarySelector = '.cdk-drop-list';
+ fixture.detectChanges();
+ const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
+ const listRect =
+ fixture.componentInstance.dropInstance.element.nativeElement.getBoundingClientRect();
+
+ startDraggingViaMouse(fixture, item);
+
+ const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
+
+ startDraggingViaMouse(fixture, item, listRect.right + 50, listRect.bottom + 50);
+ flush();
+ dispatchMouseEvent(document, 'mousemove', listRect.right + 50, listRect.bottom + 50);
+ fixture.detectChanges();
+
+ const previewRect = preview.getBoundingClientRect();
+
+ expect(Math.floor(previewRect.bottom)).toBe(Math.floor(listRect.bottom));
+ expect(Math.floor(previewRect.right)).toBe(Math.floor(listRect.right));
+ }));
+
it('should clear the id from the preview', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
@@ -1108,7 +1142,7 @@ describe('CdkDrag', () => {
preview.style.transitionDuration = '500ms';
// Move somewhere so the draggable doesn't exit immediately.
- dispatchTouchEvent(document, 'mousemove', 50, 50);
+ dispatchMouseEvent(document, 'mousemove', 50, 50);
fixture.detectChanges();
dispatchMouseEvent(document, 'mouseup');
@@ -1166,7 +1200,7 @@ describe('CdkDrag', () => {
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
preview.style.transition = 'opacity 500ms ease';
- dispatchTouchEvent(document, 'mousemove', 50, 50);
+ dispatchMouseEvent(document, 'mousemove', 50, 50);
fixture.detectChanges();
dispatchMouseEvent(document, 'mouseup');
@@ -1188,7 +1222,7 @@ describe('CdkDrag', () => {
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
preview.style.transition = 'opacity 500ms ease, transform 1000ms ease';
- dispatchTouchEvent(document, 'mousemove', 50, 50);
+ dispatchMouseEvent(document, 'mousemove', 50, 50);
fixture.detectChanges();
dispatchMouseEvent(document, 'mouseup');
@@ -1679,6 +1713,29 @@ describe('CdkDrag', () => {
expect(preview.textContent!.trim()).toContain('Custom preview');
}));
+ it('should be able to constrain the position of a custom preview', fakeAsync(() => {
+ const fixture = createComponent(DraggableInDropZoneWithCustomPreview);
+ fixture.componentInstance.boundarySelector = '.cdk-drop-list';
+ fixture.detectChanges();
+ const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
+ const listRect =
+ fixture.componentInstance.dropInstance.element.nativeElement.getBoundingClientRect();
+
+ startDraggingViaMouse(fixture, item);
+
+ const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
+
+ startDraggingViaMouse(fixture, item, listRect.right + 50, listRect.bottom + 50);
+ flush();
+ dispatchMouseEvent(document, 'mousemove', listRect.right + 50, listRect.bottom + 50);
+ fixture.detectChanges();
+
+ const previewRect = preview.getBoundingClientRect();
+
+ expect(Math.floor(previewRect.bottom)).toBe(Math.floor(listRect.bottom));
+ expect(Math.floor(previewRect.right)).toBe(Math.floor(listRect.right));
+ }));
+
it('should revert the element back to its parent after dragging with a custom ' +
'preview has stopped', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZoneWithCustomPreview);
@@ -2298,12 +2355,15 @@ describe('CdkDrag', () => {
@Component({
template: `
-
+
`
})
class StandaloneDraggable {
@@ -2311,6 +2371,7 @@ class StandaloneDraggable {
@ViewChild(CdkDrag) dragInstance: CdkDrag;
startedSpy = jasmine.createSpy('started spy');
endedSpy = jasmine.createSpy('ended spy');
+ boundarySelector: string;
}
@Component({
@@ -2414,6 +2475,7 @@ const DROP_ZONE_FIXTURE_TEMPLATE = `
*ngFor="let item of items"
cdkDrag
[cdkDragData]="item"
+ [cdkDragBoundary]="boundarySelector"
[style.height.px]="item.height"
[style.margin-bottom.px]="item.margin"
style="width: 100%; background: red;">{{item.value}}
@@ -2431,6 +2493,7 @@ class DraggableInDropZone {
{value: 'Three', height: ITEM_HEIGHT, margin: 0}
];
dropZoneId = 'items';
+ boundarySelector: string;
sortedSpy = jasmine.createSpy('sorted spy');
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop) => {
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
@@ -2493,10 +2556,16 @@ class DraggableInHorizontalDropZone {
@Component({
template: `
-
{{item}}
-
Custom preview
+
Custom preview
`
@@ -2505,6 +2574,7 @@ class DraggableInDropZoneWithCustomPreview {
@ViewChild(CdkDropList) dropInstance: CdkDropList;
@ViewChildren(CdkDrag) dragItems: QueryList;
items = ['Zero', 'One', 'Two', 'Three'];
+ boundarySelector: string;
}
diff --git a/src/cdk/drag-drop/drag.ts b/src/cdk/drag-drop/drag.ts
index 6ab2b357687..a5910622610 100644
--- a/src/cdk/drag-drop/drag.ts
+++ b/src/cdk/drag-drop/drag.ts
@@ -196,6 +196,15 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
/** Subscription to the stream that initializes the root element. */
private _rootElementInitSubscription = Subscription.EMPTY;
+ /** Cached reference to the boundary element. */
+ private _boundaryElement?: HTMLElement;
+
+ /** Cached dimensions of the preview element. */
+ private _previewRect?: ClientRect;
+
+ /** Cached dimensions of the boundary element. */
+ private _boundaryRect?: ClientRect;
+
/** Elements that can be used to drag the draggable item. */
@ContentChildren(CdkDragHandle, {descendants: true}) _handles: QueryList;
@@ -218,6 +227,13 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
*/
@Input('cdkDragRootElement') rootElementSelector: string;
+ /**
+ * Selector that will be used to determine the element to which the draggable's position will
+ * be constrained. Matching starts from the element's parent and goes up the DOM until a matching
+ * element has been found.
+ */
+ @Input('cdkDragBoundary') boundaryElementSelector: string;
+
/** Whether starting to drag this element is disabled. */
@Input('cdkDragDisabled')
get disabled(): boolean {
@@ -334,7 +350,7 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
this._rootElementInitSubscription.unsubscribe();
this._destroyPreview();
this._destroyPlaceholder();
- this._nextSibling = null;
+ this._boundaryElement = this._nextSibling = null!;
this._dragDropRegistry.removeDragItem(this);
this._removeSubscriptions();
this._moveEvents.complete();
@@ -414,6 +430,11 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(this._pointerMove);
this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(this._pointerUp);
this._scrollPosition = this._viewportRuler.getViewportScrollPosition();
+ this._boundaryElement = this._getBoundaryElement();
+
+ if (this._boundaryElement) {
+ this._boundaryRect = this._boundaryElement.getBoundingClientRect();
+ }
// If we have a custom preview template, the element won't be visible anyway so we avoid the
// extra `getBoundingClientRect` calls and just move the preview next to the cursor.
@@ -456,9 +477,8 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
/** Handler that is invoked when the user moves their pointer after they've initiated a drag. */
private _pointerMove = (event: MouseEvent | TouchEvent) => {
- const pointerPosition = this._getConstrainedPointerPosition(event);
-
if (!this._hasStartedDragging) {
+ const pointerPosition = this._getPointerPositionOnPage(event);
const distanceX = Math.abs(pointerPosition.x - this._pickupPositionOnPage.x);
const distanceY = Math.abs(pointerPosition.y - this._pickupPositionOnPage.y);
@@ -474,18 +494,28 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
return;
}
+ // We only need the preview dimensions if we have a boundary element.
+ if (this._boundaryElement) {
+ // Cache the preview element rect if we haven't cached it already or if
+ // we cached it too early before the element dimensions were computed.
+ if (!this._previewRect || (!this._previewRect.width && !this._previewRect.height)) {
+ this._previewRect = (this._preview || this._rootElement).getBoundingClientRect();
+ }
+ }
+
+ const constrainedPointerPosition = this._getConstrainedPointerPosition(event);
this._hasMoved = true;
event.preventDefault();
- this._updatePointerDirectionDelta(pointerPosition);
+ this._updatePointerDirectionDelta(constrainedPointerPosition);
if (this.dropContainer) {
- this._updateActiveDropContainer(pointerPosition);
+ this._updateActiveDropContainer(constrainedPointerPosition);
} else {
const activeTransform = this._activeTransform;
activeTransform.x =
- pointerPosition.x - this._pickupPositionOnPage.x + this._passiveTransform.x;
+ constrainedPointerPosition.x - this._pickupPositionOnPage.x + this._passiveTransform.x;
activeTransform.y =
- pointerPosition.y - this._pickupPositionOnPage.y + this._passiveTransform.y;
+ constrainedPointerPosition.y - this._pickupPositionOnPage.y + this._passiveTransform.y;
const transform = getTransform(activeTransform.x, activeTransform.y);
// Preserve the previous `transform` value, if there was one.
@@ -506,7 +536,7 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
this._ngZone.run(() => {
this._moveEvents.next({
source: this,
- pointerPosition,
+ pointerPosition: constrainedPointerPosition,
event,
delta: this._pointerDirectionDelta
});
@@ -560,6 +590,7 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
this._destroyPreview();
this._destroyPlaceholder();
+ this._boundaryRect = this._previewRect = undefined;
// Re-enter the NgZone since we bound `document` events on the outside.
this._ngZone.run(() => {
@@ -769,6 +800,19 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
point.x = this._pickupPositionOnPage.x;
}
+ if (this._boundaryRect) {
+ const {x: pickupX, y: pickupY} = this._pickupPositionInElement;
+ const boundaryRect = this._boundaryRect;
+ const previewRect = this._previewRect!;
+ const minY = boundaryRect.top + pickupY;
+ const maxY = boundaryRect.bottom - (previewRect.height - pickupY);
+ const minX = boundaryRect.left + pickupX;
+ const maxX = boundaryRect.right - (previewRect.width - pickupX);
+
+ point.x = clamp(point.x, minX, maxX);
+ point.y = clamp(point.y, minY, maxY);
+ }
+
return point;
}
@@ -832,22 +876,17 @@ export class CdkDrag implements AfterViewInit, OnDestroy {
/** Gets the root draggable element, based on the `rootElementSelector`. */
private _getRootElement(): HTMLElement {
- if (this.rootElementSelector) {
- const selector = this.rootElementSelector;
- let currentElement = this.element.nativeElement.parentElement as HTMLElement | null;
+ const element = this.element.nativeElement;
+ const rootElement = this.rootElementSelector ?
+ getClosestMatchingAncestor(element, this.rootElementSelector) : null;
- while (currentElement) {
- // IE doesn't support `matches` so we have to fall back to `msMatchesSelector`.
- if (currentElement.matches ? currentElement.matches(selector) :
- (currentElement as any).msMatchesSelector(selector)) {
- return currentElement;
- }
+ return rootElement || element;
+ }
- currentElement = currentElement.parentElement;
- }
- }
-
- return this.element.nativeElement;
+ /** Gets the boundary element, based on the `boundaryElementSelector`. */
+ private _getBoundaryElement() {
+ const selector = this.boundaryElementSelector;
+ return selector ? getClosestMatchingAncestor(this.element.nativeElement, selector) : undefined;
}
/** Unsubscribes from the global subscriptions. */
@@ -881,3 +920,23 @@ function deepCloneNode(node: HTMLElement): HTMLElement {
clone.removeAttribute('id');
return clone;
}
+
+/** Clamps a value between a minimum and a maximum. */
+function clamp(value: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, value));
+}
+
+/** Gets the closest ancestor of an element that matches a selector. */
+function getClosestMatchingAncestor(element: HTMLElement, selector: string) {
+ let currentElement = element.parentElement as HTMLElement | null;
+
+ while (currentElement) {
+ // IE doesn't support `matches` so we have to fall back to `msMatchesSelector`.
+ if (currentElement.matches ? currentElement.matches(selector) :
+ (currentElement as any).msMatchesSelector(selector)) {
+ return currentElement;
+ }
+
+ currentElement = currentElement.parentElement;
+ }
+}
diff --git a/tools/public_api_guard/cdk/drag-drop.d.ts b/tools/public_api_guard/cdk/drag-drop.d.ts
index 011421f7c27..8790915aeb4 100644
--- a/tools/public_api_guard/cdk/drag-drop.d.ts
+++ b/tools/public_api_guard/cdk/drag-drop.d.ts
@@ -10,6 +10,7 @@ export declare class CdkDrag implements AfterViewInit, OnDestroy {
_placeholderTemplate: CdkDragPlaceholder;
_pointerDown: (event: TouchEvent | MouseEvent) => void;
_previewTemplate: CdkDragPreview;
+ boundaryElementSelector: string;
data: T;
disabled: boolean;
dropContainer: CdkDropListContainer;