Skip to content

Commit 5d0ec23

Browse files
crisbetojelbourn
authored andcommitted
feat(drag-drop): add the ability to set an alternate root element (#12895)
Adds the `cdkDragRootElement` input which allows consumers to pass in a selector that will be used to determine which elements is going to become draggable, starting from the `cdkDrag` element and going up the DOM.
1 parent 4e41985 commit 5d0ec23

File tree

8 files changed

+194
-34
lines changed

8 files changed

+194
-34
lines changed

src/cdk/drag-drop/drag-drop.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,12 @@ specific axis, you can set `cdkDragLockAxis` on `cdkDrag` or `lockAxis` on `<cdk
116116
to either `"x"` or `"y"`.
117117

118118
<!-- example(cdk-drag-drop-axis-lock) -->
119+
120+
### Alternate drag root element
121+
If there's an element that you want to make draggable, but you don't have direct access to it, you
122+
can use the `cdkDragRootElement` attribute. The attribute works by accepting a selector and looking
123+
up the DOM until it finds an element that matches the selector. If an element is found, it'll become
124+
the element that is moved as the user is dragging. This is useful for cases like making a dialog
125+
draggable.
126+
127+
<!-- example(cdk-drag-drop-root-element) -->

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,22 @@ describe('CdkDrag', () => {
322322
expect(element.classList).not.toContain('cdk-drag-dragging');
323323
}));
324324

325+
it('should be able to set an alternate drag root element', fakeAsync(() => {
326+
const fixture = createComponent(DraggableWithAlternateRoot);
327+
fixture.detectChanges();
328+
329+
const dragRoot = fixture.componentInstance.dragRoot.nativeElement;
330+
const dragElement = fixture.componentInstance.dragElement.nativeElement;
331+
332+
expect(dragRoot.style.transform).toBeFalsy();
333+
expect(dragElement.style.transform).toBeFalsy();
334+
335+
dragElementViaMouse(fixture, dragRoot, 50, 100);
336+
337+
expect(dragRoot.style.transform).toBe('translate3d(50px, 100px, 0px)');
338+
expect(dragElement.style.transform).toBeFalsy();
339+
}));
340+
325341
});
326342

327343
describe('draggable with a handle', () => {
@@ -1597,6 +1613,23 @@ class ConnectedDropZones implements AfterViewInit {
15971613
}
15981614

15991615

1616+
@Component({
1617+
template: `
1618+
<div #dragRoot class="alternate-root" style="width: 200px; height: 200px; background: hotpink">
1619+
<div
1620+
cdkDrag
1621+
cdkDragRootElement=".alternate-root"
1622+
#dragElement
1623+
style="width: 100px; height: 100px; background: red;"></div>
1624+
</div>
1625+
`
1626+
})
1627+
class DraggableWithAlternateRoot {
1628+
@ViewChild('dragElement') dragElement: ElementRef<HTMLElement>;
1629+
@ViewChild('dragRoot') dragRoot: ElementRef<HTMLElement>;
1630+
@ViewChild(CdkDrag) dragInstance: CdkDrag;
1631+
}
1632+
16001633

16011634
/**
16021635
* Drags an element to a position on the page using the mouse.

src/cdk/drag-drop/drag.ts

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {Directionality} from '@angular/cdk/bidi';
1010
import {ViewportRuler} from '@angular/cdk/scrolling';
1111
import {DOCUMENT} from '@angular/common';
1212
import {
13+
AfterViewInit,
1314
ContentChild,
1415
ContentChildren,
1516
Directive,
@@ -27,7 +28,7 @@ import {
2728
ViewContainerRef,
2829
} from '@angular/core';
2930
import {merge, Observable, Subject} from 'rxjs';
30-
import {takeUntil} from 'rxjs/operators';
31+
import {takeUntil, take} from 'rxjs/operators';
3132
import {DragDropRegistry} from './drag-drop-registry';
3233
import {
3334
CdkDragDrop,
@@ -60,11 +61,9 @@ const POINTER_DIRECTION_CHANGE_THRESHOLD = 5;
6061
host: {
6162
'class': 'cdk-drag',
6263
'[class.cdk-drag-dragging]': '_isDragging()',
63-
'(mousedown)': '_startDragging($event)',
64-
'(touchstart)': '_startDragging($event)',
6564
}
6665
})
67-
export class CdkDrag<T = any> implements OnDestroy {
66+
export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
6867
private _document: Document;
6968
private _destroyed = new Subject<void>();
7069

@@ -127,6 +126,9 @@ export class CdkDrag<T = any> implements OnDestroy {
127126
/** Pointer position at which the last change in the delta occurred. */
128127
private _pointerPositionAtLastDirectionChange: Point;
129128

129+
/** Root element that will be dragged by the user. */
130+
private _rootElement: HTMLElement;
131+
130132
/** Elements that can be used to drag the draggable item. */
131133
@ContentChildren(CdkDragHandle) _handles: QueryList<CdkDragHandle>;
132134

@@ -142,6 +144,13 @@ export class CdkDrag<T = any> implements OnDestroy {
142144
/** Locks the position of the dragged element along the specified axis. */
143145
@Input('cdkDragLockAxis') lockAxis: 'x' | 'y';
144146

147+
/**
148+
* Selector that will be used to determine the root draggable element, starting from
149+
* the `cdkDrag` element and going up the DOM. Passing an alternate root element is useful
150+
* when trying to enable dragging on an element that you might not have access to.
151+
*/
152+
@Input('cdkDragRootElement') rootElementSelector: string;
153+
145154
/** Emits when the user starts dragging the item. */
146155
@Output('cdkDragStarted') started: EventEmitter<CdkDragStart> = new EventEmitter<CdkDragStart>();
147156

@@ -197,7 +206,26 @@ export class CdkDrag<T = any> implements OnDestroy {
197206
return this._placeholder;
198207
}
199208

209+
/** Returns the root draggable element. */
210+
getRootElement(): HTMLElement {
211+
return this._rootElement;
212+
}
213+
214+
ngAfterViewInit() {
215+
// We need to wait for the zone to stabilize, in order for the reference
216+
// element to be in the proper place in the DOM. This is mostly relevant
217+
// for draggable elements inside portals since they get stamped out in
218+
// their original DOM position and then they get transferred to the portal.
219+
this._ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => {
220+
const rootElement = this._rootElement = this._getRootElement();
221+
rootElement.addEventListener('mousedown', this._startDragging);
222+
rootElement.addEventListener('touchstart', this._startDragging);
223+
});
224+
}
225+
200226
ngOnDestroy() {
227+
this._rootElement.removeEventListener('mousedown', this._startDragging);
228+
this._rootElement.removeEventListener('touchstart', this._startDragging);
201229
this._destroyPreview();
202230
this._destroyPlaceholder();
203231

@@ -206,7 +234,7 @@ export class CdkDrag<T = any> implements OnDestroy {
206234
if (this._isDragging()) {
207235
// Since we move out the element to the end of the body while it's being
208236
// dragged, we have to make sure that it's removed if it gets destroyed.
209-
this._removeElement(this.element.nativeElement);
237+
this._removeElement(this._rootElement);
210238
}
211239

212240
this._nextSibling = null;
@@ -217,7 +245,7 @@ export class CdkDrag<T = any> implements OnDestroy {
217245
}
218246

219247
/** Starts the dragging sequence. */
220-
_startDragging(event: MouseEvent | TouchEvent) {
248+
_startDragging = (event: MouseEvent | TouchEvent) => {
221249
// Delegate the event based on whether it started from a handle or the element itself.
222250
if (this._handles.length) {
223251
const targetHandle = this._handles.find(handle => {
@@ -227,10 +255,10 @@ export class CdkDrag<T = any> implements OnDestroy {
227255
});
228256

229257
if (targetHandle) {
230-
this._pointerDown(targetHandle.element, event);
258+
this._pointerDown(targetHandle.element.nativeElement, event);
231259
}
232260
} else {
233-
this._pointerDown(this.element, event);
261+
this._pointerDown(this._rootElement, event);
234262
}
235263
}
236264

@@ -240,7 +268,7 @@ export class CdkDrag<T = any> implements OnDestroy {
240268
}
241269

242270
/** Handler for when the pointer is pressed down on the element or the handle. */
243-
private _pointerDown = (referenceElement: ElementRef<HTMLElement>,
271+
private _pointerDown = (referenceElement: HTMLElement,
244272
event: MouseEvent | TouchEvent) => {
245273

246274
const isDragging = this._isDragging();
@@ -277,7 +305,7 @@ export class CdkDrag<T = any> implements OnDestroy {
277305
this.started.emit({source: this});
278306

279307
if (this.dropContainer) {
280-
const element = this.element.nativeElement;
308+
const element = this._rootElement;
281309

282310
// Grab the `nextSibling` before the preview and placeholder
283311
// have been created so we don't get the preview by accident.
@@ -318,7 +346,7 @@ export class CdkDrag<T = any> implements OnDestroy {
318346
pointerPosition.x - this._pickupPositionOnPage.x + this._passiveTransform.x;
319347
activeTransform.y =
320348
pointerPosition.y - this._pickupPositionOnPage.y + this._passiveTransform.y;
321-
this._setTransform(this.element.nativeElement, activeTransform.x, activeTransform.y);
349+
this._setTransform(this._rootElement, activeTransform.x, activeTransform.y);
322350
}
323351

324352
// Since this event gets fired for every pixel while dragging, we only
@@ -362,12 +390,12 @@ export class CdkDrag<T = any> implements OnDestroy {
362390
// It's important that we maintain the position, because moving the element around in the DOM
363391
// can throw off `NgFor` which does smart diffing and re-creates elements only when necessary,
364392
// while moving the existing elements in all other cases.
365-
this.element.nativeElement.style.display = '';
393+
this._rootElement.style.display = '';
366394

367395
if (this._nextSibling) {
368-
this._nextSibling.parentNode!.insertBefore(this.element.nativeElement, this._nextSibling);
396+
this._nextSibling.parentNode!.insertBefore(this._rootElement, this._nextSibling);
369397
} else {
370-
this._placeholder.parentNode!.appendChild(this.element.nativeElement);
398+
this._placeholder.parentNode!.appendChild(this._rootElement);
371399
}
372400

373401
this._destroyPreview();
@@ -430,7 +458,7 @@ export class CdkDrag<T = any> implements OnDestroy {
430458
this._previewRef = viewRef;
431459
this._setTransform(preview, this._pickupPositionOnPage.x, this._pickupPositionOnPage.y);
432460
} else {
433-
const element = this.element.nativeElement;
461+
const element = this._rootElement;
434462
const elementRect = element.getBoundingClientRect();
435463

436464
preview = element.cloneNode(true) as HTMLElement;
@@ -456,7 +484,7 @@ export class CdkDrag<T = any> implements OnDestroy {
456484
);
457485
placeholder = this._placeholderRef.rootNodes[0];
458486
} else {
459-
placeholder = this.element.nativeElement.cloneNode(true) as HTMLElement;
487+
placeholder = this._rootElement.cloneNode(true) as HTMLElement;
460488
}
461489

462490
placeholder.classList.add('cdk-drag-placeholder');
@@ -468,10 +496,10 @@ export class CdkDrag<T = any> implements OnDestroy {
468496
* @param referenceElement Element that initiated the dragging.
469497
* @param event Event that initiated the dragging.
470498
*/
471-
private _getPointerPositionInElement(referenceElement: ElementRef<HTMLElement>,
499+
private _getPointerPositionInElement(referenceElement: HTMLElement,
472500
event: MouseEvent | TouchEvent): Point {
473-
const elementRect = this.element.nativeElement.getBoundingClientRect();
474-
const handleElement = referenceElement === this.element ? null : referenceElement.nativeElement;
501+
const elementRect = this._rootElement.getBoundingClientRect();
502+
const handleElement = referenceElement === this._rootElement ? null : referenceElement;
475503
const referenceRect = handleElement ? handleElement.getBoundingClientRect() : elementRect;
476504
const x = this._isTouchEvent(event) ?
477505
event.targetTouches[0].pageX - referenceRect.left - this._scrollPosition.left :
@@ -632,6 +660,23 @@ export class CdkDrag<T = any> implements OnDestroy {
632660
positionSinceLastChange.y = y;
633661
}
634662
}
663+
664+
/** Gets the root draggable element, based on the `rootElementSelector`. */
665+
private _getRootElement(): HTMLElement {
666+
if (this.rootElementSelector) {
667+
let currentElement = this.element.nativeElement.parentElement as HTMLElement | null;
668+
669+
while (currentElement) {
670+
if (currentElement.matches(this.rootElementSelector)) {
671+
return currentElement;
672+
}
673+
674+
currentElement = currentElement.parentElement;
675+
}
676+
}
677+
678+
return this.element.nativeElement;
679+
}
635680
}
636681

637682
/** Parses a CSS time value to milliseconds. */
@@ -650,7 +695,6 @@ function getTransitionDurationInMs(element: HTMLElement): number {
650695
return parseCssTimeUnitsToMs(rawDuration) + parseCssTimeUnitsToMs(rawDelay);
651696
}
652697

653-
654698
/** Point on the page or within an element. */
655699
interface Point {
656700
x: number;

src/cdk/drag-drop/drop.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
187187
// Don't use items that are being dragged as a reference, because
188188
// their element has been moved down to the bottom of the body.
189189
if (newPositionReference && !this._dragDropRegistry.isDragging(newPositionReference)) {
190-
const element = newPositionReference.element.nativeElement;
190+
const element = newPositionReference.getRootElement();
191191
element.parentElement!.insertBefore(placeholder, element);
192192
this._activeDraggables.splice(newIndex, 0, item);
193193
} else {
@@ -277,7 +277,7 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
277277
const isDraggedItem = sibling.drag === item;
278278
const offset = isDraggedItem ? itemOffset : siblingOffset;
279279
const elementToOffset = isDraggedItem ? item.getPlaceholderElement() :
280-
sibling.drag.element.nativeElement;
280+
sibling.drag.getRootElement();
281281

282282
// Update the offset to reflect the new position.
283283
sibling.offset += offset;
@@ -320,7 +320,7 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
320320
// If the element is being dragged, we have to measure the
321321
// placeholder, because the element is hidden.
322322
drag.getPlaceholderElement() :
323-
drag.element.nativeElement;
323+
drag.getRootElement();
324324
const clientRect = elementToMeasure.getBoundingClientRect();
325325

326326
return {
@@ -355,7 +355,7 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
355355
this._dragging = false;
356356

357357
// TODO(crisbeto): may have to wait for the animations to finish.
358-
this._activeDraggables.forEach(item => item.element.nativeElement.style.transform = '');
358+
this._activeDraggables.forEach(item => item.getRootElement().style.transform = '');
359359
this._activeDraggables = [];
360360
this._positionCache.items = [];
361361
this._positionCache.siblings = [];

src/demo-app/dialog/dialog-demo.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,19 @@ export class DialogDemo {
9090
@Component({
9191
selector: 'demo-jazz-dialog',
9292
template: `
93-
<p>It's Jazz!</p>
94-
95-
<mat-form-field>
96-
<mat-label>How much?</mat-label>
97-
<input matInput #howMuch>
98-
</mat-form-field>
99-
100-
<p> {{ data.message }} </p>
101-
<button type="button" (click)="dialogRef.close(howMuch.value)">Close dialog</button>
102-
<button (click)="togglePosition()">Change dimensions</button>`
93+
<div cdkDrag cdkDragRootElement=".cdk-overlay-pane">
94+
<p>It's Jazz!</p>
95+
96+
<mat-form-field>
97+
<mat-label>How much?</mat-label>
98+
<input matInput #howMuch>
99+
</mat-form-field>
100+
101+
<p cdkDragHandle> {{ data.message }} </p>
102+
<button type="button" (click)="dialogRef.close(howMuch.value)">Close dialog</button>
103+
<button (click)="togglePosition()">Change dimensions</button>
104+
</div>
105+
`
103106
})
104107
export class JazzDialog {
105108
private _dimesionToggle = false;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.dialog-content {
2+
width: 200px;
3+
height: 200px;
4+
border: solid 1px #ccc;
5+
cursor: move;
6+
display: flex;
7+
justify-content: center;
8+
align-items: center;
9+
background: #fff;
10+
border-radius: 4px;
11+
transition: box-shadow 200ms cubic-bezier(0, 0, 0.2, 1);
12+
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2),
13+
0 2px 2px 0 rgba(0, 0, 0, 0.14),
14+
0 1px 5px 0 rgba(0, 0, 0, 0.12);
15+
}
16+
17+
.dialog-content:active {
18+
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
19+
0 8px 10px 1px rgba(0, 0, 0, 0.14),
20+
0 3px 14px 2px rgba(0, 0, 0, 0.12);
21+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<button (click)="openDialog()">Open a draggable dialog</button>
2+
3+
<ng-template>
4+
<div class="dialog-content" cdkDrag cdkDragRootElement=".cdk-overlay-pane">
5+
Drag the dialog around!
6+
</div>
7+
</ng-template>

0 commit comments

Comments
 (0)