Skip to content

Commit a79069c

Browse files
committed
feat(drag-drop): support sorting items horizontally
* Adds support for sorting items horizontally. * Fixes some tests going into an infinite loop if they fail. * Fixes the drag item's directionality not propagating to its preview. Fixes #12066.
1 parent 5c41410 commit a79069c

File tree

8 files changed

+279
-45
lines changed

8 files changed

+279
-45
lines changed

src/cdk-experimental/drag-drop/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ng_module(
1212
deps = [
1313
"@rxjs",
1414
"//src/cdk/platform",
15+
"//src/cdk/bidi",
1516
],
1617
tsconfig = "//src/cdk-experimental:tsconfig-build.json",
1718
)

src/cdk-experimental/drag-drop/drag.spec.ts

Lines changed: 173 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,28 @@ import {
66
ViewChildren,
77
QueryList,
88
AfterViewInit,
9+
Provider,
10+
ViewEncapsulation,
911
} from '@angular/core';
1012
import {TestBed, ComponentFixture, fakeAsync, flush} from '@angular/core/testing';
1113
import {DragDropModule} from './drag-drop-module';
1214
import {dispatchMouseEvent, dispatchTouchEvent} from '@angular/cdk/testing';
15+
import {Directionality} from '@angular/cdk/bidi';
1316
import {CdkDrag} from './drag';
1417
import {CdkDragDrop} from './drag-events';
1518
import {moveItemInArray, transferArrayItem} from './drag-utils';
1619
import {CdkDrop} from './drop';
1720

1821
const ITEM_HEIGHT = 25;
22+
const ITEM_WIDTH = 75;
1923

2024
describe('CdkDrag', () => {
21-
function createComponent<T>(componentType: Type<T>): ComponentFixture<T> {
25+
function createComponent<T>(componentType: Type<T>, providers: Provider[] = []):
26+
ComponentFixture<T> {
2227
TestBed.configureTestingModule({
2328
imports: [DragDropModule],
2429
declarations: [componentType],
30+
providers,
2531
}).compileComponents();
2632

2733
return TestBed.createComponent<T>(componentType);
@@ -101,9 +107,13 @@ describe('CdkDrag', () => {
101107
dispatchMouseEvent(fixture.componentInstance.dragElement.nativeElement, 'mousedown');
102108
fixture.detectChanges();
103109

104-
expect(fixture.componentInstance.startedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
105-
source: fixture.componentInstance.dragInstance
106-
}));
110+
expect(fixture.componentInstance.startedSpy).toHaveBeenCalled();
111+
112+
const event = fixture.componentInstance.startedSpy.calls.mostRecent().args[0];
113+
114+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
115+
// go into an infinite loop trying to stringify the event, if the test fails.
116+
expect(event).toEqual({source: fixture.componentInstance.dragInstance});
107117
}));
108118

109119
it('should dispatch an event when the user has stopped dragging', fakeAsync(() => {
@@ -112,9 +122,13 @@ describe('CdkDrag', () => {
112122

113123
dragElementViaMouse(fixture, fixture.componentInstance.dragElement.nativeElement, 5, 10);
114124

115-
expect(fixture.componentInstance.endedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
116-
source: fixture.componentInstance.dragInstance
117-
}));
125+
expect(fixture.componentInstance.endedSpy).toHaveBeenCalled();
126+
127+
const event = fixture.componentInstance.endedSpy.calls.mostRecent().args[0];
128+
129+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
130+
// go into an infinite loop trying to stringify the event, if the test fails.
131+
expect(event).toEqual({source: fixture.componentInstance.dragInstance});
118132
}));
119133
});
120134

