Skip to content

Commit f59d9b8

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 a6e28db commit f59d9b8

File tree

8 files changed

+280
-45
lines changed

8 files changed

+280
-45
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ng_module(
1313
"@rxjs",
1414
"//src/cdk/platform",
1515
"//src/cdk/overlay",
16+
"//src/cdk/bidi",
1617
],
1718
tsconfig = "//src/cdk-experimental:tsconfig-build.json",
1819
)
@@ -25,6 +26,7 @@ ts_library(
2526
deps = [
2627
":drag-drop",
2728
"//src/cdk/testing",
29+
"//src/cdk/bidi",
2830
],
2931
tsconfig = "//src/cdk-experimental:tsconfig-build.json",
3032
)

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);
@@ -171,9 +177,13 @@ describe('CdkDrag', () => {
171177
dispatchMouseEvent(fixture.componentInstance.dragElement.nativeElement, 'mousedown');
172178
fixture.detectChanges();
173179

174-
expect(fixture.componentInstance.startedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
175-
source: fixture.componentInstance.dragInstance
176-
}));
180+
expect(fixture.componentInstance.startedSpy).toHaveBeenCalled();
181+
182+
const event = fixture.componentInstance.startedSpy.calls.mostRecent().args[0];
183+
184+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
185+
// go into an infinite loop trying to stringify the event, if the test fails.
186+
expect(event).toEqual({source: fixture.componentInstance.dragInstance});
177187
}));
178188

179189
it('should dispatch an event when the user has stopped dragging', fakeAsync(() => {
@@ -182,9 +192,13 @@ describe('CdkDrag', () => {
182192

183193
dragElementViaMouse(fixture, fixture.componentInstance.dragElement.nativeElement, 5, 10);
184194

185-
expect(fixture.componentInstance.endedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
186-
source: fixture.componentInstance.dragInstance
187-
}));
195+
expect(fixture.componentInstance.endedSpy).toHaveBeenCalled();
196+
197+
const event = fixture.componentInstance.endedSpy.calls.mostRecent().args[0];
198+
199+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
200+
// go into an infinite loop trying to stringify the event, if the test fails.
201+
expect(event).toEqual({source: fixture.componentInstance.dragInstance});
188202
}));
189203
});
190204

