Skip to content

Commit 0bc6583

Browse files
committed
feat(cdk/drag-drop): add mixed orientation support
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.
1 parent d0ca10b commit 0bc6583

17 files changed

+683
-41
lines changed

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type DragAxis = 'x' | 'y';
1919
export type DragConstrainPosition = (point: Point, dragRef: DragRef) => Point;
2020

2121
/** Possible orientations for a drop list. */
22-
export type DropListOrientation = 'horizontal' | 'vertical';
22+
export type DropListOrientation = 'horizontal' | 'vertical' | 'mixed';
2323

2424
/**
2525
* Injection token that can be used to configure the

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

+50-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Directionality} from '@angular/cdk/bidi';
2-
import {_supportsShadowDom} from '@angular/cdk/platform';
2+
import {Platform, _supportsShadowDom} from '@angular/cdk/platform';
33
import {CdkScrollable, ViewportRuler} from '@angular/cdk/scrolling';
44
import {
55
createMouseEvent,
@@ -803,6 +803,26 @@ export function defineCommonDropListTests(config: {
803803
scrollTo(0, 0);
804804
}));
805805

806+
it('should remove the anchor node once dragging stops', fakeAsync(() => {
807+
const fixture = createComponent(DraggableInDropZone);
808+
fixture.detectChanges();
809+
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
810+
const list = fixture.componentInstance.dropInstance.element.nativeElement;
811+
812+
startDraggingViaMouse(fixture, item);
813+
814+
const anchor = Array.from(list.childNodes).find(
815+
node => node.textContent === 'cdk-drag-anchor',
816+
);
817+
expect(anchor).toBeTruthy();
818+
819+
dispatchMouseEvent(document, 'mouseup');
820+
fixture.detectChanges();
821+
flush();
822+
823+
expect(anchor!.parentNode).toBeFalsy();
824+
}));
825+
806826
it('should create a preview element while the item is dragged', fakeAsync(() => {
807827
const fixture = createComponent(DraggableInDropZone);
808828
fixture.detectChanges();
@@ -1489,7 +1509,7 @@ export function defineCommonDropListTests(config: {
14891509
it('should move the placeholder as an item is being sorted down', fakeAsync(() => {
14901510
const fixture = createComponent(DraggableInDropZone);
14911511
fixture.detectChanges();
1492-
assertDownwardSorting(
1512+
assertStartToEndSorting(
14931513
'vertical',
14941514
fixture,
14951515
config.getSortedSiblings,
@@ -1503,7 +1523,7 @@ export function defineCommonDropListTests(config: {
15031523
const cleanup = makeScrollable();
15041524

15051525
scrollTo(0, 5000);
1506-
assertDownwardSorting(
1526+
assertStartToEndSorting(
15071527
'vertical',
15081528
fixture,
15091529
config.getSortedSiblings,
@@ -1515,7 +1535,7 @@ export function defineCommonDropListTests(config: {
15151535
it('should move the placeholder as an item is being sorted up', fakeAsync(() => {
15161536
const fixture = createComponent(DraggableInDropZone);
15171537
fixture.detectChanges();
1518-
assertUpwardSorting(
1538+
assertEndToStartSorting(
15191539
'vertical',
15201540
fixture,
15211541
config.getSortedSiblings,
@@ -1529,7 +1549,7 @@ export function defineCommonDropListTests(config: {
15291549
const cleanup = makeScrollable();
15301550

15311551
scrollTo(0, 5000);
1532-
assertUpwardSorting(
1552+
assertEndToStartSorting(
15331553
'vertical',
15341554
fixture,
15351555
config.getSortedSiblings,
@@ -1541,7 +1561,7 @@ export function defineCommonDropListTests(config: {
15411561
it('should move the placeholder as an item is being sorted to the right', fakeAsync(() => {
15421562
const fixture = createComponent(DraggableInHorizontalDropZone);
15431563
fixture.detectChanges();
1544-
assertDownwardSorting(
1564+
assertStartToEndSorting(
15451565
'horizontal',
15461566
fixture,
15471567
config.getSortedSiblings,
@@ -1552,7 +1572,7 @@ export function defineCommonDropListTests(config: {
15521572
it('should move the placeholder as an item is being sorted to the left', fakeAsync(() => {
15531573
const fixture = createComponent(DraggableInHorizontalDropZone);
15541574
fixture.detectChanges();
1555-
assertUpwardSorting(
1575+
assertEndToStartSorting(
15561576
'horizontal',
15571577
fixture,
15581578
config.getSortedSiblings,
@@ -1901,15 +1921,28 @@ export function defineCommonDropListTests(config: {
19011921
}));
19021922

19031923
it('should keep the preview next to the trigger if the page was scrolled', fakeAsync(() => {
1924+
const extractTransform = (element: HTMLElement) => {
1925+
const match = element.style.transform.match(/translate3d\(\d+px, (\d+)px, \d+px\)/);
1926+
return match ? parseInt(match[1]) : 0;
1927+
};
1928+
19041929
const fixture = createComponent(DraggableInDropZoneWithCustomPreview);
19051930
fixture.detectChanges();
1931+
const platform = TestBed.inject(Platform);
1932+
1933+
// The programmatic scrolling inside the Karma iframe doesn't seem to work on iOS in the CI.
1934+
// Skip the test since the logic is the same for all other browsers which are covered.
1935+
if (platform.IOS) {
1936+
return;
1937+
}
1938+
19061939
const cleanup = makeScrollable();
19071940
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
19081941

19091942
startDraggingViaMouse(fixture, item, 50, 50);
19101943

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

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

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

19231958
cleanup();
19241959
}));
@@ -2603,6 +2638,8 @@ export function defineCommonDropListTests(config: {
26032638
dispatchMouseEvent(document, 'mouseup');
26042639
fixture.detectChanges();
26052640
tickAnimationFrames(20);
2641+
flush();
2642+
fixture.detectChanges();
26062643

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

3133-
assertDownwardSorting(
3170+
assertStartToEndSorting(
31343171
'vertical',
31353172
fixture,
31363173
config.getSortedSiblings,
@@ -3394,7 +3431,7 @@ export function defineCommonDropListTests(config: {
33943431
fixture.detectChanges();
33953432
});
33963433

3397-
assertDownwardSorting(
3434+
assertStartToEndSorting(
33983435
'vertical',
33993436
fixture,
34003437
config.getSortedSiblings,
@@ -4674,7 +4711,7 @@ export function defineCommonDropListTests(config: {
46744711
});
46754712
}
46764713

4677-
function assertDownwardSorting(
4714+
export function assertStartToEndSorting(
46784715
listOrientation: 'vertical' | 'horizontal',
46794716
fixture: ComponentFixture<any>,
46804717
getSortedSiblings: SortedSiblingsFunction,
@@ -4714,7 +4751,7 @@ function assertDownwardSorting(
47144751
flush();
47154752
}
47164753

4717-
function assertUpwardSorting(
4754+
export function assertEndToStartSorting(
47184755
listOrientation: 'vertical' | 'horizontal',
47194756
fixture: ComponentFixture<any>,
47204757
getSortedSiblings: SortedSiblingsFunction,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
2+
import {fakeAsync, flush} from '@angular/core/testing';
3+
import {CdkDropList} from './drop-list';
4+
import {CdkDrag} from './drag';
5+
import {moveItemInArray} from '../drag-utils';
6+
import {CdkDragDrop} from '../drag-events';
7+
import {
8+
ITEM_HEIGHT,
9+
ITEM_WIDTH,
10+
assertStartToEndSorting,
11+
assertEndToStartSorting,
12+
defineCommonDropListTests,
13+
} from './drop-list-shared.spec';
14+
import {createComponent, dragElementViaMouse} from './test-utils.spec';
15+
16+
describe('mixed drop list', () => {
17+
defineCommonDropListTests({
18+
verticalListOrientation: 'mixed',
19+
horizontalListOrientation: 'mixed',
20+
getSortedSiblings,
21+
});
22+
23+
it('should dispatch the `dropped` event in a wrapping drop zone', fakeAsync(() => {
24+
const fixture = createComponent(DraggableInHorizontalWrappingDropZone);
25+
fixture.detectChanges();
26+
const dragItems = fixture.componentInstance.dragItems;
27+
28+
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim())).toEqual([
29+
'Zero',
30+
'One',
31+
'Two',
32+
'Three',
33+
'Four',
34+
'Five',
35+
'Six',
36+
'Seven',
37+
]);
38+
39+
const firstItem = dragItems.first;
40+
const seventhItemRect = dragItems.toArray()[6].element.nativeElement.getBoundingClientRect();
41+
42+
dragElementViaMouse(
43+
fixture,
44+
firstItem.element.nativeElement,
45+
seventhItemRect.left + 1,
46+
seventhItemRect.top + 1,
47+
);
48+
flush();
49+
fixture.detectChanges();
50+
51+
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
52+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
53+
54+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
55+
// go into an infinite loop trying to stringify the event, if the test fails.
56+
expect(event).toEqual({
57+
previousIndex: 0,
58+
currentIndex: 6,
59+
item: firstItem,
60+
container: fixture.componentInstance.dropInstance,
61+
previousContainer: fixture.componentInstance.dropInstance,
62+
isPointerOverContainer: true,
63+
distance: {x: jasmine.any(Number), y: jasmine.any(Number)},
64+
dropPoint: {x: jasmine.any(Number), y: jasmine.any(Number)},
65+
event: jasmine.anything(),
66+
});
67+
68+
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim())).toEqual([
69+
'One',
70+
'Two',
71+
'Three',
72+
'Four',
73+
'Five',
74+
'Six',
75+
'Zero',
76+
'Seven',
77+
]);
78+
}));
79+
80+
it('should move the placeholder as an item is being sorted to the right in a wrapping drop zone', fakeAsync(() => {
81+
const fixture = createComponent(DraggableInHorizontalWrappingDropZone);
82+
fixture.detectChanges();
83+
assertStartToEndSorting(
84+
'horizontal',
85+
fixture,
86+
getSortedSiblings,
87+
fixture.componentInstance.dragItems.map(item => item.element.nativeElement),
88+
);
89+
}));
90+
91+
it('should move the placeholder as an item is being sorted to the left in a wrapping drop zone', fakeAsync(() => {
92+
const fixture = createComponent(DraggableInHorizontalWrappingDropZone);
93+
fixture.detectChanges();
94+
assertEndToStartSorting(
95+
'horizontal',
96+
fixture,
97+
getSortedSiblings,
98+
fixture.componentInstance.dragItems.map(item => item.element.nativeElement),
99+
);
100+
}));
101+
});
102+
103+
function getSortedSiblings(item: Element) {
104+
return Array.from(item.parentElement?.children || []);
105+
}
106+
107+
@Component({
108+
styles: `
109+
.cdk-drop-list {
110+
display: block;
111+
width: ${ITEM_WIDTH * 3}px;
112+
background: pink;
113+
font-size: 0;
114+
}
115+
116+
.cdk-drag {
117+
height: ${ITEM_HEIGHT * 2}px;
118+
width: ${ITEM_WIDTH}px;
119+
background: red;
120+
display: inline-block;
121+
}
122+
`,
123+
template: `
124+
<div
125+
cdkDropList
126+
cdkDropListOrientation="mixed"
127+
[cdkDropListData]="items"
128+
(cdkDropListDropped)="droppedSpy($event)">
129+
@for (item of items; track item) {
130+
<div cdkDrag>{{item}}</div>
131+
}
132+
</div>
133+
`,
134+
standalone: true,
135+
imports: [CdkDropList, CdkDrag],
136+
})
137+
class DraggableInHorizontalWrappingDropZone {
138+
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
139+
@ViewChild(CdkDropList) dropInstance: CdkDropList;
140+
items = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven'];
141+
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
142+
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
143+
});
144+
}

src/cdk/drag-drop/drag-drop.md

+13-1
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,22 @@ directive:
157157

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

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

164+
### List wrapping
165+
By default the `cdkDropList` sorts the items by moving them around using a CSS `transform`. This
166+
allows for the sorting to be animated which provides a better user experience, but comes with the
167+
drawback that it works only one direction: vertically or horizontally.
168+
169+
If you have a sortable list that needs to wrap, you can set `cdkDropListOrientation="mixed"` which
170+
will use a different strategy of sorting the elements that works by moving them in the DOM. It has
171+
the advantage of allowing the items to wrap to the next line, but it **cannot** animate the
172+
sorting action.
173+
174+
<!-- example(cdk-drag-drop-mixed-sorting) -->
175+
164176
### Restricting movement within an element
165177

166178
If you want to stop the user from being able to drag a `cdkDrag` element outside of another element,

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,8 @@ export class DragRef<T = any> {
822822
const element = this._rootElement;
823823
const parent = element.parentNode as HTMLElement;
824824
const placeholder = (this._placeholder = this._createPlaceholderElement());
825-
const anchor = (this._anchor = this._anchor || this._document.createComment(''));
825+
const anchor = (this._anchor =
826+
this._anchor || this._document.createComment(ngDevMode ? 'cdk-drag-anchor' : ''));
826827

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

0 commit comments

Comments
 (0)