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.
This commit is contained in:
Kristiyan Kostadinov
2018-12-04 00:38:00 +01:00
committed by mmalerba
parent 7fac91554e
commit 8432e804af
3 changed files with 163 additions and 33 deletions
+81 -11
View File
@@ -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: `
<div
cdkDrag
(cdkDragStarted)="startedSpy($event)"
(cdkDragEnded)="endedSpy($event)"
#dragElement
style="width: 100px; height: 100px; background: red;"></div>
<div class="wrapper" style="width: 200px; height: 200px; background: green;">
<div
cdkDrag
[cdkDragBoundary]="boundarySelector"
(cdkDragStarted)="startedSpy($event)"
(cdkDragEnded)="endedSpy($event)"
#dragElement
style="width: 100px; height: 100px; background: red;"></div>
</div>
`
})
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}}</div>
@@ -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<string[]>) => {
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
@@ -2493,10 +2556,16 @@ class DraggableInHorizontalDropZone {
@Component({
template: `
<div cdkDropList style="width: 100px; background: pink;">
<div *ngFor="let item of items" cdkDrag
<div
*ngFor="let item of items"
cdkDrag
[cdkDragBoundary]="boundarySelector"
style="width: 100%; height: ${ITEM_HEIGHT}px; background: red;">
{{item}}
<div class="custom-preview" *cdkDragPreview>Custom preview</div>
<div
class="custom-preview"
style="width: 50px; height: 50px; background: purple;"
*cdkDragPreview>Custom preview</div>
</div>
</div>
`
@@ -2505,6 +2574,7 @@ class DraggableInDropZoneWithCustomPreview {
@ViewChild(CdkDropList) dropInstance: CdkDropList;
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
items = ['Zero', 'One', 'Two', 'Three'];
boundarySelector: string;
}
+81 -22
View File
@@ -196,6 +196,15 @@ export class CdkDrag<T = any> 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<CdkDragHandle>;
@@ -218,6 +227,13 @@ export class CdkDrag<T = any> 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<T = any> 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<T = any> 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<T = any> 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<T = any> 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<T = any> 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<T = any> 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<T = any> 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<T = any> 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;
}
}
+1
View File
@@ -10,6 +10,7 @@ export declare class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
_placeholderTemplate: CdkDragPlaceholder;
_pointerDown: (event: TouchEvent | MouseEvent) => void;
_previewTemplate: CdkDragPreview;
boundaryElementSelector: string;
data: T;
disabled: boolean;
dropContainer: CdkDropListContainer;