Skip to content

Commit

Permalink
feat(cdk/drag-drop): add the ability to specify an alternate drop lis…
Browse files Browse the repository at this point in the history
…t container

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 #29140.
  • Loading branch information
crisbeto committed Jun 19, 2024
1 parent 738f57c commit b76ea59
Show file tree
Hide file tree
Showing 7 changed files with 276 additions and 29 deletions.
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

0 comments on commit b76ea59

Please sign in to comment.