diff --git a/src/cdk-experimental/drag-drop/BUILD.bazel b/src/cdk-experimental/drag-drop/BUILD.bazel index 51c0579195f1..06f1f159d56a 100644 --- a/src/cdk-experimental/drag-drop/BUILD.bazel +++ b/src/cdk-experimental/drag-drop/BUILD.bazel @@ -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", ) diff --git a/src/cdk-experimental/drag-drop/drag.spec.ts b/src/cdk-experimental/drag-drop/drag.spec.ts index fd136e5145d6..7174af7d9579 100644 --- a/src/cdk-experimental/drag-drop/drag.spec.ts +++ b/src/cdk-experimental/drag-drop/drag.spec.ts @@ -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(componentType: Type): ComponentFixture { + function createComponent(componentType: Type, providers: Provider[] = []): + ComponentFixture { TestBed.configureTestingModule({ imports: [DragDropModule], declarations: [componentType], + providers, }).compileComponents(); return TestBed.createComponent(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: ` + +
{{item}}
+
+ ` +}) +export class DraggableInHorizontalDropZone { + @ViewChildren(CdkDrag) dragItems: QueryList; + @ViewChild(CdkDrop) dropInstance: CdkDrop; + items = ['Zero', 'One', 'Two', 'Three']; + droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop) => { + moveItemInArray(this.items, event.previousIndex, event.currentIndex); + }); +} + @Component({ template: ` diff --git a/src/cdk-experimental/drag-drop/drag.ts b/src/cdk-experimental/drag-drop/drag.ts index 8bf3f06d6cd8..22c54e895098 100644 --- a/src/cdk-experimental/drag-drop/drag.ts +++ b/src/cdk-experimental/drag-drop/drag.ts @@ -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; } diff --git a/src/cdk-experimental/drag-drop/drop-container.ts b/src/cdk-experimental/drag-drop/drop-container.ts index 39bf9f8ca9e7..ae9aaedb3ecc 100644 --- a/src/cdk-experimental/drag-drop/drop-container.ts +++ b/src/cdk-experimental/drag-drop/drop-container.ts @@ -14,6 +14,9 @@ export interface CdkDropContainer { /** 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 { * @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; _getSiblingContainerFromPosition(x: number, y: number): CdkDropContainer | null; } diff --git a/src/cdk-experimental/drag-drop/drop.ts b/src/cdk-experimental/drag-drop/drop.ts index b91606f498ca..9d50b832d782 100644 --- a/src/cdk-experimental/drag-drop/drop.ts +++ b/src/cdk-experimental/drag-drop/drop.ts @@ -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 { /** 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>(); @@ -132,13 +134,19 @@ export class CdkDrop { /** * 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) { diff --git a/src/demo-app/drag-drop/drag-drop-demo.html b/src/demo-app/drag-drop/drag-drop-demo.html index 1cf13b842bfe..1ebf05dbd83f 100644 --- a/src/demo-app/drag-drop/drag-drop-demo.html +++ b/src/demo-app/drag-drop/drag-drop-demo.html @@ -1,35 +1,53 @@ -
-

To do

- -
- {{item}} - -
-
+
+
+

To do

+ +
+ {{item}} + +
+
+
+ +
+

Done

+ +
+ {{item}} + +
+
+
-
-

Done

- -
- {{item}} - -
-
+
+
+

Horizontal list

+ +
+ {{item}} + +
+
+
-
+

Data

{{todo.join(', ')}}
{{done.join(', ')}}
+
{{horizontalData.join(', ')}}
diff --git a/src/demo-app/drag-drop/drag-drop-demo.scss b/src/demo-app/drag-drop/drag-drop-demo.scss index f9aa313d0769..b30f5796fb97 100644 --- a/src/demo-app/drag-drop/drag-drop-demo.scss +++ b/src/demo-app/drag-drop/drag-drop-demo.scss @@ -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,12 +38,24 @@ justify-content: space-between; box-sizing: border-box; - .cdk-drop &:last-child { + .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-dragging & { - transition: transform 500ms ease; + .cdk-drop &:last-child { + border: none; } } diff --git a/src/demo-app/drag-drop/drag-drop-demo.ts b/src/demo-app/drag-drop/drag-drop-demo.ts index bc2e16543fe3..b72cedcf9304 100644 --- a/src/demo-app/drag-drop/drag-drop-demo.ts +++ b/src/demo-app/drag-drop/drag-drop-demo.ts @@ -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(`