feat(drag-drop): support sorting items horizontally (#12104)

This commit is contained in:
Kristiyan Kostadinov
2018-07-09 19:43:10 +02:00
committed by Joey Perrott
parent f2963bc48d
commit a410865c78
8 changed files with 280 additions and 46 deletions
@@ -13,6 +13,7 @@ ng_module(
"@rxjs",
"//src/cdk/platform",
"//src/cdk/overlay",
"//src/cdk/bidi",
],
tsconfig = "//src/cdk-experimental:tsconfig-build.json",
)
@@ -25,6 +26,7 @@ ts_library(
deps = [
":drag-drop",
"//src/cdk/testing",
"//src/cdk/bidi",
],
tsconfig = "//src/cdk-experimental:tsconfig-build.json",
)
+172 -9
View File
@@ -6,11 +6,13 @@ import {
ViewChildren,
QueryList,
AfterViewInit,
Provider,
ViewEncapsulation,
} from '@angular/core';
import {TestBed, ComponentFixture, fakeAsync, flush} from '@angular/core/testing';
import {DragDropModule} from './drag-drop-module';
import {dispatchMouseEvent, dispatchTouchEvent} from '@angular/cdk/testing';
import {Directionality} from '@angular/cdk/bidi';
import {CdkDrag} from './drag';
import {CdkDragDrop} from './drag-events';
import {moveItemInArray, transferArrayItem} from './drag-utils';
@@ -18,12 +20,15 @@ import {CdkDrop} from './drop';
import {CdkDragHandle} from './drag-handle';
const ITEM_HEIGHT = 25;
const ITEM_WIDTH = 75;
describe('CdkDrag', () => {
function createComponent<T>(componentType: Type<T>): ComponentFixture<T> {
function createComponent<T>(componentType: Type<T>, providers: Provider[] = []):
ComponentFixture<T> {
TestBed.configureTestingModule({
imports: [DragDropModule],
declarations: [componentType],
providers,
}).compileComponents();
return TestBed.createComponent<T>(componentType);
@@ -173,9 +178,13 @@ describe('CdkDrag', () => {
dispatchMouseEvent(fixture.componentInstance.dragElement.nativeElement, 'mousedown');
fixture.detectChanges();
expect(fixture.componentInstance.startedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
source: fixture.componentInstance.dragInstance
}));
expect(fixture.componentInstance.startedSpy).toHaveBeenCalled();
const event = fixture.componentInstance.startedSpy.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({source: fixture.componentInstance.dragInstance});
}));
it('should dispatch an event when the user has stopped dragging', fakeAsync(() => {
@@ -184,9 +193,13 @@ describe('CdkDrag', () => {
dragElementViaMouse(fixture, fixture.componentInstance.dragElement.nativeElement, 5, 10);
expect(fixture.componentInstance.endedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
source: fixture.componentInstance.dragInstance
}));
expect(fixture.componentInstance.endedSpy).toHaveBeenCalled();
const event = fixture.componentInstance.endedSpy.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({source: fixture.componentInstance.dragInstance});
}));
});
@@ -290,13 +303,52 @@ describe('CdkDrag', () => {
fixture.detectChanges();
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledWith(jasmine.objectContaining({
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: 2,
item: firstItem,
container: fixture.componentInstance.dropInstance,
previousContainer: fixture.componentInstance.dropInstance
}));
});
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
.toEqual(['One', 'Two', 'Zero', 'Three']);
}));
it('should dispatch the `dropped` event in a horizontal drop zone', fakeAsync(() => {
const fixture = createComponent(DraggableInHorizontalDropZone);
fixture.detectChanges();
const dragItems = fixture.componentInstance.dragItems;
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
.toEqual(['Zero', 'One', 'Two', 'Three']);
const firstItem = dragItems.first;
const thirdItemRect = dragItems.toArray()[2].element.nativeElement.getBoundingClientRect();
dragElementViaMouse(fixture, firstItem.element.nativeElement,
thirdItemRect.left + 1, thirdItemRect.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: 2,
item: firstItem,
container: fixture.componentInstance.dropInstance,
previousContainer: fixture.componentInstance.dropInstance
});
expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim()))
.toEqual(['One', 'Two', 'Zero', 'Three']);
@@ -320,6 +372,8 @@ describe('CdkDrag', () => {
expect(preview).toBeTruthy('Expected preview to be in the DOM');
expect(preview.textContent!.trim())
.toContain('One', 'Expected preview content to match element');
expect(preview.getAttribute('dir'))
.toBe('ltr', 'Expected preview element to inherit the directionality.');
expect(previewRect.width).toBe(itemRect.width, 'Expected preview width to match element');
expect(previewRect.height).toBe(itemRect.height, 'Expected preview height to match element');
@@ -333,6 +387,22 @@ describe('CdkDrag', () => {
expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM');
}));
it('should pass the proper direction to the preview in rtl', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone, [{
provide: Directionality,
useValue: ({value: 'rtl'})
}]);
fixture.detectChanges();
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
dispatchMouseEvent(item, 'mousedown');
fixture.detectChanges();
expect(document.querySelector('.cdk-drag-preview')!.getAttribute('dir'))
.toBe('rtl', 'Expected preview element to inherit the directionality.');
}));
it('should create a placeholder element while the item is dragged', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
@@ -391,6 +461,62 @@ describe('CdkDrag', () => {
cleanup();
}));
it('should move the placeholder as an item is being sorted to the right', fakeAsync(() => {
const fixture = createComponent(DraggableInHorizontalDropZone);
fixture.detectChanges();
const items = fixture.componentInstance.dragItems.toArray();
const draggedItem = items[0].element.nativeElement;
const {top, left} = draggedItem.getBoundingClientRect();
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
fixture.detectChanges();
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
// Drag over each item one-by-one going to the right.
for (let i = 0; i < items.length; i++) {
const elementRect = items[i].element.nativeElement.getBoundingClientRect();
// Add a few pixels to the left offset so we get some overlap.
dispatchMouseEvent(document, 'mousemove', elementRect.left + 5, elementRect.top);
fixture.detectChanges();
expect(getElementIndex(placeholder)).toBe(i);
}
dispatchMouseEvent(document, 'mouseup');
fixture.detectChanges();
flush();
}));
it('should move the placeholder as an item is being sorted to the left', fakeAsync(() => {
const fixture = createComponent(DraggableInHorizontalDropZone);
fixture.detectChanges();
const items = fixture.componentInstance.dragItems.toArray();
const draggedItem = items[items.length - 1].element.nativeElement;
const {top, left} = draggedItem.getBoundingClientRect();
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
fixture.detectChanges();
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
// Drag over each item one-by-one going to the left.
for (let i = items.length - 1; i > -1; i--) {
const elementRect = items[i].element.nativeElement.getBoundingClientRect();
// Remove a few pixels from the right offset so we get some overlap.
dispatchMouseEvent(document, 'mousemove', elementRect.right - 5, elementRect.top);
fixture.detectChanges();
expect(getElementIndex(placeholder)).toBe(Math.min(i + 1, items.length - 1));
}
dispatchMouseEvent(document, 'mouseup');
fixture.detectChanges();
flush();
}));
it('should clean up the preview element if the item is destroyed mid-drag', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
@@ -562,6 +688,43 @@ export class DraggableInDropZone {
}
@Component({
encapsulation: ViewEncapsulation.None,
styles: [
// Use inline blocks here to avoid flexbox issues and not to have to flip floats in rtl.
`
.cdk-drop {
display: block;
width: 300px;
background: pink;
font-size: 0;
}
.cdk-drag {
width: ${ITEM_WIDTH}px;
height: ${ITEM_HEIGHT}px;
background: red;
display: inline-block;
}
`],
template: `
<cdk-drop
orientation="horizontal"
[data]="items"
(dropped)="droppedSpy($event)">
<div *ngFor="let item of items" cdkDrag>{{item}}</div>
</cdk-drop>
`
})
export class DraggableInHorizontalDropZone {
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
@ViewChild(CdkDrop) dropInstance: CdkDrop;
items = ['Zero', 'One', 'Two', 'Three'];
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
});
}
@Component({
template: `
<cdk-drop style="display: block; width: 100px; background: pink;">
+7 -3
View File
@@ -23,8 +23,9 @@ import {
ContentChildren,
QueryList,
} from '@angular/core';
import {CdkDragHandle} from './drag-handle';
import {DOCUMENT} from '@angular/platform-browser';
import {Directionality} from '@angular/cdk/bidi';
import {CdkDragHandle} from './drag-handle';
import {CdkDropContainer, CDK_DROP_CONTAINER} from './drop-container';
import {supportsPassiveEventListeners} from '@angular/cdk/platform';
import {CdkDragStart, CdkDragEnd, CdkDragExit, CdkDragEnter, CdkDragDrop} from './drag-events';
@@ -126,7 +127,8 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
@Inject(DOCUMENT) document: any,
private _ngZone: NgZone,
private _viewContainerRef: ViewContainerRef,
private _viewportRuler: ViewportRuler) {
private _viewportRuler: ViewportRuler,
@Optional() private _dir: Directionality) {
this._document = document;
}
@@ -302,7 +304,7 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
});
}
this.dropContainer._sortItem(this, y);
this.dropContainer._sortItem(this, x, y);
this._setTransform(this._preview,
x - this._pickupPositionInElement.x,
y - this._pickupPositionInElement.y);
@@ -333,6 +335,8 @@ export class CdkDrag implements AfterContentInit, OnDestroy {
}
preview.classList.add('cdk-drag-preview');
preview.setAttribute('dir', this._dir ? this._dir.value : 'ltr');
return preview;
}
@@ -14,6 +14,9 @@ export interface CdkDropContainer<T = any> {
/** Arbitrary data to attach to all events emitted by this container. */
data: T;
/** Direction in which the list is oriented. */
orientation: 'horizontal' | 'vertical';
/** Starts dragging an item. */
start(): void;
@@ -42,7 +45,7 @@ export interface CdkDropContainer<T = any> {
* @param item Item whose index should be determined.
*/
getItemIndex(item: CdkDrag): number;
_sortItem(item: CdkDrag, yOffset: number): void;
_sortItem(item: CdkDrag, xOffset: number, yOffset: number): void;
_draggables: QueryList<CdkDrag>;
_getSiblingContainerFromPosition(x: number, y: number): CdkDropContainer | null;
}
+12 -4
View File
@@ -22,7 +22,6 @@ import {CdkDrag} from './drag';
import {CdkDragExit, CdkDragEnter, CdkDragDrop} from './drag-events';
import {CDK_DROP_CONTAINER} from './drop-container';
/** Container that wraps a set of draggable items. */
@Component({
moduleId: module.id,
@@ -53,6 +52,9 @@ export class CdkDrop<T = any> {
/** Arbitrary data to attach to all events emitted by this container. */
@Input() data: T;
/** Direction in which the list is oriented. */
@Input() orientation: 'horizontal' | 'vertical' = 'vertical';
/** Emits when the user drops an item inside the container. */
@Output() dropped = new EventEmitter<CdkDragDrop<T, any>>();
@@ -132,13 +134,19 @@ export class CdkDrop<T = any> {
/**
* Sorts an item inside the container based on its position.
* @param item Item to be sorted.
* @param xOffset Position of the item along the X axis.
* @param yOffset Position of the item along the Y axis.
*/
_sortItem(item: CdkDrag, yOffset: number): void {
// TODO: only covers Y axis sorting.
_sortItem(item: CdkDrag, xOffset: number, yOffset: number): void {
const siblings = this._positionCache.items;
const newPosition = siblings.find(({drag, clientRect}) => {
return drag !== item && yOffset > clientRect.top && yOffset < clientRect.bottom;
if (drag === item) {
return false;
}
return this.orientation === 'horizontal' ?
xOffset > clientRect.left && xOffset < clientRect.right :
yOffset > clientRect.top && yOffset < clientRect.bottom;
});
if (!newPosition && siblings.length > 0) {
+43 -25
View File
@@ -1,35 +1,53 @@
<div class="list">
<h2>To do</h2>
<cdk-drop
#one
(cdkDragDropped)="drop($event)"
[data]="todo"
[connectedTo]="[two]">
<div *ngFor="let item of todo" cdkDrag>
{{item}}
<mat-icon cdkDragHandle svgIcon="dnd-move"></mat-icon>
</div>
</cdk-drop>
<div>
<div class="list">
<h2>To do</h2>
<cdk-drop
#one
(dropped)="drop($event)"
[data]="todo"
[connectedTo]="[two]">
<div *ngFor="let item of todo" cdkDrag>
{{item}}
<mat-icon cdkDragHandle svgIcon="dnd-move"></mat-icon>
</div>
</cdk-drop>
</div>
<div class="list">
<h2>Done</h2>
<cdk-drop
#two
(dropped)="drop($event)"
[data]="done"
[connectedTo]="[one]">
<div *ngFor="let item of done" cdkDrag>
{{item}}
<mat-icon cdkDragHandle svgIcon="dnd-move"></mat-icon>
</div>
</cdk-drop>
</div>
</div>
<div class="list">
<h2>Done</h2>
<cdk-drop
#two
(cdkDragDropped)="drop($event)"
[data]="done"
[connectedTo]="[one]">
<div *ngFor="let item of done" cdkDrag>
{{item}}
<mat-icon cdkDragHandle svgIcon="dnd-move"></mat-icon>
</div>
</cdk-drop>
<div>
<div class="list horizontal">
<h2>Horizontal list</h2>
<cdk-drop
orientation="horizontal"
(dropped)="drop($event)"
[data]="horizontalData">
<div *ngFor="let item of horizontalData" cdkDrag>
{{item}}
<mat-icon cdkDragHandle svgIcon="dnd-move"></mat-icon>
</div>
</cdk-drop>
</div>
</div>
<div class="list">
<div>
<h2>Data</h2>
<pre>{{todo.join(', ')}}</pre>
<pre>{{done.join(', ')}}</pre>
<pre>{{horizontalData.join(', ')}}</pre>
</div>
<div class="list">
+32 -4
View File
@@ -5,12 +5,28 @@
display: inline-block;
margin-right: 25px;
vertical-align: top;
[dir='rtl'] & {
margin-right: 0;
margin-left: 25px;
}
&.horizontal {
width: 1000px;
margin-right: 0;
margin-left: 0;
}
}
.cdk-drop {
border: solid 1px #ccc;
min-height: 60px;
display: block;
.horizontal & {
display: flex;
flex-direction: row;
}
}
.cdk-drag {
@@ -22,13 +38,25 @@
justify-content: space-between;
box-sizing: border-box;
.cdk-drop &:last-child {
border: none;
}
.cdk-drop-dragging & {
transition: transform 500ms ease;
}
.horizontal & {
border: none;
border-right: solid 1px #ccc;
flex-grow: 1;
flex-basis: 0;
[dir='rtl'] & {
border-right: none;
border-left: solid 1px #ccc;
}
}
.cdk-drop &:last-child {
border: none;
}
}
.cdk-drag-preview {
+8
View File
@@ -33,6 +33,14 @@ export class DragAndDropDemo {
'Check reddit'
];
horizontalData = [
'Bronze age',
'Iron age',
'Middle ages',
'Early modern period',
'Long nineteenth century'
];
constructor(iconRegistry: MatIconRegistry, sanitizer: DomSanitizer) {
iconRegistry.addSvgIconLiteral('dnd-move', sanitizer.bypassSecurityTrustHtml(`
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">