Skip to content

Commit b5e3015

Browse files
authored
feat(cdk/drag-drop): add the ability to specify an alternate drop list container (angular#29283)
Adds the new `cdkDropListElementContainer` input that allows users to specify a different element that should be considered the root of the drop list. This is useful in the cases where the user might not have full control over the DOM between the drop list and the items, like when making a tab list draggable. Fixes angular#29140.
1 parent 3550a87 commit b5e3015

File tree

7 files changed

+276
-29
lines changed

7 files changed

+276
-29
lines changed

src/cdk/drag-drop/directives/drop-list-shared.spec.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4709,6 +4709,61 @@ export function defineCommonDropListTests(config: {
47094709
expect(event.stopPropagation).toHaveBeenCalled();
47104710
}));
47114711
});
4712+
4713+
describe('with an alternate element container', () => {
4714+
it('should move the placeholder into the alternate container of an empty list', fakeAsync(() => {
4715+
const fixture = createComponent(ConnectedDropZonesWithAlternateContainer);
4716+
fixture.detectChanges();
4717+
4718+
const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement);
4719+
const item = fixture.componentInstance.groupedDragItems[0][1];
4720+
const sourceContainer = dropZones[0].querySelector('.inner-container')!;
4721+
const targetContainer = dropZones[1].querySelector('.inner-container')!;
4722+
const targetRect = targetContainer.getBoundingClientRect();
4723+
4724+
startDraggingViaMouse(fixture, item.element.nativeElement);
4725+
4726+
const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!;
4727+
4728+
expect(placeholder).toBeTruthy();
4729+
expect(placeholder.parentNode)
4730+
.withContext('Expected placeholder to be inside the first container.')
4731+
.toBe(sourceContainer);
4732+
4733+
dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1);
4734+
fixture.detectChanges();
4735+
4736+
expect(placeholder.parentNode)
4737+
.withContext('Expected placeholder to be inside second container.')
4738+
.toBe(targetContainer);
4739+
}));
4740+
4741+
it('should throw if the items are not inside of the alternate container', fakeAsync(() => {
4742+
const fixture = createComponent(DraggableWithInvalidAlternateContainer);
4743+
fixture.detectChanges();
4744+
4745+
expect(() => {
4746+
const item = fixture.componentInstance.dragItems.first.element.nativeElement;
4747+
startDraggingViaMouse(fixture, item);
4748+
tick();
4749+
}).toThrowError(
4750+
/Invalid DOM structure for drop list\. All items must be placed directly inside of the element container/,
4751+
);
4752+
}));
4753+
4754+
it('should throw if the alternate container cannot be found', fakeAsync(() => {
4755+
const fixture = createComponent(DraggableWithMissingAlternateContainer);
4756+
fixture.detectChanges();
4757+
4758+
expect(() => {
4759+
const item = fixture.componentInstance.dragItems.first.element.nativeElement;
4760+
startDraggingViaMouse(fixture, item);
4761+
tick();
4762+
}).toThrowError(
4763+
/CdkDropList could not find an element container matching the selector "does-not-exist"/,
4764+
);
4765+
}));
4766+
});
47124767
}
47134768

