Skip to content

Commit a410865

Browse files
crisbetojosephperrott
authored andcommitted
feat(drag-drop): support sorting items horizontally (#12104)
1 parent f2963bc commit a410865

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: 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: 172 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,29 @@ import {
66
ViewChildren,
77
QueryList,
88
AfterViewInit,
9+
Provider,
910
ViewEncapsulation,
1011
} from '@angular/core';
1112
import {TestBed, ComponentFixture, fakeAsync, flush} from '@angular/core/testing';
1213
import {DragDropModule} from './drag-drop-module';
1314
import {dispatchMouseEvent, dispatchTouchEvent} from '@angular/cdk/testing';
15+
import {Directionality} from '@angular/cdk/bidi';
1416
import {CdkDrag} from './drag';
1517
import {CdkDragDrop} from './drag-events';
1618
import {moveItemInArray, transferArrayItem} from './drag-utils';
1719
import {CdkDrop} from './drop';
1820
import {CdkDragHandle} from './drag-handle';
1921

2022
const ITEM_HEIGHT = 25;
23+
const ITEM_WIDTH = 75;
2124

2225
describe('CdkDrag', () => {
23-
function createComponent<T>(componentType: Type<T>): ComponentFixture<T> {
26+
function createComponent<T>(componentType: Type<T>, providers: Provider[] = []):
27+
ComponentFixture<T> {
2428
TestBed.configureTestingModule({
2529
imports: [DragDropModule],
2630
declarations: [componentType],
31+
providers,
2732
}).compileComponents();
2833

2934
return TestBed.createComponent<T>(componentType);
@@ -173,9 +178,13 @@ describe('CdkDrag', () => {
173178
dispatchMouseEvent(fixture.componentInstance.dragElement.nativeElement, 'mousedown');
174179
fixture.detectChanges();
175180

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

181190
it('should dispatch an event when the user has stopped dragging', fakeAsync(() => {
@@ -184,9 +193,13 @@ describe('CdkDrag', () => {
184193

185194
dragElementViaMouse(fixture, fixture.componentInstance.dragElement.nativeElement, 5, 10);
186195

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

@@ -290,13 +303,52 @@ describe('CdkDrag', () => {
290303
fixture.detectChanges();
291304

292305
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
293-
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
306+
307+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
308+
309+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
310+
// go into an infinite loop trying to stringify the event, if the test fails.
311+
expect(event).toEqual({
294312
previousIndex: 0,
295313
currentIndex: 2,
296314
item: firstItem,
297315
container: fixture.componentInstance.dropInstance,
298316
previousContainer: fixture.componentInstance.dropInstance
299-
}));
317+
});
318+
319+
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
320+
.toEqual(['One', 'Two', 'Zero', 'Three']);
321+
}));
322+
323+
it('should dispatch the `dropped` event in a horizontal drop zone', fakeAsync(() => {
324+
const fixture = createComponent(DraggableInHorizontalDropZone);
325+
fixture.detectChanges();
326+
const dragItems = fixture.componentInstance.dragItems;
327+
328+
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
329+
.toEqual(['Zero', 'One', 'Two', 'Three']);
330+
331+
const firstItem = dragItems.first;
332+
const thirdItemRect = dragItems.toArray()[2].element.nativeElement.getBoundingClientRect();
333+
334+
dragElementViaMouse(fixture, firstItem.element.nativeElement,
335+
thirdItemRect.left + 1, thirdItemRect.top + 1);
336+
flush();
337+
fixture.detectChanges();
338+
339+
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
340+
341+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
342+
343+
// Assert the event like this, rather than `toHaveBeenCalledWith`, because Jasmine will
344+
// go into an infinite loop trying to stringify the event, if the test fails.
345+
expect(event).toEqual({
346+
previousIndex: 0,
347+
currentIndex: 2,
348+
item: firstItem,
349+
container: fixture.componentInstance.dropInstance,
350+
previousContainer: fixture.componentInstance.dropInstance
351+
});
300352

301353
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
302354
.toEqual(['One', 'Two', 'Zero', 'Three']);
@@ -320,6 +372,8 @@ describe('CdkDrag', () => {
320372
expect(preview).toBeTruthy('Expected preview to be in the DOM');
321373
expect(preview.textContent!.trim())
322374
.toContain('One', 'Expected preview content to match element');
375+
expect(preview.getAttribute('dir'))
376+
.toBe('ltr', 'Expected preview element to inherit the directionality.');
323377
expect(previewRect.width).toBe(itemRect.width, 'Expected preview width to match element');
324378
expect(previewRect.height).toBe(itemRect.height, 'Expected preview height to match element');
325379

@@ -333,6 +387,22 @@ describe('CdkDrag', () => {
333387
expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM');
334388
}));
335389

390+
it('should pass the proper direction to the preview in rtl', fakeAsync(() => {
391+
const fixture = createComponent(DraggableInDropZone, [{
392+
provide: Directionality,
393+
useValue: ({value: 'rtl'})
394+
}]);
395+
396+
fixture.detectChanges();
397+
398+
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
399+
dispatchMouseEvent(item, 'mousedown');
400+
fixture.detectChanges();
401+
402+
expect(document.querySelector('.cdk-drag-preview')!.getAttribute('dir'))
403+
.toBe('rtl', 'Expected preview element to inherit the directionality.');
404+
}));
405+
336406
it('should create a placeholder element while the item is dragged', fakeAsync(() => {
337407
const fixture = createComponent(DraggableInDropZone);
338408
fixture.detectChanges();
@@ -391,6 +461,62 @@ describe('CdkDrag', () => {
391461
cleanup();
392462
}));
393463

464+
it('should move the placeholder as an item is being sorted to the right', fakeAsync(() => {
465+
const fixture = createComponent(DraggableInHorizontalDropZone);
466+
fixture.detectChanges();
467+
468+
const items = fixture.componentInstance.dragItems.toArray();
469+
const draggedItem = items[0].element.nativeElement;
470+
const {top, left} = draggedItem.getBoundingClientRect();
471+
472+
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
473+
fixture.detectChanges();
474+
475+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
476+
477+
// Drag over each item one-by-one going to the right.
478+
for (let i = 0; i < items.length; i++) {
479+
const elementRect = items[i].element.nativeElement.getBoundingClientRect();
480+
481+
// Add a few pixels to the left offset so we get some overlap.
482+
dispatchMouseEvent(document, 'mousemove', elementRect.left + 5, elementRect.top);
483+
fixture.detectChanges();
484+
expect(getElementIndex(placeholder)).toBe(i);
485+
}
486+
487+
dispatchMouseEvent(document, 'mouseup');
488+
fixture.detectChanges();
489+
flush();
490+
}));
491+
492+
it('should move the placeholder as an item is being sorted to the left', fakeAsync(() => {
493+
const fixture = createComponent(DraggableInHorizontalDropZone);
494+
fixture.detectChanges();
495+
496+
const items = fixture.componentInstance.dragItems.toArray();
497+
const draggedItem = items[items.length - 1].element.nativeElement;
498+
const {top, left} = draggedItem.getBoundingClientRect();
499+
500+
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
501+
fixture.detectChanges();
502+
503+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
504+
505+
// Drag over each item one-by-one going to the left.
506+
for (let i = items.length - 1; i > -1; i--) {
507+
const elementRect = items[i].element.nativeElement.getBoundingClientRect();
508+
509+
// Remove a few pixels from the right offset so we get some overlap.
510+
dispatchMouseEvent(document, 'mousemove', elementRect.right - 5, elementRect.top);
511+
fixture.detectChanges();
512+
expect(getElementIndex(placeholder)).toBe(Math.min(i + 1, items.length - 1));
513+
}
514+
515+
dispatchMouseEvent(document, 'mouseup');
516+
fixture.detectChanges();
517+
flush();
518+
}));
519+
394520
it('should clean up the preview element if the item is destroyed mid-drag', fakeAsync(() => {
395521
const fixture = createComponent(DraggableInDropZone);
396522
fixture.detectChanges();
@@ -562,6 +688,43 @@ export class DraggableInDropZone {
562688
}
563689

564690

691+
@Component({
692+
encapsulation: ViewEncapsulation.None,
693+
styles: [
694+
// Use inline blocks here to avoid flexbox issues and not to have to flip floats in rtl.
695+
`
696+
.cdk-drop {
697+
display: block;
698+
width: 300px;
699+
background: pink;
700+
font-size: 0;
701+
}
702+
703+
.cdk-drag {
704+
width: ${ITEM_WIDTH}px;
705+
height: ${ITEM_HEIGHT}px;
706+
background: red;
707+
display: inline-block;
708+
}
709+
`],
710+
template: `
711+
<cdk-drop
712+
orientation="horizontal"
713+
[data]="items"
714+
(dropped)="droppedSpy($event)">
715+
<div *ngFor="let item of items" cdkDrag>{{item}}</div>
716+
</cdk-drop>
717+
`
718+
})
719+
export class DraggableInHorizontalDropZone {
720+
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
721+
@ViewChild(CdkDrop) dropInstance: CdkDrop;
722+
items = ['Zero', 'One', 'Two', 'Three'];
723+
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
724+
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
725+
});
726+
}
727+
565728
@Component({
566729
template: `
567730
<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
@@ -23,8 +23,9 @@ import {
2323
ContentChildren,
2424
QueryList,
2525
} from '@angular/core';
26-
import {CdkDragHandle} from './drag-handle';
2726
import {DOCUMENT} from '@angular/platform-browser';
27+
import {Directionality} from '@angular/cdk/bidi';
28+
import {CdkDragHandle} from './drag-handle';
2829
import {CdkDropContainer, CDK_DROP_CONTAINER} from './drop-container';
2930
import {supportsPassiveEventListeners} from '@angular/cdk/platform';
3031
import {CdkDragStart, CdkDragEnd, CdkDragExit, CdkDragEnter, CdkDragDrop} from './drag-events';
@@ -126,7 +127,8 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
126127
@Inject(DOCUMENT) document: any,
127128
private _ngZone: NgZone,
128129
private _viewContainerRef: ViewContainerRef,
129-
private _viewportRuler: ViewportRuler) {
130+
private _viewportRuler: ViewportRuler,
131+
@Optional() private _dir: Directionality) {
130132
this._document = document;
131133
}
132134

@@ -302,7 +304,7 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
302304
});
303305
}
304306

305-
this.dropContainer._sortItem(this, y);
307+
this.dropContainer._sortItem(this, x, y);
306308
this._setTransform(this._preview,
307309
x - this._pickupPositionInElement.x,
308310
y - this._pickupPositionInElement.y);
@@ -333,6 +335,8 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
333335
}
334336

335337
preview.classList.add('cdk-drag-preview');
338+
preview.setAttribute('dir', this._dir ? this._dir.value : 'ltr');
339+
336340
return preview;
337341
}
338342

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)