Skip to content

Commit 8432e80

Browse files
crisbetommalerba
authored andcommittedDec 3, 2018
feat(drag-drop): add the ability to constrain dragging to an element (#14242)
Adds the `cdkDragBoundary` input that allows for people to constrain the dragging of an element to another element. Fixes #14211.
1 parent 7fac915 commit 8432e80

File tree

3 files changed

+164
-34
lines changed

3 files changed

+164
-34
lines changed
 

‎src/cdk/drag-drop/drag.spec.ts

+81-11
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,17 @@ describe('CdkDrag', () => {
567567
expect(dragElement.style.transform).toBe('translate3d(13px, 37px, 0px)');
568568
}));
569569

570+
it('should allow for dragging to be constrained to an element', fakeAsync(() => {
571+
const fixture = createComponent(StandaloneDraggable);
572+
fixture.componentInstance.boundarySelector = '.wrapper';
573+
fixture.detectChanges();
574+
const dragElement = fixture.componentInstance.dragElement.nativeElement;
575+
576+
expect(dragElement.style.transform).toBeFalsy();
577+
dragElementViaMouse(fixture, dragElement, 300, 300);
578+
expect(dragElement.style.transform).toBe('translate3d(100px, 100px, 0px)');
579+
}));
580+
570581
});
571582

