Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cdk/drag-drop): add mixed orientation support #29216

Merged
merged 4 commits into from
Jun 12, 2024
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
2 changes: 1 addition & 1 deletion src/cdk/drag-drop/directives/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type DragAxis = 'x' | 'y';
export type DragConstrainPosition = (point: Point, dragRef: DragRef) => Point;

/** Possible orientations for a drop list. */
export type DropListOrientation = 'horizontal' | 'vertical';
export type DropListOrientation = 'horizontal' | 'vertical' | 'mixed';

/**
* Injection token that can be used to configure the
Expand Down
1,372 changes: 688 additions & 684 deletions src/cdk/drag-drop/directives/drop-list-shared.spec.ts

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions src/cdk/drag-drop/directives/mixed-drop-list.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
import {fakeAsync, flush} from '@angular/core/testing';
import {CdkDropList} from './drop-list';
import {CdkDrag} from './drag';
import {moveItemInArray} from '../drag-utils';
import {CdkDragDrop} from '../drag-events';
import {
ITEM_HEIGHT,
ITEM_WIDTH,
assertStartToEndSorting,
assertEndToStartSorting,
defineCommonDropListTests,
} from './drop-list-shared.spec';
import {createComponent, dragElementViaMouse} from './test-utils.spec';

describe('mixed drop list', () => {
defineCommonDropListTests({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that we don't have that many tests here, because we reuse the ~190 tests from the existing drop list. We add some more below to ensure that sorting works properly on mixed lists as well.

verticalListOrientation: 'mixed',
horizontalListOrientation: 'mixed',
getSortedSiblings,
});

it('should dispatch the `dropped` event in a wrapping drop zone', fakeAsync(() => {
const fixture = createComponent(DraggableInHorizontalWrappingDropZone);
fixture.detectChanges();
const dragItems = fixture.componentInstance.dragItems;

expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim())).toEqual([
'Zero',
'One',
'Two',
'Three',
'Four',
'Five',
'Six',
'Seven',
]);

const firstItem = dragItems.first;
const seventhItemRect = dragItems.toArray()[6].element.nativeElement.getBoundingClientRect();

dragElementViaMouse(
fixture,
firstItem.element.nativeElement,
seventhItemRect.left + 1,
seventhItemRect.top + 1,
);
flush();
fixture.detectChanges();

expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];

// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
// go into an infinite loop trying to stringify the event, if the test fails.
expect(event).toEqual({
previousIndex: 0,
currentIndex: 6,
item: firstItem,
container: fixture.componentInstance.dropInstance,
previousContainer: fixture.componentInstance.dropInstance,
isPointerOverContainer: true,
distance: {x: jasmine.any(Number), y: jasmine.any(Number)},
dropPoint: {x: jasmine.any(Number), y: jasmine.any(Number)},
event: jasmine.anything(),
});

expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim())).toEqual([
'One',
'Two',
'Three',
'Four',
'Five',
'Six',
'Zero',
'Seven',
]);
}));

it('should move the placeholder as an item is being sorted to the right in a wrapping drop zone', fakeAsync(() => {
const fixture = createComponent(DraggableInHorizontalWrappingDropZone);
fixture.detectChanges();
assertStartToEndSorting(
'horizontal',
fixture,
getSortedSiblings,
fixture.componentInstance.dragItems.map(item => item.element.nativeElement),
);
}));

it('should move the placeholder as an item is being sorted to the left in a wrapping drop zone', fakeAsync(() => {
const fixture = createComponent(DraggableInHorizontalWrappingDropZone);
fixture.detectChanges();
assertEndToStartSorting(
'horizontal',
fixture,
getSortedSiblings,
fixture.componentInstance.dragItems.map(item => item.element.nativeElement),
);
}));
});

function getSortedSiblings(item: Element) {
return Array.from(item.parentElement?.children || []);
}

@Component({
styles: `
.cdk-drop-list {
display: block;
width: ${ITEM_WIDTH * 3}px;
background: pink;
font-size: 0;
}

.cdk-drag {
height: ${ITEM_HEIGHT * 2}px;
width: ${ITEM_WIDTH}px;
background: red;
display: inline-block;
}
`,
template: `
<div
cdkDropList
cdkDropListOrientation="mixed"
[cdkDropListData]="items"
(cdkDropListDropped)="droppedSpy($event)">
@for (item of items; track item) {
<div cdkDrag>{{item}}</div>
}
</div>
`,
standalone: true,
imports: [CdkDropList, CdkDrag],
})
class DraggableInHorizontalWrappingDropZone {
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
@ViewChild(CdkDropList) dropInstance: CdkDropList;
items = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven'];
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
});
}
93 changes: 37 additions & 56 deletions src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {ComponentFixture, fakeAsync, flush} from '@angular/core/testing';
import {fakeAsync, flush} from '@angular/core/testing';
import {dispatchMouseEvent} from '@angular/cdk/testing/private';
import {_supportsShadowDom} from '@angular/cdk/platform';
import {createComponent, startDraggingViaMouse} from './test-utils.spec';
import {
ConnectedDropZones,
DraggableInDropZone,
DraggableInScrollableVerticalDropZone,
ITEM_HEIGHT,
Expand All @@ -17,10 +18,13 @@ describe('Single-axis drop list', () => {
defineCommonDropListTests({
verticalListOrientation: 'vertical',
horizontalListOrientation: 'horizontal',
getElementIndexByPosition,
getElementSibligsByPosition,
assertUpwardSorting,
assertDownwardSorting,
getSortedSiblings: (element, direction) => {
return element.parentElement
? Array.from(element.parentElement.children).sort((a, b) => {
return a.getBoundingClientRect()[direction] - b.getBoundingClientRect()[direction];
})
: [];
},
});

it('should lay out the elements correctly, when swapping down with a taller element', fakeAsync(() => {
Expand Down Expand Up @@ -268,66 +272,43 @@ describe('Single-axis drop list', () => {
.withContext('Expected placeholder to preserve transform when dragging stops.')
.toBe(true);
}));
});

function getElementIndexByPosition(element: Element, direction: 'top' | 'left') {
return getElementSibligsByPosition(element, direction).indexOf(element);
}

function getElementSibligsByPosition(element: Element, direction: 'top' | 'left') {
return element.parentElement
? Array.from(element.parentElement.children).sort((a, b) => {
return a.getBoundingClientRect()[direction] - b.getBoundingClientRect()[direction];
})
: [];
}

function assertDownwardSorting(fixture: ComponentFixture<any>, items: Element[]) {
const draggedItem = items[0];
const {top, left} = draggedItem.getBoundingClientRect();

startDraggingViaMouse(fixture, draggedItem, left, top);
it('should enter as last child if entering from top in reversed container', fakeAsync(() => {
const fixture = createComponent(ConnectedDropZones);

const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
// Make sure there's only one item in the first list.
fixture.componentInstance.todo = ['things'];
fixture.detectChanges();

// Drag over each item one-by-one going downwards.
for (let i = 0; i < items.length; i++) {
const elementRect = items[i].getBoundingClientRect();
const groups = fixture.componentInstance.groupedDragItems;
const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement);
const item = groups[0][0];

// Add a few pixels to the top offset so we get some overlap.
dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.top + 5);
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
expect(getElementIndexByPosition(placeholder, 'top')).toBe(i);
}
// Add some initial padding as the target drop zone
const targetDropZoneStyle = dropZones[1].style;
targetDropZoneStyle.paddingTop = '10px';
targetDropZoneStyle.display = 'flex';
targetDropZoneStyle.flexDirection = 'column-reverse';

dispatchMouseEvent(document, 'mouseup');
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
flush();
}
const targetRect = dropZones[1].getBoundingClientRect();

function assertUpwardSorting(fixture: ComponentFixture<any>, items: Element[]) {
const draggedItem = items[items.length - 1];
const {top, left} = draggedItem.getBoundingClientRect();
startDraggingViaMouse(fixture, item.element.nativeElement);

startDraggingViaMouse(fixture, draggedItem, left, top);
const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!;

const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
expect(placeholder).toBeTruthy();

// Drag over each item one-by-one going upwards.
for (let i = items.length - 1; i > -1; i--) {
const elementRect = items[i].getBoundingClientRect();
expect(dropZones[0].contains(placeholder))
.withContext('Expected placeholder to be inside the first container.')
.toBe(true);

// Remove a few pixels from the bottom offset so we get some overlap.
dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.bottom - 5);
fixture.changeDetectorRef.markForCheck();
dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.top);
fixture.detectChanges();
expect(getElementIndexByPosition(placeholder, 'top')).toBe(i);
}

dispatchMouseEvent(document, 'mouseup');
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
flush();
}
expect(dropZones[1].lastChild === placeholder)
.withContext('Expected placeholder to be last child inside second container.')
.toBe(true);

dispatchMouseEvent(document, 'mouseup');
}));
});
2 changes: 1 addition & 1 deletion src/cdk/drag-drop/directives/test-utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export function makeScrollable(
const veryTallElement = document.createElement('div');
veryTallElement.style.width = direction === 'vertical' ? '100%' : '4000px';
veryTallElement.style.height = direction === 'vertical' ? '2000px' : '5px';
element.appendChild(veryTallElement);
element.prepend(veryTallElement);

return () => {
scrollTo(0, 0);
Expand Down
14 changes: 13 additions & 1 deletion src/cdk/drag-drop/drag-drop.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,22 @@ directive:

### List orientation
The `cdkDropList` directive assumes that lists are vertical by default. This can be
changed by setting the `orientation` property to `"horizontal".
changed by setting the `cdkDropListOrientation` property to `horizontal`.

<!-- example(cdk-drag-drop-horizontal-sorting) -->

### List wrapping
By default the `cdkDropList` sorts the items by moving them around using a CSS `transform`. This
allows for the sorting to be animated which provides a better user experience, but comes with the
drawback that it works only one direction: vertically or horizontally.

If you have a sortable list that needs to wrap, you can set `cdkDropListOrientation="mixed"` which
will use a different strategy of sorting the elements that works by moving them in the DOM. It has
the advantage of allowing the items to wrap to the next line, but it **cannot** animate the
sorting action.

<!-- example(cdk-drag-drop-mixed-sorting) -->

### Restricting movement within an element

If you want to stop the user from being able to drag a `cdkDrag` element outside of another element,
Expand Down
3 changes: 2 additions & 1 deletion src/cdk/drag-drop/drag-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,8 @@ export class DragRef<T = any> {
const element = this._rootElement;
const parent = element.parentNode as HTMLElement;
const placeholder = (this._placeholder = this._createPlaceholderElement());
const anchor = (this._anchor = this._anchor || this._document.createComment(''));
const anchor = (this._anchor =
this._anchor || this._document.createComment(ngDevMode ? 'cdk-drag-anchor' : ''));

// Insert an anchor node so that we can restore the element's position in the DOM.
parent.insertBefore(anchor, element);
Expand Down
Loading
Loading