@@ -257,13 +271,52 @@ describe('CdkDrag', () => {
257271
fixture.detectChanges();
258272

259273
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
260-
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
274+
275+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
276+
277+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
278+
// go into an infinite loop trying to stringify the event, if the test fails.
279+
expect(event).toEqual({
261280
previousIndex: 0,
262281
currentIndex: 2,
263282
item: firstItem,
264283
container: fixture.componentInstance.dropInstance,
265284
previousContainer: fixture.componentInstance.dropInstance
266-
}));
285+
});
286+
287+
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
288+
.toEqual(['One', 'Two', 'Zero', 'Three']);
289+
}));
290+
291+
it('should dispatch the `dropped` event in a horizontal drop zone', fakeAsync(() => {
292+
const fixture = createComponent(DraggableInHorizontalDropZone);
293+
fixture.detectChanges();
294+
const dragItems = fixture.componentInstance.dragItems;
295+
296+
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
297+
.toEqual(['Zero', 'One', 'Two', 'Three']);
298+
299+
const firstItem = dragItems.first;
300+
const thirdItemRect = dragItems.toArray()[2].element.nativeElement.getBoundingClientRect();
301+
302+
dragElementViaMouse(fixture, firstItem.element.nativeElement,
303+
thirdItemRect.left + 1, thirdItemRect.top + 1);
304+
flush();
305+
fixture.detectChanges();
306+
307+
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
308+
309+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
310+
311+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
312+
// go into an infinite loop trying to stringify the event, if the test fails.
313+
expect(event).toEqual({
314+
previousIndex: 0,
315+
currentIndex: 2,
316+
item: firstItem,
317+
container: fixture.componentInstance.dropInstance,
318+
previousContainer: fixture.componentInstance.dropInstance
319+
});
267320

268321
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
269322
.toEqual(['One', 'Two', 'Zero', 'Three']);
@@ -287,6 +340,8 @@ describe('CdkDrag', () => {
287340
expect(preview).toBeTruthy('Expected preview to be in the DOM');
288341
expect(preview.textContent!.trim())
289342
.toContain('One', 'Expected preview content to match element');
343+
expect(preview.getAttribute('dir'))
344+
.toBe('ltr', 'Expected preview element to inherit the directionality.');
290345
expect(previewRect.width).toBe(itemRect.width, 'Expected preview width to match element');
291346
expect(previewRect.height).toBe(itemRect.height, 'Expected preview height to match element');
292347

@@ -300,6 +355,22 @@ describe('CdkDrag', () => {
300355
expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM');
301356
}));
302357

358+
it('should pass the proper direction to the preview in rtl', fakeAsync(() => {
359+
const fixture = createComponent(DraggableInDropZone, [{
360+
provide: Directionality,
361+
useValue: ({value: 'rtl'})
362+
}]);
363+
364+
fixture.detectChanges();
365+
366+
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
367+
dispatchMouseEvent(item, 'mousedown');
368+
fixture.detectChanges();
369+
370+
expect(document.querySelector('.cdk-drag-preview')!.getAttribute('dir'))
371+
.toBe('rtl', 'Expected preview element to inherit the directionality.');
372+
}));
373+
303374
it('should create a placeholder element while the item is dragged', fakeAsync(() => {
304375
const fixture = createComponent(DraggableInDropZone);
305376
fixture.detectChanges();
@@ -358,6 +429,62 @@ describe('CdkDrag', () => {
358429
cleanup();
359430
}));
360431

432+
it('should move the placeholder as an item is being sorted to the right', fakeAsync(() => {
433+
const fixture = createComponent(DraggableInHorizontalDropZone);
434+
fixture.detectChanges();
435+
436+
const items = fixture.componentInstance.dragItems.toArray();
437+
const draggedItem = items[0].element.nativeElement;
438+
const {top, left} = draggedItem.getBoundingClientRect();
439+
440+
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
441+
fixture.detectChanges();
442+
443+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
444+
445+
// Drag over each item one-by-one going to the right.
446+
for (let i = 0; i < items.length; i++) {
447+
const elementRect = items[i].element.nativeElement.getBoundingClientRect();
448+
449+
// Add a few pixels to the left offset so we get some overlap.
450+
dispatchMouseEvent(document, 'mousemove', elementRect.left + 5, elementRect.top);
451+
fixture.detectChanges();
452+
expect(getElementIndex(placeholder)).toBe(i);
453+
}
454+
455+
dispatchMouseEvent(document, 'mouseup');
456+
fixture.detectChanges();
457+
flush();
458+
}));
459+
460+
it('should move the placeholder as an item is being sorted to the left', fakeAsync(() => {
461+
const fixture = createComponent(DraggableInHorizontalDropZone);
462+
fixture.detectChanges();
463+
464+
const items = fixture.componentInstance.dragItems.toArray();
465+
const draggedItem = items[items.length - 1].element.nativeElement;
466+
const {top, left} = draggedItem.getBoundingClientRect();
467+
468+
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
469+
fixture.detectChanges();
470+
471+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
472+
473+
// Drag over each item one-by-one going to the left.
474+
for (let i = items.length - 1; i > -1; i--) {
475+
const elementRect = items[i].element.nativeElement.getBoundingClientRect();
476+
477+
// Remove a few pixels from the right offset so we get some overlap.
478+
dispatchMouseEvent(document, 'mousemove', elementRect.right - 5, elementRect.top);
479+
fixture.detectChanges();
480+
expect(getElementIndex(placeholder)).toBe(Math.min(i + 1, items.length - 1));
481+
}
482+
483+
dispatchMouseEvent(document, 'mouseup');
484+
fixture.detectChanges();
485+
flush();
486+
}));
487+
361488
it('should clean up the preview element if the item is destroyed mid-drag', fakeAsync(() => {
362489
const fixture = createComponent(DraggableInDropZone);
363490
fixture.detectChanges();
@@ -490,6 +617,43 @@ export class DraggableInDropZone {
490617
}
491618

492619

620+
@Component({
621+
encapsulation: ViewEncapsulation.None,
622+
styles: [
623+
// Use inline blocks here to avoid flexbox issues and not to have to flip floats in rtl.
624+
`
625+
.cdk-drop {
626+
display: block;
627+
width: 300px;
628+
background: pink;
629+
font-size: 0;
630+
}
631+
632+
.cdk-drag {
633+
width: ${ITEM_WIDTH}px;
634+
height: ${ITEM_HEIGHT}px;
635+
background: red;
636+
display: inline-block;
637+
}
638+
`],
639+
template: `
640+
<cdk-drop
641+
orientation="horizontal"
642+
[data]="items"
643+
(dropped)="droppedSpy($event)">
644+
<div *ngFor="let item of items" cdkDrag>{{item}}</div>
645+
</cdk-drop>
646+
`
647+
})
648+
export class DraggableInHorizontalDropZone {
649+
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
650+
@ViewChild(CdkDrop) dropInstance: CdkDrop;
651+
items = ['Zero', 'One', 'Two', 'Three'];
652+
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
653+
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
654+
});
655+
}
656+
493657
@Component({
494658
template: `
495659
<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';
@@ -122,7 +123,8 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
122123
@Inject(DOCUMENT) document: any,
123124
private _ngZone: NgZone,
124125
private _viewContainerRef: ViewContainerRef,
125-
private _viewportRuler: ViewportRuler) {
126+
private _viewportRuler: ViewportRuler,
127+
@Optional() private _dir: Directionality) {
126128
this._document = document;
127129
}
128130

@@ -284,7 +286,7 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
284286
});
285287
}
286288

287-
this.dropContainer._sortItem(this, y);
289+
this.dropContainer._sortItem(this, x, y);
288290
this._setTransform(this._preview,
289291
x - this._pickupPositionInElement.x,
290292
y - this._pickupPositionInElement.y);
@@ -315,6 +317,8 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
315317
}
316318

317319
preview.classList.add('cdk-drag-preview');
320+
preview.setAttribute('dir', this._dir ? this._dir.value : 'ltr');
321+
318322
return preview;
319323
}
320324

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)