Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions src/cdk/drag-drop/directives/drop-list-shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4709,6 +4709,61 @@ export function defineCommonDropListTests(config: {
expect(event.stopPropagation).toHaveBeenCalled();
}));
});

describe('with an alternate element container', () => {
it('should move the placeholder into the alternate container of an empty list', fakeAsync(() => {
const fixture = createComponent(ConnectedDropZonesWithAlternateContainer);
fixture.detectChanges();

const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement);
const item = fixture.componentInstance.groupedDragItems[0][1];
const sourceContainer = dropZones[0].querySelector('.inner-container')!;
const targetContainer = dropZones[1].querySelector('.inner-container')!;
const targetRect = targetContainer.getBoundingClientRect();

startDraggingViaMouse(fixture, item.element.nativeElement);

const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!;

expect(placeholder).toBeTruthy();
expect(placeholder.parentNode)
.withContext('Expected placeholder to be inside the first container.')
.toBe(sourceContainer);

dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1);
fixture.detectChanges();

expect(placeholder.parentNode)
.withContext('Expected placeholder to be inside second container.')
.toBe(targetContainer);
}));

it('should throw if the items are not inside of the alternate container', fakeAsync(() => {
const fixture = createComponent(DraggableWithInvalidAlternateContainer);
fixture.detectChanges();

expect(() => {
const item = fixture.componentInstance.dragItems.first.element.nativeElement;
startDraggingViaMouse(fixture, item);
tick();
}).toThrowError(
/Invalid DOM structure for drop list\. All items must be placed directly inside of the element container/,
);
}));

it('should throw if the alternate container cannot be found', fakeAsync(() => {
const fixture = createComponent(DraggableWithMissingAlternateContainer);
fixture.detectChanges();

expect(() => {
const item = fixture.componentInstance.dragItems.first.element.nativeElement;
startDraggingViaMouse(fixture, item);
tick();
}).toThrowError(
/CdkDropList could not find an element container matching the selector "does-not-exist"/,
);
}));
});
}

export function assertStartToEndSorting(
Expand Down Expand Up @@ -5891,3 +5946,98 @@ class DraggableWithRadioInputsInDropZone {
{id: 3, checked: true},
];
}

@Component({
encapsulation: ViewEncapsulation.ShadowDom,
styles: [...CONNECTED_DROP_ZONES_STYLES, `.inner-container {min-height: 50px;}`],
template: `
<div
cdkDropList
#todoZone="cdkDropList"
[cdkDropListData]="todo"
[cdkDropListConnectedTo]="[doneZone]"
(cdkDropListDropped)="droppedSpy($event)"
(cdkDropListEntered)="enteredSpy($event)"
cdkDropListElementContainer=".inner-container">
<div class="inner-container">
@for (item of todo; track item) {
<div
[cdkDragData]="item"
(cdkDragEntered)="itemEnteredSpy($event)"
cdkDrag>{{item}}</div>
}
</div>
</div>

<div
cdkDropList
#doneZone="cdkDropList"
[cdkDropListData]="done"
[cdkDropListConnectedTo]="[todoZone]"
(cdkDropListDropped)="droppedSpy($event)"
(cdkDropListEntered)="enteredSpy($event)"
cdkDropListElementContainer=".inner-container">
<div class="inner-container">
@for (item of done; track item) {
<div
[cdkDragData]="item"
(cdkDragEntered)="itemEnteredSpy($event)"
cdkDrag>{{item}}</div>
}
</div>
</div>
`,
standalone: true,
imports: [CdkDropList, CdkDrag],
})
class ConnectedDropZonesWithAlternateContainer extends ConnectedDropZones {
override done: string[] = [];
}

@Component({
template: `
<div
cdkDropList
cdkDropListElementContainer=".element-container"
style="width: 100px; background: pink;">
<div class="element-container"></div>

@for (item of items; track $index) {
<div
cdkDrag
[cdkDragData]="item"
style="width: 100%; height: 50px; background: red;">{{item}}</div>
}
</div>
`,
standalone: true,
imports: [CdkDropList, CdkDrag],
})
class DraggableWithInvalidAlternateContainer {
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
@ViewChild(CdkDropList) dropInstance: CdkDropList;
items = ['Zero', 'One', 'Two', 'Three'];
}

