Skip to content

Commit 8aa7719

Browse files
committed
feat(drag-drop): add the ability to constrain to an element
Adds the `cdkDragBoundry` input that allow for people to constrain the dragging of an element to another element. Fixes #14211.
1 parent df488b0 commit 8aa7719

File tree

2 files changed

+164
-34
lines changed

2 files changed

+164
-34
lines changed

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

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ describe('CdkDrag', () => {
493493
expect(dragElement.style.touchAction)
494494
.not.toEqual('none', 'should not disable touchAction on when there is a drag handle');
495495
});
496+
496497
it('should be able to reset a freely-dragged item to its initial position', fakeAsync(() => {
497498
const fixture = createComponent(StandaloneDraggable);
498499
fixture.detectChanges();
@@ -545,6 +546,17 @@ describe('CdkDrag', () => {
545546
expect(fixture.componentInstance.endedSpy).toHaveBeenCalledTimes(1);
546547
}));
547548

549+
it('should allow for dragging to be constrained to an elemnet', fakeAsync(() => {
550+
const fixture = createComponent(StandaloneDraggable);
551+
fixture.componentInstance.boundrySelector = '.wrapper';
552+
fixture.detectChanges();
553+
const dragElement = fixture.componentInstance.dragElement.nativeElement;
554+
555+
expect(dragElement.style.transform).toBeFalsy();
556+
dragElementViaMouse(fixture, dragElement, 300, 300);
557+
expect(dragElement.style.transform).toBe('translate3d(100px, 100px, 0px)');
558+
}));
559+
548560
});
549561