47144769
export function assertStartToEndSorting(
@@ -5891,3 +5946,98 @@ class DraggableWithRadioInputsInDropZone {
58915946
{id: 3, checked: true},
58925947
];
58935948
}
5949+
5950+
@Component({
5951+
encapsulation: ViewEncapsulation.ShadowDom,
5952+
styles: [...CONNECTED_DROP_ZONES_STYLES, `.inner-container {min-height: 50px;}`],
5953+
template: `
5954+
<div
5955+
cdkDropList
5956+
#todoZone="cdkDropList"
5957+
[cdkDropListData]="todo"
5958+
[cdkDropListConnectedTo]="[doneZone]"
5959+
(cdkDropListDropped)="droppedSpy($event)"
5960+
(cdkDropListEntered)="enteredSpy($event)"
5961+
cdkDropListElementContainer=".inner-container">
5962+
<div class="inner-container">
5963+
@for (item of todo; track item) {
5964+
<div
5965+
[cdkDragData]="item"
5966+
(cdkDragEntered)="itemEnteredSpy($event)"
5967+
cdkDrag>{{item}}</div>
5968+
}
5969+
</div>
5970+
</div>
5971+
5972+
<div
5973+
cdkDropList
5974+
#doneZone="cdkDropList"
5975+
[cdkDropListData]="done"
5976+
[cdkDropListConnectedTo]="[todoZone]"
5977+
(cdkDropListDropped)="droppedSpy($event)"
5978+
(cdkDropListEntered)="enteredSpy($event)"
5979+
cdkDropListElementContainer=".inner-container">
5980+
<div class="inner-container">
5981+
@for (item of done; track item) {
5982+
<div
5983+
[cdkDragData]="item"
5984+
(cdkDragEntered)="itemEnteredSpy($event)"
5985+
cdkDrag>{{item}}</div>
5986+
}
5987+
</div>
5988+
</div>
5989+
`,
5990+
standalone: true,
5991+
imports: [CdkDropList, CdkDrag],
5992+
})
5993+
class ConnectedDropZonesWithAlternateContainer extends ConnectedDropZones {
5994+
override done: string[] = [];
5995+
}
5996+
5997+
@Component({
5998+
template: `
5999+
<div
6000+
cdkDropList
6001+
cdkDropListElementContainer=".element-container"
6002+
style="width: 100px; background: pink;">
6003+
<div class="element-container"></div>
6004+
6005+
@for (item of items; track $index) {
6006+
<div
6007+
cdkDrag
6008+
[cdkDragData]="item"
6009+
style="width: 100%; height: 50px; background: red;">{{item}}</div>
6010+
}
6011+
</div>
6012+
`,
6013+
standalone: true,
6014+
imports: [CdkDropList, CdkDrag],
6015+
})
6016+
class DraggableWithInvalidAlternateContainer {
6017+
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
6018+
@ViewChild(CdkDropList) dropInstance: CdkDropList;
6019+
items = ['Zero', 'One', 'Two', 'Three'];
6020+
}
6021+
6022+
@Component({
6023+
template: `
6024+
<div
6025+
cdkDropList
6026+
cdkDropListElementContainer="does-not-exist"
6027+
style="width: 100px; background: pink;">
6028+
@for (item of items; track $index) {
6029+
<div
6030+
cdkDrag
6031+
[cdkDragData]="item"
6032+
style="width: 100%; height: 50px; background: red;">{{item}}</div>
6033+
}
6034+
</div>
6035+
`,
6036+
standalone: true,
6037+
imports: [CdkDropList, CdkDrag],
6038+
})
6039+
class DraggableWithMissingAlternateContainer {
6040+
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
6041+
@ViewChild(CdkDropList) dropInstance: CdkDropList;
6042+
items = ['Zero', 'One', 'Two', 'Three'];
6043+
}