@Component({
template: `
<div
cdkDropList
cdkDropListElementContainer="does-not-exist"
style="width: 100px; background: pink;">
@for (item of items; track $index) {
<div
cdkDrag
[cdkDragData]="item"
style="width: 100%; height: 50px; background: red;">{{item}}</div>
}
</div>
`,
standalone: true,
imports: [CdkDropList, CdkDrag],
})
class DraggableWithMissingAlternateContainer {
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
@ViewChild(CdkDropList) dropInstance: CdkDropList;
items = ['Zero', 'One', 'Two', 'Three'];
}
28 changes: 28 additions & 0 deletions src/cdk/drag-drop/directives/drop-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ export class CdkDropList<T = any> implements OnDestroy {
@Input('cdkDropListAutoScrollStep')
autoScrollStep: NumberInput;

/**
* Selector that will be used to resolve an alternate element container for the drop list.
* Passing an alternate container is useful for the cases where one might not have control
* over the parent node of the draggable items within the list (e.g. due to content projection).
* This allows for usages like:
*
* ```
* <div cdkDropList cdkDropListElementContainer=".inner">
* <div class="inner">
* <div cdkDrag></div>
* </div>
* </div>
* ```
*/
@Input('cdkDropListElementContainer') elementContainerSelector: string | null;

/** Emits when the user drops an item inside the container. */
@Output('cdkDropListDropped')
readonly dropped: EventEmitter<CdkDragDrop<T, any>> = new EventEmitter<CdkDragDrop<T, any>>();
Expand Down Expand Up @@ -295,6 +311,18 @@ export class CdkDropList<T = any> implements OnDestroy {
this._scrollableParentsResolved = true;
}

if (this.elementContainerSelector) {
const container = this.element.nativeElement.querySelector(this.elementContainerSelector);

if (!container && (typeof ngDevMode === 'undefined' || ngDevMode)) {
throw new Error(
`CdkDropList could not find an element container matching the selector "${this.elementContainerSelector}"`,
);
}

ref.withElementContainer(container as HTMLElement);
}

ref.disabled = this.disabled;
ref.lockAxis = this.lockAxis;
ref.sortingDisabled = this.sortingDisabled;
Expand Down
97 changes: 75 additions & 22 deletions src/cdk/drag-drop/drop-list-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ export class DropListRef<T = any> {
/** Arbitrary data that can be attached to the drop list. */
data: T;

/** Element that is the direct parent of the drag items. */
private _container: HTMLElement;

/** Whether an item in the list is being dragged. */
private _isDragging = false;

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

/** Elements that can be scrolled while the user is dragging. */
private _scrollableElements: HTMLElement[];
private _scrollableElements: HTMLElement[] = [];

/** Initial value for the element's `scroll-snap-type` style. */
private _initialScrollSnap: string;
Expand All @@ -199,9 +202,9 @@ export class DropListRef<T = any> {
private _ngZone: NgZone,
private _viewportRuler: ViewportRuler,
) {
this.element = coerceElement(element);
const coercedElement = (this.element = coerceElement(element));
this._document = _document;
this.withScrollableParents([this.element]).withOrientation('vertical');
this.withOrientation('vertical').withElementContainer(coercedElement);
_dragDropRegistry.registerDropContainer(this);
this._parentPositions = new ParentPositionTracker(_document);
}
Expand Down Expand Up @@ -358,20 +361,14 @@ export class DropListRef<T = any> {
*/
withOrientation(orientation: DropListOrientation): this {
if (orientation === 'mixed') {
this._sortStrategy = new MixedSortStrategy(
coerceElement(this.element),
this._document,
this._dragDropRegistry,
);
this._sortStrategy = new MixedSortStrategy(this._document, this._dragDropRegistry);
} else {
const strategy = new SingleAxisSortStrategy(
coerceElement(this.element),
this._dragDropRegistry,
);
const strategy = new SingleAxisSortStrategy(this._dragDropRegistry);
strategy.direction = this._direction;
strategy.orientation = orientation;
this._sortStrategy = strategy;
}
this._sortStrategy.withElementContainer(this._container);
this._sortStrategy.withSortPredicate((index, item) => this.sortPredicate(index, item, this));
return this;
}
Expand All @@ -381,7 +378,7 @@ export class DropListRef<T = any> {
* @param elements Elements that can be scrolled.
*/
withScrollableParents(elements: HTMLElement[]): this {
const element = coerceElement(this.element);
const element = this._container;

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

/**
* Configures the drop list so that a different element is used as the container for the
* dragged items. This is useful for the cases when one might not have control over the
* full DOM that sets up the dragging.
* Note that the alternate container needs to be a descendant of the drop list.
* @param container New element container to be assigned.
*/
withElementContainer(container: HTMLElement): this {
if (container === this._container) {
return this;
}

const element = coerceElement(this.element);

if (
(typeof ngDevMode === 'undefined' || ngDevMode) &&
container !== element &&
!element.contains(container)
) {
throw new Error(
'Invalid DOM structure for drop list. Alternate container element must be a descendant of the drop list.',
);
}

const oldContainerIndex = this._scrollableElements.indexOf(this._container);
const newContainerIndex = this._scrollableElements.indexOf(container);

if (oldContainerIndex > -1) {
this._scrollableElements.splice(oldContainerIndex, 1);
}

if (newContainerIndex > -1) {
this._scrollableElements.splice(newContainerIndex, 1);
}

if (this._sortStrategy) {
this._sortStrategy.withElementContainer(container);
}

this._cachedShadowRoot = null;
this._scrollableElements.unshift(container);
this._container = container;
return this;
}

/** Gets the scrollable parents that are registered with this drop container. */
getScrollableParents(): readonly HTMLElement[] {
return this._scrollableElements;
Expand Down Expand Up @@ -526,10 +568,25 @@ export class DropListRef<T = any> {

/** Starts the dragging sequence within the list. */
private _draggingStarted() {
const styles = coerceElement(this.element).style as DragCSSStyleDeclaration;
const styles = this._container.style as DragCSSStyleDeclaration;
this.beforeStarted.next();
this._isDragging = true;

if (
(typeof ngDevMode === 'undefined' || ngDevMode) &&
// Prevent the check from running on apps not using an alternate container. Ideally we
// would always run it, but introducing it at this stage would be a breaking change.
this._container !== coerceElement(this.element)
) {
for (const drag of this._draggables) {
if (!drag.isDragging() && drag.getVisibleElement().parentNode !== this._container) {
throw new Error(
'Invalid DOM structure for drop list. All items must be placed directly inside of the element container.',
);
}
}
}

// We need to disable scroll snapping while the user is dragging, because it breaks automatic
// scrolling. The browser seems to round the value based on the snapping points which means
// that we can't increment/decrement the scroll position.
Expand All @@ -543,19 +600,17 @@ export class DropListRef<T = any> {

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

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

/** Resets the container to its initial state. */
private _reset() {
this._isDragging = false;

const styles = coerceElement(this.element).style as DragCSSStyleDeclaration;
const styles = this._container.style as DragCSSStyleDeclaration;
styles.scrollSnapType = styles.msScrollSnapType = this._initialScrollSnap;

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

const nativeElement = coerceElement(this.element);

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

/**
Expand Down Expand Up @@ -709,7 +762,7 @@ export class DropListRef<T = any> {
*/
private _getShadowRoot(): RootNode {
if (!this._cachedShadowRoot) {
const shadowRoot = _getShadowRoot(coerceElement(this.element));
const shadowRoot = _getShadowRoot(this._container);
this._cachedShadowRoot = (shadowRoot || this._document) as RootNode;
}

Expand Down
Loading