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;