src/cdk/drag-drop/directives/drop-list.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,22 @@ export class CdkDropList<T = any> implements OnDestroy {
127127
@Input('cdkDropListAutoScrollStep')
128128
autoScrollStep: NumberInput;
129129

130+
/**
131+
* Selector that will be used to resolve an alternate element container for the drop list.
132+
* Passing an alternate container is useful for the cases where one might not have control
133+
* over the parent node of the draggable items within the list (e.g. due to content projection).
134+
* This allows for usages like:
135+
*
136+
* ```
137+
* <div cdkDropList cdkDropListElementContainer=".inner">
138+
* <div class="inner">
139+
* <div cdkDrag></div>
140+
* </div>
141+
* </div>
142+
* ```
143+
*/
144+
@Input('cdkDropListElementContainer') elementContainerSelector: string | null;
145+
130146
/** Emits when the user drops an item inside the container. */
131147
@Output('cdkDropListDropped')
132148
readonly dropped: EventEmitter<CdkDragDrop<T, any>> = new EventEmitter<CdkDragDrop<T, any>>();
@@ -295,6 +311,18 @@ export class CdkDropList<T = any> implements OnDestroy {
295311
this._scrollableParentsResolved = true;
296312
}
297313

314+
if (this.elementContainerSelector) {
315+
const container = this.element.nativeElement.querySelector(this.elementContainerSelector);
316+
317+
if (!container && (typeof ngDevMode === 'undefined' || ngDevMode)) {
318+
throw new Error(
319+
`CdkDropList could not find an element container matching the selector "${this.elementContainerSelector}"`,
320+
);
321+
}
322+
323+
ref.withElementContainer(container as HTMLElement);
324+
}
325+
298326
ref.disabled = this.disabled;
299327
ref.lockAxis = this.lockAxis;
300328
ref.sortingDisabled = this.sortingDisabled;

src/cdk/drag-drop/drop-list-ref.ts

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ export class DropListRef<T = any> {
141141
/** Arbitrary data that can be attached to the drop list. */
142142
data: T;
143143

144+
/** Element that is the direct parent of the drag items. */
145+
private _container: HTMLElement;
146+
144147
/** Whether an item in the list is being dragged. */
145148
private _isDragging = false;
146149

@@ -184,7 +187,7 @@ export class DropListRef<T = any> {
184187
private _document: Document;
185188

186189
/** Elements that can be scrolled while the user is dragging. */
187-
private _scrollableElements: HTMLElement[];
190+
private _scrollableElements: HTMLElement[] = [];
188191

189192
/** Initial value for the element's `scroll-snap-type` style. */
190193
private _initialScrollSnap: string;
@@ -199,9 +202,9 @@ export class DropListRef<T = any> {
199202
private _ngZone: NgZone,
200203
private _viewportRuler: ViewportRuler,
201204
) {
202-
this.element = coerceElement(element);
205+
const coercedElement = (this.element = coerceElement(element));
203206
this._document = _document;
204-
this.withScrollableParents([this.element]).withOrientation('vertical');
207+
this.withOrientation('vertical').withElementContainer(coercedElement);
205208
_dragDropRegistry.registerDropContainer(this);
206209
this._parentPositions = new ParentPositionTracker(_document);
207210
}
@@ -358,20 +361,14 @@ export class DropListRef<T = any> {
358361
*/
359362
withOrientation(orientation: DropListOrientation): this {
360363
if (orientation === 'mixed') {
361-
this._sortStrategy = new MixedSortStrategy(
362-
coerceElement(this.element),
363-
this._document,
364-
this._dragDropRegistry,
365-
);
364+
this._sortStrategy = new MixedSortStrategy(this._document, this._dragDropRegistry);
366365
} else {
367-
const strategy = new SingleAxisSortStrategy(
368-
coerceElement(this.element),
369-
this._dragDropRegistry,
370-
);
366+
const strategy = new SingleAxisSortStrategy(this._dragDropRegistry);
371367
strategy.direction = this._direction;
372368
strategy.orientation = orientation;
373369
this._sortStrategy = strategy;
374370
}
371+
this._sortStrategy.withElementContainer(this._container);
375372
this._sortStrategy.withSortPredicate((index, item) => this.sortPredicate(index, item, this));
376373
return this;
377374
}
@@ -381,7 +378,7 @@ export class DropListRef<T = any> {
381378
* @param elements Elements that can be scrolled.
382379
*/
383380
withScrollableParents(elements: HTMLElement[]): this {
384-
const element = coerceElement(this.element);
381+
const element = this._container;
385382

386383
// We always allow the current element to be scrollable
387384
// so we need to ensure that it's in the array.
@@ -390,6 +387,51 @@ export class DropListRef<T = any> {
390387
return this;
391388
}
392389

390+
/**
391+
* Configures the drop list so that a different element is used as the container for the
392+
* dragged items. This is useful for the cases when one might not have control over the
393+
* full DOM that sets up the dragging.
394+
* Note that the alternate container needs to be a descendant of the drop list.
395+
* @param container New element container to be assigned.
396+
*/
397+
withElementContainer(container: HTMLElement): this {
398+
if (container === this._container) {
399+
return this;
400+
}
401+
402+
const element = coerceElement(this.element);
403+
404+
if (
405+
(typeof ngDevMode === 'undefined' || ngDevMode) &&
406+
container !== element &&
407+
!element.contains(container)
408+
) {
409+
throw new Error(
410+
'Invalid DOM structure for drop list. Alternate container element must be a descendant of the drop list.',
411+
);
412+
}
413+
414+
const oldContainerIndex = this._scrollableElements.indexOf(this._container);
415+
const newContainerIndex = this._scrollableElements.indexOf(container);
416+
417+
if (oldContainerIndex > -1) {
418+
this._scrollableElements.splice(oldContainerIndex, 1);
419+
}
420+
421+
if (newContainerIndex > -1) {
422+
this._scrollableElements.splice(newContainerIndex, 1);
423+
}
424+
425+
if (this._sortStrategy) {
426+
this._sortStrategy.withElementContainer(container);
427+
}
428+
429+
this._cachedShadowRoot = null;
430+
this._scrollableElements.unshift(container);
431+
this._container = container;
432+
return this;
433+
}
434+
393435
/** Gets the scrollable parents that are registered with this drop container. */
394436
getScrollableParents(): readonly HTMLElement[] {
395437
return this._scrollableElements;
@@ -526,10 +568,25 @@ export class DropListRef<T = any> {
526568

527569
/** Starts the dragging sequence within the list. */
528570
private _draggingStarted() {
529-
const styles = coerceElement(this.element).style as DragCSSStyleDeclaration;
571+
const styles = this._container.style as DragCSSStyleDeclaration;
530572
this.beforeStarted.next();
531573
this._isDragging = true;
532574

575+
if (
576+
(typeof ngDevMode === 'undefined' || ngDevMode) &&
577+
// Prevent the check from running on apps not using an alternate container. Ideally we
578+
// would always run it, but introducing it at this stage would be a breaking change.
579+
this._container !== coerceElement(this.element)
580+
) {
581+
for (const drag of this._draggables) {
582+
if (!drag.isDragging() && drag.getVisibleElement().parentNode !== this._container) {
583+
throw new Error(
584+
'Invalid DOM structure for drop list. All items must be placed directly inside of the element container.',
585+
);
586+
}
587+
}
588+
}
589+
533590
// We need to disable scroll snapping while the user is dragging, because it breaks automatic
534591
// scrolling. The browser seems to round the value based on the snapping points which means
535592
// that we can't increment/decrement the scroll position.
@@ -543,19 +600,17 @@ export class DropListRef<T = any> {
543600

544601
/** Caches the positions of the configured scrollable parents. */
545602
private _cacheParentPositions() {
546-
const element = coerceElement(this.element);
547603
this._parentPositions.cache(this._scrollableElements);
548604

549605
// The list element is always in the `scrollableElements`
550606
// so we can take advantage of the cached `DOMRect`.
551-
this._domRect = this._parentPositions.positions.get(element)!.clientRect!;
607+
this._domRect = this._parentPositions.positions.get(this._container)!.clientRect!;
552608
}
553609

554610
/** Resets the container to its initial state. */
555611
private _reset() {
556612
this._isDragging = false;
557-
558-
const styles = coerceElement(this.element).style as DragCSSStyleDeclaration;
613+
const styles = this._container.style as DragCSSStyleDeclaration;
559614
styles.scrollSnapType = styles.msScrollSnapType = this._initialScrollSnap;
560615

561616
this._siblings.forEach(sibling => sibling._stopReceiving(this));
@@ -632,15 +687,13 @@ export class DropListRef<T = any> {
632687
return false;
633688
}
634689

635-
const nativeElement = coerceElement(this.element);
636-
637690
// The `DOMRect`, that we're using to find the container over which the user is
638691
// hovering, doesn't give us any information on whether the element has been scrolled
639692
// out of the view or whether it's overlapping with other containers. This means that
640693
// we could end up transferring the item into a container that's invisible or is positioned
641694
// below another one. We use the result from `elementFromPoint` to get the top-most element
642695
// at the pointer position and to find whether it's one of the intersecting drop containers.
643-
return elementFromPoint === nativeElement || nativeElement.contains(elementFromPoint);
696+
return elementFromPoint === this._container || this._container.contains(elementFromPoint);
644697
}
645698

646699
/**
@@ -709,7 +762,7 @@ export class DropListRef<T = any> {
709762
*/
710763
private _getShadowRoot(): RootNode {
711764
if (!this._cachedShadowRoot) {
712-
const shadowRoot = _getShadowRoot(coerceElement(this.element));
765+
const shadowRoot = _getShadowRoot(this._container);
713766
this._cachedShadowRoot = (shadowRoot || this._document) as RootNode;
714767
}
715768

0 commit comments

Comments
 (0)