Skip to content

Commit

Permalink
feat(cdk/drag-drop): add mixed orientation support
Browse files Browse the repository at this point in the history
Currently the drop list sorts items by moving them using a `transform` which keeps the DOM stable and allows for the sorting to be animated, but has the drawback of only allowing sorting in one direction.

These changes implement a new `DropListSortStrategy` that allows sorting of lists that can wrap by moving the DOM nodes around directly, rather than via a `transform`. It has the caveat that it can't animate the sorting action.

The new strategy can be enabled by setting `cdkDropListOrientation="mixed"`.

Fixes #13372.
  • Loading branch information
crisbeto committed Jun 12, 2024
1 parent d0ca10b commit 0bc6583
Show file tree
Hide file tree
Showing 17 changed files with 683 additions and 41 deletions.
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
63 changes: 50 additions & 13 deletions src/cdk/drag-drop/directives/drop-list-shared.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Directionality} from '@angular/cdk/bidi';
import {_supportsShadowDom} from '@angular/cdk/platform';
import {Platform, _supportsShadowDom} from '@angular/cdk/platform';
import {CdkScrollable, ViewportRuler} from '@angular/cdk/scrolling';
import {
createMouseEvent,
Expand Down Expand Up @@ -803,6 +803,26 @@ export function defineCommonDropListTests(config: {
scrollTo(0, 0);
}));

it('should remove the anchor node once dragging stops', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
const list = fixture.componentInstance.dropInstance.element.nativeElement;

startDraggingViaMouse(fixture, item);

const anchor = Array.from(list.childNodes).find(
node => node.textContent === 'cdk-drag-anchor',
);
expect(anchor).toBeTruthy();

dispatchMouseEvent(document, 'mouseup');
fixture.detectChanges();
flush();

expect(anchor!.parentNode).toBeFalsy();
}));

it('should create a preview element while the item is dragged', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
Expand Down Expand Up @@ -1489,7 +1509,7 @@ export function defineCommonDropListTests(config: {
it('should move the placeholder as an item is being sorted down', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
assertDownwardSorting(
assertStartToEndSorting(
'vertical',
fixture,
config.getSortedSiblings,
Expand All @@ -1503,7 +1523,7 @@ export function defineCommonDropListTests(config: {
const cleanup = makeScrollable();

scrollTo(0, 5000);
assertDownwardSorting(
assertStartToEndSorting(
'vertical',
fixture,
config.getSortedSiblings,
Expand All @@ -1515,7 +1535,7 @@ export function defineCommonDropListTests(config: {
it('should move the placeholder as an item is being sorted up', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
assertUpwardSorting(
assertEndToStartSorting(
'vertical',
fixture,
config.getSortedSiblings,
Expand All @@ -1529,7 +1549,7 @@ export function defineCommonDropListTests(config: {
const cleanup = makeScrollable();

scrollTo(0, 5000);
assertUpwardSorting(
assertEndToStartSorting(
'vertical',
fixture,
config.getSortedSiblings,
Expand All @@ -1541,7 +1561,7 @@ export function defineCommonDropListTests(config: {
it('should move the placeholder as an item is being sorted to the right', fakeAsync(() => {
const fixture = createComponent(DraggableInHorizontalDropZone);
fixture.detectChanges();
assertDownwardSorting(
assertStartToEndSorting(
'horizontal',
fixture,
config.getSortedSiblings,
Expand All @@ -1552,7 +1572,7 @@ export function defineCommonDropListTests(config: {
it('should move the placeholder as an item is being sorted to the left', fakeAsync(() => {
const fixture = createComponent(DraggableInHorizontalDropZone);
fixture.detectChanges();
assertUpwardSorting(
assertEndToStartSorting(
'horizontal',
fixture,
config.getSortedSiblings,
Expand Down Expand Up @@ -1901,15 +1921,28 @@ export function defineCommonDropListTests(config: {
}));

it('should keep the preview next to the trigger if the page was scrolled', fakeAsync(() => {
const extractTransform = (element: HTMLElement) => {
const match = element.style.transform.match(/translate3d\(\d+px, (\d+)px, \d+px\)/);
return match ? parseInt(match[1]) : 0;
};

const fixture = createComponent(DraggableInDropZoneWithCustomPreview);
fixture.detectChanges();
const platform = TestBed.inject(Platform);

// The programmatic scrolling inside the Karma iframe doesn't seem to work on iOS in the CI.
// Skip the test since the logic is the same for all other browsers which are covered.
if (platform.IOS) {
return;
}

const cleanup = makeScrollable();
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;

startDraggingViaMouse(fixture, item, 50, 50);

const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
expect(preview.style.transform).toBe('translate3d(50px, 50px, 0px)');
expect(extractTransform(preview)).toBe(50);

scrollTo(0, 5000);
fixture.detectChanges();
Expand All @@ -1918,7 +1951,9 @@ export function defineCommonDropListTests(config: {
dispatchMouseEvent(document, 'mousemove', 55, 55);
fixture.detectChanges();

expect(preview.style.transform).toBe('translate3d(55px, 1571px, 0px)');
// Note that here we just check that the value is greater, because on the
// CI the values end up being inconsistent between browsers.
expect(extractTransform(preview)).toBeGreaterThan(1000);

cleanup();
}));
Expand Down Expand Up @@ -2603,6 +2638,8 @@ export function defineCommonDropListTests(config: {
dispatchMouseEvent(document, 'mouseup');
fixture.detectChanges();
tickAnimationFrames(20);
flush();
fixture.detectChanges();

expect(list.scrollTop).toBe(previousScrollTop);
}));
Expand Down Expand Up @@ -3130,7 +3167,7 @@ export function defineCommonDropListTests(config: {
documentElement.style.position = 'absolute';
documentElement.style.top = '100px';

assertDownwardSorting(
assertStartToEndSorting(
'vertical',
fixture,
config.getSortedSiblings,
Expand Down Expand Up @@ -3394,7 +3431,7 @@ export function defineCommonDropListTests(config: {
fixture.detectChanges();
});

assertDownwardSorting(
assertStartToEndSorting(
'vertical',
fixture,
config.getSortedSiblings,
Expand Down Expand Up @@ -4674,7 +4711,7 @@ export function defineCommonDropListTests(config: {
});
}

function assertDownwardSorting(
export function assertStartToEndSorting(
listOrientation: 'vertical' | 'horizontal',
fixture: ComponentFixture<any>,
getSortedSiblings: SortedSiblingsFunction,
Expand Down Expand Up @@ -4714,7 +4751,7 @@ function assertDownwardSorting(
flush();
}

function assertUpwardSorting(
export function assertEndToStartSorting(
listOrientation: 'vertical' | 'horizontal',
fixture: ComponentFixture<any>,
getSortedSiblings: SortedSiblingsFunction,
Expand Down
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({
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);
});
}
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

0 comments on commit 0bc6583

Please sign in to comment.