572583
describe('draggable with a handle', () => {
@@ -1057,6 +1068,29 @@ describe('CdkDrag', () => {
10571068
expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM');
10581069
}));
10591070

1071+
it('should be able to constrain the preview position', fakeAsync(() => {
1072+
const fixture = createComponent(DraggableInDropZone);
1073+
fixture.componentInstance.boundarySelector = '.cdk-drop-list';
1074+
fixture.detectChanges();
1075+
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
1076+
const listRect =
1077+
fixture.componentInstance.dropInstance.element.nativeElement.getBoundingClientRect();
1078+
1079+
startDraggingViaMouse(fixture, item);
1080+
1081+
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
1082+
1083+
startDraggingViaMouse(fixture, item, listRect.right + 50, listRect.bottom + 50);
1084+
flush();
1085+
dispatchMouseEvent(document, 'mousemove', listRect.right + 50, listRect.bottom + 50);
1086+
fixture.detectChanges();
1087+
1088+
const previewRect = preview.getBoundingClientRect();
1089+
1090+
expect(Math.floor(previewRect.bottom)).toBe(Math.floor(listRect.bottom));
1091+
expect(Math.floor(previewRect.right)).toBe(Math.floor(listRect.right));
1092+
}));
1093+
10601094
it('should clear the id from the preview', fakeAsync(() => {
10611095
const fixture = createComponent(DraggableInDropZone);
10621096
fixture.detectChanges();
@@ -1108,7 +1142,7 @@ describe('CdkDrag', () => {
11081142
preview.style.transitionDuration = '500ms';
11091143

11101144
// Move somewhere so the draggable doesn't exit immediately.
1111-
dispatchTouchEvent(document, 'mousemove', 50, 50);
1145+
dispatchMouseEvent(document, 'mousemove', 50, 50);
11121146
fixture.detectChanges();
11131147

11141148
dispatchMouseEvent(document, 'mouseup');
@@ -1166,7 +1200,7 @@ describe('CdkDrag', () => {
11661200
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
11671201
preview.style.transition = 'opacity 500ms ease';
11681202

1169-
dispatchTouchEvent(document, 'mousemove', 50, 50);
1203+
dispatchMouseEvent(document, 'mousemove', 50, 50);
11701204
fixture.detectChanges();
11711205

11721206
dispatchMouseEvent(document, 'mouseup');
@@ -1188,7 +1222,7 @@ describe('CdkDrag', () => {
11881222
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
11891223
preview.style.transition = 'opacity 500ms ease, transform 1000ms ease';
11901224

1191-
dispatchTouchEvent(document, 'mousemove', 50, 50);
1225+
dispatchMouseEvent(document, 'mousemove', 50, 50);
11921226
fixture.detectChanges();
11931227

11941228
dispatchMouseEvent(document, 'mouseup');
@@ -1679,6 +1713,29 @@ describe('CdkDrag', () => {
16791713
expect(preview.textContent!.trim()).toContain('Custom preview');
16801714
}));
16811715

1716+
it('should be able to constrain the position of a custom preview', fakeAsync(() => {
1717+
const fixture = createComponent(DraggableInDropZoneWithCustomPreview);
1718+
fixture.componentInstance.boundarySelector = '.cdk-drop-list';
1719+
fixture.detectChanges();
1720+
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
1721+
const listRect =
1722+
fixture.componentInstance.dropInstance.element.nativeElement.getBoundingClientRect();
1723+
1724+
startDraggingViaMouse(fixture, item);
1725+
1726+
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
1727+
1728+
startDraggingViaMouse(fixture, item, listRect.right + 50, listRect.bottom + 50);
1729+
flush();
1730+
dispatchMouseEvent(document, 'mousemove', listRect.right + 50, listRect.bottom + 50);
1731+
fixture.detectChanges();
1732+
1733+
const previewRect = preview.getBoundingClientRect();
1734+
1735+
expect(Math.floor(previewRect.bottom)).toBe(Math.floor(listRect.bottom));
1736+
expect(Math.floor(previewRect.right)).toBe(Math.floor(listRect.right));
1737+
}));
1738+
16821739
it('should revert the element back to its parent after dragging with a custom ' +
16831740
'preview has stopped', fakeAsync(() => {
16841741
const fixture = createComponent(DraggableInDropZoneWithCustomPreview);
@@ -2298,19 +2355,23 @@ describe('CdkDrag', () => {
22982355

22992356
@Component({
23002357
template: `
2301-
<div
2302-
cdkDrag
2303-
(cdkDragStarted)="startedSpy($event)"
2304-
(cdkDragEnded)="endedSpy($event)"
2305-
#dragElement
2306-
style="width: 100px; height: 100px; background: red;"></div>
2358+
<div class="wrapper" style="width: 200px; height: 200px; background: green;">
2359+
<div
2360+
cdkDrag
2361+
[cdkDragBoundary]="boundarySelector"
2362+
(cdkDragStarted)="startedSpy($event)"
2363+
(cdkDragEnded)="endedSpy($event)"
2364+
#dragElement
2365+
style="width: 100px; height: 100px; background: red;"></div>
2366+
</div>
23072367
`
23082368
})
23092369
class StandaloneDraggable {
23102370
@ViewChild('dragElement') dragElement: ElementRef<HTMLElement>;
23112371
@ViewChild(CdkDrag) dragInstance: CdkDrag;
23122372
startedSpy = jasmine.createSpy('started spy');
23132373
endedSpy = jasmine.createSpy('ended spy');
2374+
boundarySelector: string;
23142375
}
23152376

23162377
@Component({
@@ -2414,6 +2475,7 @@ const DROP_ZONE_FIXTURE_TEMPLATE = `
24142475
*ngFor="let item of items"
24152476
cdkDrag
24162477
[cdkDragData]="item"
2478+
[cdkDragBoundary]="boundarySelector"
24172479
[style.height.px]="item.height"
24182480
[style.margin-bottom.px]="item.margin"
24192481
style="width: 100%; background: red;">{{item.value}}</div>
@@ -2431,6 +2493,7 @@ class DraggableInDropZone {
24312493
{value: 'Three', height: ITEM_HEIGHT, margin: 0}
24322494
];
24332495
dropZoneId = 'items';
2496+
boundarySelector: string;
24342497
sortedSpy = jasmine.createSpy('sorted spy');
24352498
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
24362499
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
@@ -2493,10 +2556,16 @@ class DraggableInHorizontalDropZone {
24932556
@Component({
24942557
template: `
24952558
<div cdkDropList style="width: 100px; background: pink;">
2496-
<div *ngFor="let item of items" cdkDrag
2559+
<div
2560+
*ngFor="let item of items"
2561+
cdkDrag
2562+
[cdkDragBoundary]="boundarySelector"
24972563
style="width: 100%; height: ${ITEM_HEIGHT}px; background: red;">
24982564
{{item}}
2499-
<div class="custom-preview" *cdkDragPreview>Custom preview</div>
2565+
<div
2566+
class="custom-preview"
2567+
style="width: 50px; height: 50px; background: purple;"
2568+
*cdkDragPreview>Custom preview</div>
25002569
</div>
25012570
</div>
25022571
`
@@ -2505,6 +2574,7 @@ class DraggableInDropZoneWithCustomPreview {
25052574
@ViewChild(CdkDropList) dropInstance: CdkDropList;
25062575
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
25072576
items = ['Zero', 'One', 'Two', 'Three'];
2577+
boundarySelector: string;
25082578
}
25092579

25102580

‎src/cdk/drag-drop/drag.ts

+82-23
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,15 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
196196
/** Subscription to the stream that initializes the root element. */
197197
private _rootElementInitSubscription = Subscription.EMPTY;
198198

199+
/** Cached reference to the boundary element. */
200+
private _boundaryElement?: HTMLElement;
201+
202+
/** Cached dimensions of the preview element. */
203+
private _previewRect?: ClientRect;
204+
205+
/** Cached dimensions of the boundary element. */
206+
private _boundaryRect?: ClientRect;
207+
199208
/** Elements that can be used to drag the draggable item. */
200209
@ContentChildren(CdkDragHandle, {descendants: true}) _handles: QueryList<CdkDragHandle>;
201210

@@ -218,6 +227,13 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
218227
*/
219228
@Input('cdkDragRootElement') rootElementSelector: string;
220229

230+
/**
231+
* Selector that will be used to determine the element to which the draggable's position will
232+
* be constrained. Matching starts from the element's parent and goes up the DOM until a matching
233+
* element has been found.
234+
*/
235+
@Input('cdkDragBoundary') boundaryElementSelector: string;
236+
221237
/** Whether starting to drag this element is disabled. */
222238
@Input('cdkDragDisabled')
223239
get disabled(): boolean {
@@ -334,7 +350,7 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
334350
this._rootElementInitSubscription.unsubscribe();
335351
this._destroyPreview();
336352
this._destroyPlaceholder();
337-
this._nextSibling = null;
353+
this._boundaryElement = this._nextSibling = null!;
338354
this._dragDropRegistry.removeDragItem(this);
339355
this._removeSubscriptions();
340356
this._moveEvents.complete();
@@ -414,6 +430,11 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
414430
this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(this._pointerMove);
415431
this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(this._pointerUp);
416432
this._scrollPosition = this._viewportRuler.getViewportScrollPosition();
433+
this._boundaryElement = this._getBoundaryElement();
434+
435+
if (this._boundaryElement) {
436+
this._boundaryRect = this._boundaryElement.getBoundingClientRect();
437+
}
417438

418439
// If we have a custom preview template, the element won't be visible anyway so we avoid the
419440
// extra `getBoundingClientRect` calls and just move the preview next to the cursor.
@@ -456,9 +477,8 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
456477

457478
/** Handler that is invoked when the user moves their pointer after they've initiated a drag. */
458479
private _pointerMove = (event: MouseEvent | TouchEvent) => {
459-
const pointerPosition = this._getConstrainedPointerPosition(event);
460-
461480
if (!this._hasStartedDragging) {
481+
const pointerPosition = this._getPointerPositionOnPage(event);
462482
const distanceX = Math.abs(pointerPosition.x - this._pickupPositionOnPage.x);
463483
const distanceY = Math.abs(pointerPosition.y - this._pickupPositionOnPage.y);
464484

@@ -474,18 +494,28 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
474494
return;
475495
}
476496

497+
// We only need the preview dimensions if we have a boundary element.
498+
if (this._boundaryElement) {
499+
// Cache the preview element rect if we haven't cached it already or if
500+
// we cached it too early before the element dimensions were computed.
501+
if (!this._previewRect || (!this._previewRect.width && !this._previewRect.height)) {
502+
this._previewRect = (this._preview || this._rootElement).getBoundingClientRect();
503+
}
504+
}
505+
506+
const constrainedPointerPosition = this._getConstrainedPointerPosition(event);
477507
this._hasMoved = true;
478508
event.preventDefault();
479-
this._updatePointerDirectionDelta(pointerPosition);
509+
this._updatePointerDirectionDelta(constrainedPointerPosition);
480510

481511
if (this.dropContainer) {
482-
this._updateActiveDropContainer(pointerPosition);
512+
this._updateActiveDropContainer(constrainedPointerPosition);
483513
} else {
484514
const activeTransform = this._activeTransform;
485515
activeTransform.x =
486-
pointerPosition.x - this._pickupPositionOnPage.x + this._passiveTransform.x;
516+
constrainedPointerPosition.x - this._pickupPositionOnPage.x + this._passiveTransform.x;
487517
activeTransform.y =
488-
pointerPosition.y - this._pickupPositionOnPage.y + this._passiveTransform.y;
518+
constrainedPointerPosition.y - this._pickupPositionOnPage.y + this._passiveTransform.y;
489519
const transform = getTransform(activeTransform.x, activeTransform.y);
490520

491521
// Preserve the previous `transform` value, if there was one.
@@ -506,7 +536,7 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
506536
this._ngZone.run(() => {
507537
this._moveEvents.next({
508538
source: this,
509-
pointerPosition,
539+
pointerPosition: constrainedPointerPosition,
510540
event,
511541
delta: this._pointerDirectionDelta
512542
});
@@ -560,6 +590,7 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
560590

561591
this._destroyPreview();
562592
this._destroyPlaceholder();
593+
this._boundaryRect = this._previewRect = undefined;
563594

564595
// Re-enter the NgZone since we bound `document` events on the outside.
565596
this._ngZone.run(() => {
@@ -769,6 +800,19 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
769800
point.x = this._pickupPositionOnPage.x;
770801
}
771802

803+
if (this._boundaryRect) {
804+
const {x: pickupX, y: pickupY} = this._pickupPositionInElement;
805+
const boundaryRect = this._boundaryRect;
806+
const previewRect = this._previewRect!;
807+
const minY = boundaryRect.top + pickupY;
808+
const maxY = boundaryRect.bottom - (previewRect.height - pickupY);
809+
const minX = boundaryRect.left + pickupX;
810+
const maxX = boundaryRect.right - (previewRect.width - pickupX);
811+
812+
point.x = clamp(point.x, minX, maxX);
813+
point.y = clamp(point.y, minY, maxY);
814+
}
815+
772816
return point;
773817
}
774818

@@ -832,22 +876,17 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
832876

833877
/** Gets the root draggable element, based on the `rootElementSelector`. */
834878
private _getRootElement(): HTMLElement {
835-
if (this.rootElementSelector) {
836-
const selector = this.rootElementSelector;
837-
let currentElement = this.element.nativeElement.parentElement as HTMLElement | null;
838-
839-
while (currentElement) {
840-
// IE doesn't support `matches` so we have to fall back to `msMatchesSelector`.
841-
if (currentElement.matches ? currentElement.matches(selector) :
842-
(currentElement as any).msMatchesSelector(selector)) {
843-
return currentElement;
844-
}
845-
846-
currentElement = currentElement.parentElement;
847-
}
848-
}
879+
const element = this.element.nativeElement;
880+
const rootElement = this.rootElementSelector ?
881+
getClosestMatchingAncestor(element, this.rootElementSelector) : null;
849882

850-
return this.element.nativeElement;
883+
return rootElement || element;
884+
}
885+
886+
/** Gets the boundary element, based on the `boundaryElementSelector`. */
887+
private _getBoundaryElement() {
888+
const selector = this.boundaryElementSelector;
889+
return selector ? getClosestMatchingAncestor(this.element.nativeElement, selector) : undefined;
851890
}
852891

853892
/** Unsubscribes from the global subscriptions. */
@@ -881,3 +920,23 @@ function deepCloneNode(node: HTMLElement): HTMLElement {
881920
clone.removeAttribute('id');
882921
return clone;
883922
}
923+
924+
/** Clamps a value between a minimum and a maximum. */
925+
function clamp(value: number, min: number, max: number) {
926+
return Math.max(min, Math.min(max, value));
927+
}
928+
929+
/** Gets the closest ancestor of an element that matches a selector. */
930+
function getClosestMatchingAncestor(element: HTMLElement, selector: string) {
931+
let currentElement = element.parentElement as HTMLElement | null;
932+
933+
while (currentElement) {
934+
// IE doesn't support `matches` so we have to fall back to `msMatchesSelector`.
935+
if (currentElement.matches ? currentElement.matches(selector) :
936+
(currentElement as any).msMatchesSelector(selector)) {
937+
return currentElement;
938+
}
939+
940+
currentElement = currentElement.parentElement;
941+
}
942+
}

‎tools/public_api_guard/cdk/drag-drop.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export declare class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
1010
_placeholderTemplate: CdkDragPlaceholder;
1111
_pointerDown: (event: TouchEvent | MouseEvent) => void;
1212
_previewTemplate: CdkDragPreview;
13+
boundaryElementSelector: string;
1314
data: T;
1415
disabled: boolean;
1516
dropContainer: CdkDropListContainer;

0 commit comments

Comments
 (0)
Please sign in to comment.