@@ -187,13 +201,52 @@ describe('CdkDrag', () => {
187201
fixture.detectChanges();
188202

189203
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
190-
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
204+
205+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
206+
207+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
208+
// go into an infinite loop trying to stringify the event, if the test fails.
209+
expect(event).toEqual({
191210
previousIndex: 0,
192211
currentIndex: 2,
193212
item: firstItem,
194213
container: fixture.componentInstance.dropInstance,
195214
previousContainer: fixture.componentInstance.dropInstance
196-
}));
215+
});
216+
217+
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
218+
.toEqual(['One', 'Two', 'Zero', 'Three']);
219+
}));
220+
221+
it('should dispatch the `dropped` event in a horizontal drop zone', fakeAsync(() => {
222+
const fixture = createComponent(DraggableInHorizontalDropZone);
223+
fixture.detectChanges();
224+
const dragItems = fixture.componentInstance.dragItems;
225+
226+
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
227+
.toEqual(['Zero', 'One', 'Two', 'Three']);
228+
229+
const firstItem = dragItems.first;
230+
const thirdItemRect = dragItems.toArray()[2].element.nativeElement.getBoundingClientRect();
231+
232+
dragElementViaMouse(fixture, firstItem.element.nativeElement,
233+
thirdItemRect.left + 1, thirdItemRect.top + 1);
234+
flush();
235+
fixture.detectChanges();
236+
237+
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
238+
239+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
240+
241+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
242+
// go into an infinite loop trying to stringify the event, if the test fails.
243+
expect(event).toEqual({
244+
previousIndex: 0,
245+
currentIndex: 2,
246+
item: firstItem,
247+
container: fixture.componentInstance.dropInstance,
248+
previousContainer: fixture.componentInstance.dropInstance
249+
});
197250

198251
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
199252
.toEqual(['One', 'Two', 'Zero', 'Three']);
@@ -217,6 +270,8 @@ describe('CdkDrag', () => {
217270
expect(preview).toBeTruthy('Expected preview to be in the DOM');
218271
expect(preview.textContent!.trim())
219272
.toContain('One', 'Expected preview content to match element');
273+
expect(preview.getAttribute('dir'))
274+
.toBe('ltr', 'Expected preview element to inherit the directionality.');
220275
expect(previewRect.width).toBe(itemRect.width, 'Expected preview width to match element');
221276
expect(previewRect.height).toBe(itemRect.height, 'Expected preview height to match element');
222277

@@ -230,6 +285,22 @@ describe('CdkDrag', () => {
230285
expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM');
231286
}));
232287

288+
it('should pass the proper direction to the preview in rtl', fakeAsync(() => {
289+
const fixture = createComponent(DraggableInDropZone, [{
290+
provide: Directionality,
291+
useValue: ({value: 'rtl'})
292+
}]);
293+
294+
fixture.detectChanges();
295+
296+
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
297+
dispatchMouseEvent(item, 'mousedown');
298+
fixture.detectChanges();
299+
300+
expect(document.querySelector('.cdk-drag-preview')!.getAttribute('dir'))
301+
.toBe('rtl', 'Expected preview element to inherit the directionality.');
302+
}));
303+
233304
it('should create a placeholder element while the item is dragged', fakeAsync(() => {
234305
const fixture = createComponent(DraggableInDropZone);
235306
fixture.detectChanges();
@@ -310,6 +381,62 @@ describe('CdkDrag', () => {
310381
flush();
311382
}));
312383

384+
it('should move the placeholder as an item is being sorted to the right', fakeAsync(() => {
385+
const fixture = createComponent(DraggableInHorizontalDropZone);
386+
fixture.detectChanges();
387+
388+
const items = fixture.componentInstance.dragItems.toArray();
389+
const draggedItem = items[0].element.nativeElement;
390+
const {top, left} = draggedItem.getBoundingClientRect();
391+
392+
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
393+
fixture.detectChanges();
394+
395+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
396+
397+
// Drag over each item one-by-one going to the right.
398+
for (let i = 0; i < items.length; i++) {
399+
const elementRect = items[i].element.nativeElement.getBoundingClientRect();
400+
401+
// Add a few pixels to the left offset so we get some overlap.
402+
dispatchMouseEvent(document, 'mousemove', elementRect.left + 5, elementRect.top);
403+
fixture.detectChanges();
404+
expect(getElementIndex(placeholder)).toBe(i);
405+
}
406+
407+
dispatchMouseEvent(document, 'mouseup');
408+
fixture.detectChanges();
409+
flush();
410+
}));
411+
412+
it('should move the placeholder as an item is being sorted to the left', fakeAsync(() => {
413+
const fixture = createComponent(DraggableInHorizontalDropZone);
414+
fixture.detectChanges();
415+
416+
const items = fixture.componentInstance.dragItems.toArray();
417+
const draggedItem = items[items.length - 1].element.nativeElement;
418+
const {top, left} = draggedItem.getBoundingClientRect();
419+
420+
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
421+
fixture.detectChanges();
422+
423+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
424+
425+
// Drag over each item one-by-one going to the left.
426+
for (let i = items.length - 1; i > -1; i--) {
427+
const elementRect = items[i].element.nativeElement.getBoundingClientRect();
428+
429+
// Remove a few pixels from the right offset so we get some overlap.
430+
dispatchMouseEvent(document, 'mousemove', elementRect.right - 5, elementRect.top);
431+
fixture.detectChanges();
432+
expect(getElementIndex(placeholder)).toBe(Math.min(i + 1, items.length - 1));
433+
}
434+
435+
dispatchMouseEvent(document, 'mouseup');
436+
fixture.detectChanges();
437+
flush();
438+
}));
439+
313440
it('should clean up the preview element if the item is destroyed mid-drag', fakeAsync(() => {
314441
const fixture = createComponent(DraggableInDropZone);
315442
fixture.detectChanges();
@@ -442,6 +569,43 @@ export class DraggableInDropZone {
442569
}
443570

444571

572+
@Component({
573+
encapsulation: ViewEncapsulation.None,
574+
styles: [
575+
// Use inline blocks here to avoid flexbox issues and not to have to flip floats in rtl.
576+
`
577+
.cdk-drop {
578+
display: block;
579+
width: 300px;
580+
background: pink;
581+
font-size: 0;
582+
}
583+
584+
.cdk-drag {
585+
width: ${ITEM_WIDTH}px;
586+
height: ${ITEM_HEIGHT}px;
587+
background: red;
588+
display: inline-block;
589+
}
590+
`],
591+
template: `
592+
<cdk-drop
593+
orientation="horizontal"
594+
[data]="items"
595+
(dropped)="droppedSpy($event)">
596+
<div *ngFor="let item of items" cdkDrag>{{item}}</div>
597+
</cdk-drop>
598+
`
599+
})
600+
export class DraggableInHorizontalDropZone {
601+
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
602+
@ViewChild(CdkDrop) dropInstance: CdkDrop;
603+
items = ['Zero', 'One', 'Two', 'Three'];
604+
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
605+
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
606+
});
607+
}
608+
445609
@Component({
446610
template: `
447611
<cdk-drop style="display: block; width: 100px; background: pink;">

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ import {
2121
ViewContainerRef,
2222
EmbeddedViewRef,
2323
} from '@angular/core';
24-
import {CdkDragHandle} from './drag-handle';
2524
import {DOCUMENT} from '@angular/platform-browser';
25+
import {Directionality} from '@angular/cdk/bidi';
26+
import {CdkDragHandle} from './drag-handle';
2627
import {CdkDropContainer, CDK_DROP_CONTAINER} from './drop-container';
2728
import {supportsPassiveEventListeners} from '@angular/cdk/platform';
2829
import {CdkDragStart, CdkDragEnd, CdkDragExit, CdkDragEnter, CdkDragDrop} from './drag-events';
@@ -117,7 +118,8 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
117118
@Inject(CDK_DROP_CONTAINER) @Optional() @SkipSelf() public dropContainer: CdkDropContainer,
118119
@Inject(DOCUMENT) document: any,
119120
private _ngZone: NgZone,
120-
private _viewContainerRef: ViewContainerRef) {
121+
private _viewContainerRef: ViewContainerRef,
122+
@Optional() private _dir: Directionality) {
121123
this._document = document;
122124
}
123125

@@ -278,7 +280,7 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
278280
});
279281
}
280282

281-
this.dropContainer._sortItem(this, y);
283+
this.dropContainer._sortItem(this, x, y);
282284
this._setTransform(this._preview,
283285
x - this._pickupPositionInElement.x,
284286
y - this._pickupPositionInElement.y);
@@ -309,6 +311,8 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
309311
}
310312

311313
preview.classList.add('cdk-drag-preview');
314+
preview.setAttribute('dir', this._dir ? this._dir.value : 'ltr');
315+
312316
return preview;
313317
}
314318

src/cdk-experimental/drag-drop/drop-container.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export interface CdkDropContainer<T = any> {
1414
/** Arbitrary data to attach to all events emitted by this container. */
1515
data: T;
1616

17+
/** Direction in which the list is oriented. */
18+
orientation: 'horizontal' | 'vertical';
19+
1720
/** Starts dragging an item. */
1821
start(): void;
1922

@@ -42,7 +45,7 @@ export interface CdkDropContainer<T = any> {
4245
* @param item Item whose index should be determined.
4346
*/
4447
getItemIndex(item: CdkDrag): number;
45-
_sortItem(item: CdkDrag, yOffset: number): void;
48+
_sortItem(item: CdkDrag, xOffset: number, yOffset: number): void;
4649
_draggables: QueryList<CdkDrag>;
4750
_getSiblingContainerFromPosition(x: number, y: number): CdkDropContainer | null;
4851
}

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {CdkDrag} from './drag';
2222
import {CdkDragExit, CdkDragEnter, CdkDragDrop} from './drag-events';
2323
import {CDK_DROP_CONTAINER} from './drop-container';
2424

25-
2625
/** Container that wraps a set of draggable items. */
2726
@Component({
2827
moduleId: module.id,
@@ -53,6 +52,9 @@ export class CdkDrop<T = any> {
5352
/** Arbitrary data to attach to all events emitted by this container. */
5453
@Input() data: T;
5554

55+
/** Direction in which the list is oriented. */
56+
@Input() orientation: 'horizontal' | 'vertical' = 'vertical';
57+
5658
/** Emits when the user drops an item inside the container. */
5759
@Output() dropped = new EventEmitter<CdkDragDrop<T, any>>();
5860

@@ -132,13 +134,19 @@ export class CdkDrop<T = any> {
132134
/**
133135
* Sorts an item inside the container based on its position.
134136
* @param item Item to be sorted.
137+
* @param xOffset Position of the item along the X axis.
135138
* @param yOffset Position of the item along the Y axis.
136139
*/
137-
_sortItem(item: CdkDrag, yOffset: number): void {
138-
// TODO: only covers Y axis sorting.
140+
_sortItem(item: CdkDrag, xOffset: number, yOffset: number): void {
139141
const siblings = this._positionCache.items;
140142
const newPosition = siblings.find(({drag, clientRect}) => {
141-
return drag !== item && yOffset > clientRect.top && yOffset < clientRect.bottom;
143+
if (drag === item) {
144+
return false;
145+
}
146+
147+
return this.orientation === 'horizontal' ?
148+
xOffset > clientRect.left && xOffset < clientRect.right :
149+
yOffset > clientRect.top && yOffset < clientRect.bottom;
142150
});
143151

144152
if (!newPosition && siblings.length > 0) {

0 commit comments

Comments
 (0)