mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(drag-drop): support sorting items horizontally (#12104)
This commit is contained in:
committed by
Joey Perrott
parent
f2963bc48d
commit
a410865c78
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user