550562
describe('draggable with a handle', () => {
@@ -989,6 +1001,29 @@ describe('CdkDrag', () => {
9891001
expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM');
9901002
}));
9911003

1004+
it('should be able to constrain the preview position', fakeAsync(() => {
1005+
const fixture = createComponent(DraggableInDropZone);
1006+
fixture.componentInstance.boundrySelector = '.cdk-drop-list';
1007+
fixture.detectChanges();
1008+
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
1009+
const listRect =
1010+
fixture.componentInstance.dropInstance.element.nativeElement.getBoundingClientRect();
1011+
1012+
startDraggingViaMouse(fixture, item);
1013+
1014+
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
1015+
1016+
startDraggingViaMouse(fixture, item, listRect.right + 50, listRect.bottom + 50);
1017+
flush();
1018+
dispatchMouseEvent(document, 'mousemove', listRect.right + 50, listRect.bottom + 50);
1019+
fixture.detectChanges();
1020+
1021+
const previewRect = preview.getBoundingClientRect();
1022+
1023+
expect(Math.floor(previewRect.bottom)).toBe(Math.floor(listRect.bottom));
1024+
expect(Math.floor(previewRect.right)).toBe(Math.floor(listRect.right));
1025+
}));
1026+
9921027
it('should clear the id from the preview', fakeAsync(() => {
9931028
const fixture = createComponent(DraggableInDropZone);
9941029
fixture.detectChanges();
@@ -1040,7 +1075,7 @@ describe('CdkDrag', () => {
10401075
preview.style.transitionDuration = '500ms';
10411076

10421077
// Move somewhere so the draggable doesn't exit immediately.
1043-
dispatchTouchEvent(document, 'mousemove', 50, 50);
1078+
dispatchMouseEvent(document, 'mousemove', 50, 50);
10441079
fixture.detectChanges();
10451080

10461081
dispatchMouseEvent(document, 'mouseup');
@@ -1098,7 +1133,7 @@ describe('CdkDrag', () => {
10981133
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
10991134
preview.style.transition = 'opacity 500ms ease';
11001135

1101-
dispatchTouchEvent(document, 'mousemove', 50, 50);
1136+
dispatchMouseEvent(document, 'mousemove', 50, 50);
11021137
fixture.detectChanges();
11031138

11041139
dispatchMouseEvent(document, 'mouseup');
@@ -1120,7 +1155,7 @@ describe('CdkDrag', () => {
11201155
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
11211156
preview.style.transition = 'opacity 500ms ease, transform 1000ms ease';
11221157

1123-
dispatchTouchEvent(document, 'mousemove', 50, 50);
1158+
dispatchMouseEvent(document, 'mousemove', 50, 50);
11241159
fixture.detectChanges();
11251160

11261161
dispatchMouseEvent(document, 'mouseup');
@@ -1611,6 +1646,29 @@ describe('CdkDrag', () => {
16111646
expect(preview.textContent!.trim()).toContain('Custom preview');
16121647
}));
16131648

1649+
it('should be able to constrain the position of a custom preview', fakeAsync(() => {
1650+
const fixture = createComponent(DraggableInDropZoneWithCustomPreview);
1651+
fixture.componentInstance.boundrySelector = '.cdk-drop-list';
1652+
fixture.detectChanges();
1653+
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
1654+
const listRect =
1655+
fixture.componentInstance.dropInstance.element.nativeElement.getBoundingClientRect();
1656+
1657+
startDraggingViaMouse(fixture, item);
1658+
1659+
const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
1660+
1661+
startDraggingViaMouse(fixture, item, listRect.right + 50, listRect.bottom + 50);
1662+
flush();
1663+
dispatchMouseEvent(document, 'mousemove', listRect.right + 50, listRect.bottom + 50);
1664+
fixture.detectChanges();
1665+
1666+
const previewRect = preview.getBoundingClientRect();
1667+
1668+
expect(Math.floor(previewRect.bottom)).toBe(Math.floor(listRect.bottom));
1669+
expect(Math.floor(previewRect.right)).toBe(Math.floor(listRect.right));
1670+
}));
1671+
16141672
it('should revert the element back to its parent after dragging with a custom ' +
16151673
'preview has stopped', fakeAsync(() => {
16161674
const fixture = createComponent(DraggableInDropZoneWithCustomPreview);
@@ -2214,19 +2272,23 @@ describe('CdkDrag', () => {
22142272

22152273
@Component({
22162274
template: `
2217-
<div
2218-
cdkDrag
2219-
(cdkDragStarted)="startedSpy($event)"
2220-
(cdkDragEnded)="endedSpy($event)"
2221-
#dragElement
2222-
style="width: 100px; height: 100px; background: red;"></div>
2275+
<div class="wrapper" style="width: 200px; height: 200px; background: green;">
2276+
<div
2277+
cdkDrag
2278+
[cdkDragBoundry]="boundrySelector"
2279+
(cdkDragStarted)="startedSpy($event)"
2280+
(cdkDragEnded)="endedSpy($event)"
2281+
#dragElement
2282+
style="width: 100px; height: 100px; background: red;"></div>
2283+
</div>
22232284
`
22242285
})
22252286
class StandaloneDraggable {
22262287
@ViewChild('dragElement') dragElement: ElementRef<HTMLElement>;
22272288
@ViewChild(CdkDrag) dragInstance: CdkDrag;
22282289
startedSpy = jasmine.createSpy('started spy');
22292290
endedSpy = jasmine.createSpy('ended spy');
2291+
boundrySelector: string;
22302292
}
22312293

22322294
@Component({
@@ -2317,6 +2379,7 @@ const DROP_ZONE_FIXTURE_TEMPLATE = `
23172379
*ngFor="let item of items"
23182380
cdkDrag
23192381
[cdkDragData]="item"
2382+
[cdkDragBoundry]="boundrySelector"
23202383
[style.height.px]="item.height"
23212384
[style.margin-bottom.px]="item.margin"
23222385
style="width: 100%; background: red;">{{item.value}}</div>
@@ -2334,6 +2397,7 @@ class DraggableInDropZone {
23342397
{value: 'Three', height: ITEM_HEIGHT, margin: 0}
23352398
];
23362399
dropZoneId = 'items';
2400+
boundrySelector: string;
23372401
sortedSpy = jasmine.createSpy('sorted spy');
23382402
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
23392403
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
@@ -2396,10 +2460,16 @@ class DraggableInHorizontalDropZone {
23962460
@Component({
23972461
template: `
23982462
<div cdkDropList style="width: 100px; background: pink;">
2399-
<div *ngFor="let item of items" cdkDrag
2463+
<div
2464+
*ngFor="let item of items"
2465+
cdkDrag
2466+
[cdkDragBoundry]="boundrySelector"
24002467
style="width: 100%; height: ${ITEM_HEIGHT}px; background: red;">
24012468
{{item}}
2402-
<div class="custom-preview" *cdkDragPreview>Custom preview</div>
2469+
<div
2470+
class="custom-preview"
2471+
style="width: 50px; height: 50px; background: purple;"
2472+
*cdkDragPreview>Custom preview</div>
24032473
</div>
24042474
</div>
24052475
`
@@ -2408,6 +2478,7 @@ class DraggableInDropZoneWithCustomPreview {
24082478
@ViewChild(CdkDropList) dropInstance: CdkDropList;
24092479
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
24102480
items = ['Zero', 'One', 'Two', 'Three'];
2481+
boundrySelector: string;
24112482
}
24122483

24132484

src/cdk/drag-drop/drag.ts

Lines changed: 82 additions & 23 deletions
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 boundry element. */
200+
private _boundryElement?: HTMLElement;
201+
202+
/** Cached dimensions of the preview element. */
203+
private _previewRect?: ClientRect;
204+
205+
/** Cached dimensions of the boundry element. */
206+
private _boundryRect?: 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('cdkDragBoundry') boundryElementSelector: 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._boundryElement = 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._boundryElement = this._getBoundryElement();
434+
435+
if (this._boundryElement) {
436+
this._boundryRect = this._boundryElement.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 boundry element.
498+
if (this._boundryElement) {
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.
@@ -500,7 +530,7 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
500530
this._ngZone.run(() => {
501531
this._moveEvents.next({
502532
source: this,
503-
pointerPosition,
533+
pointerPosition: constrainedPointerPosition,
504534
event,
505535
delta: this._pointerDirectionDelta
506536
});
@@ -554,6 +584,7 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
554584

555585
this._destroyPreview();
556586
this._destroyPlaceholder();
587+
this._boundryRect = this._previewRect = undefined;
557588

558589
// Re-enter the NgZone since we bound `document` events on the outside.
559590
this._ngZone.run(() => {
@@ -760,6 +791,19 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
760791
point.x = this._pickupPositionOnPage.x;
761792
}
762793

794+
if (this._boundryRect) {
795+
const {x: pickupX, y: pickupY} = this._pickupPositionInElement;
796+
const boundryRect = this._boundryRect;
797+
const previewRect = this._previewRect!;
798+
const minY = boundryRect.top + pickupY;
799+
const maxY = boundryRect.bottom - (previewRect.height - pickupY);
800+
const minX = boundryRect.left + pickupX;
801+
const maxX = boundryRect.right - (previewRect.width - pickupX);
802+
803+
point.x = clamp(point.x, minX, maxX);
804+
point.y = clamp(point.y, minY, maxY);
805+
}
806+
763807
return point;
764808
}
765809

@@ -823,22 +867,17 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
823867

824868
/** Gets the root draggable element, based on the `rootElementSelector`. */
825869
private _getRootElement(): HTMLElement {
826-
if (this.rootElementSelector) {
827-
const selector = this.rootElementSelector;
828-
let currentElement = this.element.nativeElement.parentElement as HTMLElement | null;
829-
830-
while (currentElement) {
831-
// IE doesn't support `matches` so we have to fall back to `msMatchesSelector`.
832-
if (currentElement.matches ? currentElement.matches(selector) :
833-
(currentElement as any).msMatchesSelector(selector)) {
834-
return currentElement;
835-
}
836-
837-
currentElement = currentElement.parentElement;
838-
}
839-
}
870+
const element = this.element.nativeElement;
871+
const rootElement = this.rootElementSelector ?
872+
getClosestMatchingAncestor(element, this.rootElementSelector) : null;
840873

841-
return this.element.nativeElement;
874+
return rootElement || element;
875+
}
876+
877+
/** Gets the boundry element, based on the `boundryElementSelector`. */
878+
private _getBoundryElement() {
879+
const selector = this.boundryElementSelector;
880+
return selector ? getClosestMatchingAncestor(this.element.nativeElement, selector) : undefined;
842881
}
843882

844883
/** Unsubscribes from the global subscriptions. */
@@ -870,3 +909,23 @@ function deepCloneNode(node: HTMLElement): HTMLElement {
870909
clone.removeAttribute('id');
871910
return clone;
872911
}
912+
913+
/** Clamps a value between a minimum and a maximum. */
914+
function clamp(value: number, min: number, max: number) {
915+
return Math.max(min, Math.min(max, value));
916+
}
917+
918+
/** Gets the closest ancestor of an element that matches a selector. */
919+
function getClosestMatchingAncestor(element: HTMLElement, selector: string) {
920+
let currentElement = element.parentElement as HTMLElement | null;
921+
922+
while (currentElement) {
923+
// IE doesn't support `matches` so we have to fall back to `msMatchesSelector`.
924+
if (currentElement.matches ? currentElement.matches(selector) :
925+
(currentElement as any).msMatchesSelector(selector)) {
926+
return currentElement;
927+
}
928+
929+
currentElement = currentElement.parentElement;
930+
}
931+
}

0 commit comments

Comments
 (0)