diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7464d57455a9..245eba80c748 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -87,6 +87,7 @@ /src/cdk-experimental/** @jelbourn /src/cdk-experimental/dialog/** @jelbourn @josephperrott @crisbeto /src/cdk-experimental/scrolling/** @mmalerba +/src/cdk-experimental/drag-drop/** @crisbeto # Docs examples & guides /guides/** @amcdnl @jelbourn @@ -110,6 +111,7 @@ /src/demo-app/demo-app/** @jelbourn /src/demo-app/dialog/** @jelbourn @crisbeto /src/demo-app/drawer/** @mmalerba +/src/demo-app/drag-drop/** @crisbeto /src/demo-app/example/** @andrewseguin /src/demo-app/examples-page/** @andrewseguin /src/demo-app/expansion/** @josephperrott diff --git a/packages.bzl b/packages.bzl index f528c17310cd..17aa21984ad5 100644 --- a/packages.bzl +++ b/packages.bzl @@ -24,6 +24,7 @@ CDK_TARGETS = ["//src/cdk"] + ["//src/cdk/%s" % p for p in CDK_PACKAGES] CDK_EXPERIMENTAL_PACKAGES = [ "dialog", "scrolling", + "drag-drop", ] CDK_EXPERIMENTAL_TARGETS = ["//src/cdk-experimental"] + [ diff --git a/src/cdk-experimental/drag-drop/BUILD.bazel b/src/cdk-experimental/drag-drop/BUILD.bazel new file mode 100644 index 000000000000..ebd082185db4 --- /dev/null +++ b/src/cdk-experimental/drag-drop/BUILD.bazel @@ -0,0 +1,51 @@ +package(default_visibility=["//visibility:public"]) +load("@angular//:index.bzl", "ng_module") +load("@build_bazel_rules_typescript//:defs.bzl", "ts_library", "ts_web_test") +load("@io_bazel_rules_sass//sass:sass.bzl", "sass_binary") + + +ng_module( + name = "drag-drop", + srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), + module_name = "@angular/cdk-experimental/drag-drop", + assets = [":drop.css"], + deps = [ + "@rxjs", + "//src/cdk/platform", + ], + tsconfig = "//src/cdk-experimental:tsconfig-build.json", +) + + +ts_library( + name = "drag_and_drop_test_sources", + testonly = 1, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":drag-drop", + "//src/cdk/testing", + ], + tsconfig = "//src/cdk-experimental:tsconfig-build.json", +) + +sass_binary( + name = "drop_scss", + src = "drop.scss", +) + +ts_web_test( + name = "unit_tests", + bootstrap = [ + "//:web_test_bootstrap_scripts", + ], + tags = ["manual"], + + # Do not sort + deps = [ + "//:tslib_bundle", + "//:angular_bundles", + "//:angular_test_bundles", + "//test:angular_test_init", + ":drag_and_drop_test_sources", + ], +) diff --git a/src/cdk-experimental/drag-drop/drag-drop-module.ts b/src/cdk-experimental/drag-drop/drag-drop-module.ts new file mode 100644 index 000000000000..f682197a6ee2 --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag-drop-module.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {CdkDrop} from './drop'; +import {CdkDrag} from './drag'; +import {CdkDragHandle} from './drag-handle'; +import {CdkDragPreview} from './drag-preview'; +import {CdkDragPlaceholder} from './drag-placeholder'; + +@NgModule({ + declarations: [ + CdkDrop, + CdkDrag, + CdkDragHandle, + CdkDragPreview, + CdkDragPlaceholder, + ], + exports: [ + CdkDrop, + CdkDrag, + CdkDragHandle, + CdkDragPreview, + CdkDragPlaceholder, + ], +}) +export class DragDropModule {} diff --git a/src/cdk-experimental/drag-drop/drag-drop.md b/src/cdk-experimental/drag-drop/drag-drop.md new file mode 100644 index 000000000000..02b37e1f11e8 --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag-drop.md @@ -0,0 +1 @@ +# TODO diff --git a/src/cdk-experimental/drag-drop/drag-events.ts b/src/cdk-experimental/drag-drop/drag-events.ts new file mode 100644 index 000000000000..7d71eb4ca38e --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag-events.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkDrag} from './drag'; +import {CdkDropContainer} from './drop-container'; + +/** Event emitted when the user starts dragging a draggable. */ +export interface CdkDragStart { + /** Draggable that emitted the event. */ + source: CdkDrag; +} + + +/** Event emitted when the user stops dragging a draggable. */ +export interface CdkDragEnd { + /** Draggable that emitted the event. */ + source: CdkDrag; +} + +/** Event emitted when the user moves an item into a new drop container. */ +export interface CdkDragEnter { + /** Container into which the user has moved the item. */ + container: CdkDropContainer; + /** Item that was removed from the container. */ + item: CdkDrag; +} + +/** + * Event emitted when the user removes an item from a + * drop container by moving it into another one. + */ +export interface CdkDragExit { + /** Container from which the user has a removed an item. */ + container: CdkDropContainer; + /** Item that was removed from the container. */ + item: CdkDrag; +} + + +/** Event emitted when the user drops a draggable item inside a drop container. */ +export interface CdkDragDrop { + /** Index of the item when it was picked up. */ + previousIndex: number; + /** Current index of the item. */ + currentIndex: number; + /** Item that is being dropped. */ + item: CdkDrag; + /** Container in which the item was dropped. */ + container: CdkDropContainer; + /** Container from which the item was picked up. Can be the same as the `container`. */ + previousContainer: CdkDropContainer; +} diff --git a/src/cdk-experimental/drag-drop/drag-handle.ts b/src/cdk-experimental/drag-drop/drag-handle.ts new file mode 100644 index 000000000000..6fd5282aaf66 --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag-handle.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef} from '@angular/core'; + +/** Handle that can be used to drag and CdkDrag instance. */ +@Directive({ + selector: '[cdkDragHandle]', + host: { + 'class': 'cdk-drag-handle' + } +}) +export class CdkDragHandle { + constructor(public element: ElementRef) {} +} diff --git a/src/cdk-experimental/drag-drop/drag-placeholder.ts b/src/cdk-experimental/drag-drop/drag-placeholder.ts new file mode 100644 index 000000000000..518415e1b64f --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag-placeholder.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, TemplateRef, Input} from '@angular/core'; + +/** + * Element that will be used as a template for the placeholder of a CdkDrag when + * it is being dragged. The placeholder is displayed in place of the element being dragged. + */ +@Directive({ + selector: 'ng-template[cdkDragPlaceholder]' +}) +export class CdkDragPlaceholder { + /** Context data to be added to the placeholder template instance. */ + @Input() data: T; + constructor(public templateRef: TemplateRef) {} +} diff --git a/src/cdk-experimental/drag-drop/drag-preview.ts b/src/cdk-experimental/drag-drop/drag-preview.ts new file mode 100644 index 000000000000..cb7078f9e7fd --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag-preview.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, TemplateRef, Input} from '@angular/core'; + +/** + * Element that will be used as a template for the preview + * of a CdkDrag when it is being dragged. + */ +@Directive({ + selector: 'ng-template[cdkDragPreview]' +}) +export class CdkDragPreview { + /** Context data to be added to the preview template instance. */ + @Input() data: T; + constructor(public templateRef: TemplateRef) {} +} diff --git a/src/cdk-experimental/drag-drop/drag-utils.ts b/src/cdk-experimental/drag-drop/drag-utils.ts new file mode 100644 index 000000000000..da8cecb5becb --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag-utils.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Moves an item one index in an array to another. + * @param array Array in which to move the item. + * @param fromIndex Starting index of the item. + * @param toIndex Index to which the item should be moved. + */ +export function moveItemInArray(array: T[], fromIndex: number, toIndex: number): void { + if (fromIndex === toIndex) { + return; + } + + const target = array[fromIndex]; + const delta = toIndex < fromIndex ? -1 : 1; + + for (let i = fromIndex; i !== toIndex; i += delta) { + array[i] = array[i + delta]; + } + + array[toIndex] = target; +} + + +/** + * Moves an item from one array to another. + * @param currentArray Array from which to transfer the item. + * @param targetArray Array into which to put the item. + * @param currentIndex Index of the item in its current array. + * @param targetIndex Index at which to insert the item. + */ +export function transferArrayItem(currentArray: T[], + targetArray: T[], + currentIndex: number, + targetIndex: number): void { + targetArray.splice(targetIndex, 0, currentArray.splice(currentIndex, 1)[0]); +} diff --git a/src/cdk-experimental/drag-drop/drag.spec.ts b/src/cdk-experimental/drag-drop/drag.spec.ts new file mode 100644 index 000000000000..dbc195563360 --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag.spec.ts @@ -0,0 +1,582 @@ +import { + Component, + Type, + ViewChild, + ElementRef, + ViewChildren, + QueryList, + AfterViewInit, +} 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 {CdkDrag} from './drag'; +import {CdkDragDrop} from './drag-events'; +import {moveItemInArray, transferArrayItem} from './drag-utils'; +import {CdkDrop} from './drop'; + +const ITEM_HEIGHT = 25; + +describe('CdkDrag', () => { + function createComponent(componentType: Type): ComponentFixture { + TestBed.configureTestingModule({ + imports: [DragDropModule], + declarations: [componentType], + }).compileComponents(); + + return TestBed.createComponent(componentType); + } + + describe('standalone draggable', () => { + describe('mouse dragging', () => { + it('should drag an element freely to a particular position', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + const dragElement = fixture.componentInstance.dragElement.nativeElement; + + expect(dragElement.style.transform).toBeFalsy(); + dragElementViaMouse(fixture, dragElement, 50, 100); + expect(dragElement.style.transform).toBe('translate3d(50px, 100px, 0px)'); + })); + + it('should continue dragging the element from where it was left off', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + const dragElement = fixture.componentInstance.dragElement.nativeElement; + + expect(dragElement.style.transform).toBeFalsy(); + + dragElementViaMouse(fixture, dragElement, 50, 100); + expect(dragElement.style.transform).toBe('translate3d(50px, 100px, 0px)'); + + dragElementViaMouse(fixture, dragElement, 100, 200); + expect(dragElement.style.transform).toBe('translate3d(150px, 300px, 0px)'); + })); + }); + + describe('touch dragging', () => { + it('should drag an element freely to a particular position', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + const dragElement = fixture.componentInstance.dragElement.nativeElement; + + expect(dragElement.style.transform).toBeFalsy(); + dragElementViaTouch(fixture, dragElement, 50, 100); + expect(dragElement.style.transform).toBe('translate3d(50px, 100px, 0px)'); + })); + + it('should continue dragging the element from where it was left off', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + const dragElement = fixture.componentInstance.dragElement.nativeElement; + + expect(dragElement.style.transform).toBeFalsy(); + + dragElementViaTouch(fixture, dragElement, 50, 100); + expect(dragElement.style.transform).toBe('translate3d(50px, 100px, 0px)'); + + dragElementViaTouch(fixture, dragElement, 100, 200); + expect(dragElement.style.transform).toBe('translate3d(150px, 300px, 0px)'); + })); + + it('should prevent the default `touchmove` action on the page while dragging', + fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + + dispatchTouchEvent(fixture.componentInstance.dragElement.nativeElement, 'touchstart'); + fixture.detectChanges(); + + expect(dispatchTouchEvent(document, 'touchmove').defaultPrevented).toBe(true); + + dispatchTouchEvent(document, 'touchend'); + fixture.detectChanges(); + })); + }); + + it('should dispatch an event when the user has started dragging', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + + dispatchMouseEvent(fixture.componentInstance.dragElement.nativeElement, 'mousedown'); + fixture.detectChanges(); + + expect(fixture.componentInstance.startedSpy).toHaveBeenCalledWith(jasmine.objectContaining({ + source: fixture.componentInstance.dragInstance + })); + })); + + it('should dispatch an event when the user has stopped dragging', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + + dragElementViaMouse(fixture, fixture.componentInstance.dragElement.nativeElement, 5, 10); + + expect(fixture.componentInstance.endedSpy).toHaveBeenCalledWith(jasmine.objectContaining({ + source: fixture.componentInstance.dragInstance + })); + })); + }); + + describe('draggable with a handle', () => { + it('should not be able to drag the entire element if it has a handle', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggableWithHandle); + fixture.detectChanges(); + const dragElement = fixture.componentInstance.dragElement.nativeElement; + + expect(dragElement.style.transform).toBeFalsy(); + dragElementViaMouse(fixture, dragElement, 50, 100); + expect(dragElement.style.transform).toBeFalsy(); + })); + + it('should be able to drag an element using its handle', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggableWithHandle); + fixture.detectChanges(); + const dragElement = fixture.componentInstance.dragElement.nativeElement; + const handle = fixture.componentInstance.handleElement.nativeElement; + + expect(dragElement.style.transform).toBeFalsy(); + dragElementViaMouse(fixture, handle, 50, 100); + expect(dragElement.style.transform).toBe('translate3d(50px, 100px, 0px)'); + })); + }); + + describe('in a drop container', () => { + it('should be able to attach data to the drop container', () => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); + + expect(fixture.componentInstance.dropInstance.data).toBe(fixture.componentInstance.items); + }); + + it('should toggle a class when the user starts dragging an item', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + const dropZone = fixture.componentInstance.dropInstance; + + expect(dropZone.element.nativeElement.classList).not.toContain('cdk-drop-dragging'); + + dispatchMouseEvent(item, 'mousedown'); + fixture.detectChanges(); + + expect(dropZone.element.nativeElement.classList).toContain('cdk-drop-dragging'); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(dropZone.element.nativeElement.classList).not.toContain('cdk-drop-dragging'); + })); + + it('should dispatch the `dropped` event when an item has been dropped', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + 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); + expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledWith(jasmine.objectContaining({ + 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 create a preview element while the item is dragged', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + const itemRect = item.getBoundingClientRect(); + const initialParent = item.parentNode; + + dispatchMouseEvent(item, 'mousedown'); + fixture.detectChanges(); + + const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement; + const previewRect = preview.getBoundingClientRect(); + + expect(item.parentNode).toBe(document.body, 'Expected element to be moved out into the body'); + expect(item.style.display).toBe('none', 'Expected element to be hidden'); + expect(preview).toBeTruthy('Expected preview to be in the DOM'); + expect(preview.textContent!.trim()) + .toContain('One', 'Expected preview content to match element'); + expect(previewRect.width).toBe(itemRect.width, 'Expected preview width to match element'); + expect(previewRect.height).toBe(itemRect.height, 'Expected preview height to match element'); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + + expect(item.parentNode) + .toBe(initialParent, 'Expected element to be moved back into its old parent'); + expect(item.style.display).toBeFalsy('Expected element to be visible'); + expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM'); + })); + + it('should create a placeholder element while the item is dragged', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + const initialParent = item.parentNode; + + dispatchMouseEvent(item, 'mousedown'); + fixture.detectChanges(); + + const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; + + expect(placeholder).toBeTruthy('Expected placeholder to be in the DOM'); + expect(placeholder.parentNode) + .toBe(initialParent, 'Expected placeholder to be inserted into the same parent'); + expect(placeholder.textContent!.trim()) + .toContain('One', 'Expected placeholder content to match element'); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + + expect(placeholder.parentNode).toBeFalsy('Expected placeholder to be removed from the DOM'); + })); + + it('should move the placeholder as an item is being sorted down', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + 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 downwards. + for (let i = 0; i < items.length; i++) { + const elementRect = items[i].element.nativeElement.getBoundingClientRect(); + + // Add a few pixels to the top offset so we get some overlap. + dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.top + 5); + fixture.detectChanges(); + expect(getElementIndex(placeholder)).toBe(i); + } + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + })); + + it('should move the placeholder as an item is being sorted up', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + 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 upwards. + for (let i = items.length - 1; i > -1; i--) { + const elementRect = items[i].element.nativeElement.getBoundingClientRect(); + + // Remove a few pixels from the bottom offset so we get some overlap. + dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.bottom - 5); + 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(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + + dispatchMouseEvent(item, 'mousedown'); + fixture.detectChanges(); + + const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement; + + expect(preview.parentNode).toBeTruthy('Expected preview to be in the DOM'); + expect(item.parentNode).toBeTruthy('Expected drag item to be in the DOM'); + + fixture.destroy(); + + expect(preview.parentNode).toBeFalsy('Expected preview to be removed from the DOM'); + expect(item.parentNode).toBeFalsy('Expected drag item to be removed from the DOM'); + })); + + it('should be able to customize the preview element', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZoneWithCustomPreview); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + + dispatchMouseEvent(item, 'mousedown'); + fixture.detectChanges(); + + const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement; + + expect(preview).toBeTruthy(); + expect(preview.classList).toContain('custom-preview'); + expect(preview.textContent!.trim()).toContain('Custom preview'); + })); + + it('should position custom previews next to the pointer', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZoneWithCustomPreview); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + + dispatchMouseEvent(item, 'mousedown', 50, 50); + fixture.detectChanges(); + + const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement; + + expect(preview.style.transform).toBe('translate3d(50px, 50px, 0px)'); + })); + + it('should be able to customize the placeholder', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZoneWithCustomPlaceholder); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + + dispatchMouseEvent(item, 'mousedown'); + fixture.detectChanges(); + + const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; + + expect(placeholder).toBeTruthy(); + expect(placeholder.classList).toContain('custom-placeholder'); + expect(placeholder.textContent!.trim()).toContain('Custom placeholder'); + })); + + }); + + describe('in a connected drop container', () => { + it('should dispatch the `dropped` event when an item has been dropped into a new container', + fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); + + // TODO + // console.log(fixture.componentInstance.groupedDragItems.map(d => d.length)); + })); + }); + +}); + +@Component({ + template: ` +
+ ` +}) +export class StandaloneDraggable { + @ViewChild('dragElement') dragElement: ElementRef; + @ViewChild(CdkDrag) dragInstance: CdkDrag; + startedSpy = jasmine.createSpy('started spy'); + endedSpy = jasmine.createSpy('ended spy'); +} + +@Component({ + template: ` +
+
+
+ ` +}) +export class StandaloneDraggableWithHandle { + @ViewChild('dragElement') dragElement: ElementRef; + @ViewChild('handleElement') handleElement: ElementRef; + @ViewChild(CdkDrag) dragInstance: CdkDrag; +} + + +@Component({ + template: ` + +
{{item}}
+
+ ` +}) +export class DraggableInDropZone { + @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: ` + +
+ {{item}} +
Custom preview
+
+
+ ` +}) +export class DraggableInDropZoneWithCustomPreview { + @ViewChildren(CdkDrag) dragItems: QueryList; + items = ['Zero', 'One', 'Two', 'Three']; +} + + +@Component({ + template: ` + +
+ {{item}} +
Custom placeholder
+
+
+ ` +}) +export class DraggableInDropZoneWithCustomPlaceholder { + @ViewChildren(CdkDrag) dragItems: QueryList; + items = ['Zero', 'One', 'Two', 'Three']; +} + + +@Component({ + template: ` + +
{{item}}
+
+ + +
{{item}}
+
+ ` +}) +export class ConnectedDropZones implements AfterViewInit { + @ViewChildren(CdkDrag) rawDragItems: QueryList; + @ViewChildren(CdkDrop) dropInstances: QueryList; + + groupedDragItems: CdkDrag[][] = []; + + todo = ['Zero', 'One', 'Two', 'Three']; + done = ['Four', 'Five', 'Six']; + + droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop) => { + if (event.previousContainer === event.container) { + moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + } else { + transferArrayItem(event.previousContainer.data, + event.container.data, + event.previousIndex, + event.currentIndex); + } + }); + + ngAfterViewInit() { + this.dropInstances.forEach((dropZone, index) => { + if (!this.groupedDragItems[index]) { + this.groupedDragItems.push([]); + } + + this.groupedDragItems[index].push(...dropZone._draggables.toArray()); + }); + } +} + + + +/** + * Drags an element to a position on the page using the mouse. + * @param fixture Fixture on which to run change detection. + * @param element Element which is being dragged. + * @param x Position along the x axis to which to drag the element. + * @param y Position along the y axis to which to drag the element. + */ +function dragElementViaMouse(fixture: ComponentFixture, + element: HTMLElement, x: number, y: number) { + + dispatchMouseEvent(element, 'mousedown'); + fixture.detectChanges(); + + dispatchMouseEvent(document, 'mousemove', x, y); + fixture.detectChanges(); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); +} + +/** + * Drags an element to a position on the page using a touch device. + * @param fixture Fixture on which to run change detection. + * @param element Element which is being dragged. + * @param x Position along the x axis to which to drag the element. + * @param y Position along the y axis to which to drag the element. + */ +function dragElementViaTouch(fixture: ComponentFixture, + element: HTMLElement, x: number, y: number) { + + dispatchTouchEvent(element, 'touchstart'); + fixture.detectChanges(); + + dispatchTouchEvent(document, 'touchmove', x, y); + fixture.detectChanges(); + + dispatchTouchEvent(document, 'touchend'); + fixture.detectChanges(); +} + +/** Gets the index of a DOM element inside its parent. */ +function getElementIndex(element: HTMLElement) { + return element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : -1; +} diff --git a/src/cdk-experimental/drag-drop/drag.ts b/src/cdk-experimental/drag-drop/drag.ts new file mode 100644 index 000000000000..e1a2349c9289 --- /dev/null +++ b/src/cdk-experimental/drag-drop/drag.ts @@ -0,0 +1,524 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Directive, + ContentChild, + Inject, + Optional, + ElementRef, + AfterContentInit, + NgZone, + SkipSelf, + OnDestroy, + Output, + EventEmitter, + ViewContainerRef, + EmbeddedViewRef, +} from '@angular/core'; +import {CdkDragHandle} from './drag-handle'; +import {DOCUMENT} from '@angular/platform-browser'; +import {CdkDropContainer, CDK_DROP_CONTAINER} from './drop-container'; +import {supportsPassiveEventListeners} from '@angular/cdk/platform'; +import {CdkDragStart, CdkDragEnd, CdkDragExit, CdkDragEnter, CdkDragDrop} from './drag-events'; +import {CdkDragPreview} from './drag-preview'; +import {CdkDragPlaceholder} from './drag-placeholder'; + +/** Event options that can be used to bind an active event. */ +const activeEventOptions = supportsPassiveEventListeners() ? {passive: false} : false; + +// TODO: add an API for moving a draggable up/down the +// list programmatically. Useful for keyboard controls. + +/** Element that can be moved inside a CdkDrop container. */ +@Directive({ + selector: '[cdkDrag]', + exportAs: 'cdkDrag', + host: { + 'class': 'cdk-drag', + } +}) +export class CdkDrag implements AfterContentInit, OnDestroy { + private _document: Document; + + /** Element displayed next to the user's pointer while the element is dragged. */ + private _preview: HTMLElement; + + /** Reference to the view of the preview element. */ + private _previewRef: EmbeddedViewRef | null; + + /** Reference to the view of the placeholder element. */ + private _placeholderRef: EmbeddedViewRef | null; + + /** Element that is rendered instead of the draggable item while it is being sorted. */ + private _placeholder: HTMLElement; + + /** Coordinates within the element at which the user picked up the element. */ + private _pickupPositionInElement: Point; + + /** Coordinates on the page at which the user picked up the element. */ + private _pickupPositionOnPage: Point; + + /** + * CSS `transform` applied to the element when it isn't being dragged. We need a + * passive transform in order for the dragged element to retain its new position + * after the user has stopped dragging and because we need to know the relative + * position in case they start dragging again. This corresponds to `element.style.transform`. + */ + private _passiveTransform: Point = {x: 0, y: 0}; + + /** CSS `transform` that is applied to the element while it's being dragged. */ + private _activeTransform: Point = {x: 0, y: 0}; + + /** Whether the element is being dragged. */ + private _isDragging = false; + + /** Whether the element has moved since the user started dragging it. */ + private _hasMoved = false; + + /** Drop container in which the CdkDrag resided when dragging began. */ + private _initialContainer: CdkDropContainer; + + /** Element that can be used to drag the draggable item. */ + @ContentChild(CdkDragHandle) _handle: CdkDragHandle; + + /** Element that will be used as a template to create the draggable item's preview. */ + @ContentChild(CdkDragPreview) _previewTemplate: CdkDragPreview; + + /** + * Template for placeholder element rendered to show where a draggable would be dropped. + */ + @ContentChild(CdkDragPlaceholder) _placeholderTemplate: CdkDragPlaceholder; + + /** Emits when the user starts dragging the item. */ + @Output('cdkDragStarted') started = new EventEmitter(); + + /** Emits when the user stops dragging an item in the container. */ + @Output('cdkDragEnded') ended = new EventEmitter(); + + /** Emits when the user has moved the item into a new container. */ + @Output('cdkDragEntered') entered = new EventEmitter>(); + + /** Emits when the user removes the item its container by dragging it into another container. */ + @Output('cdkDragExited') exited = new EventEmitter>(); + + /** Emits when the user drops the item inside a container. */ + @Output('cdkDragDropped') dropped = new EventEmitter>(); + + constructor( + /** Element that the draggable is attached to. */ + public element: ElementRef, + /** Droppable container that the draggable is a part of. */ + @Inject(CDK_DROP_CONTAINER) @Optional() @SkipSelf() public dropContainer: CdkDropContainer, + @Inject(DOCUMENT) document: any, + private _ngZone: NgZone, + private _viewContainerRef: ViewContainerRef) { + this._document = document; + } + + /** + * Returns the element that is being used as a placeholder + * while the current element is being dragged. + */ + getPlaceholderElement(): HTMLElement { + return this._placeholder; + } + + ngAfterContentInit() { + // TODO: doesn't handle (pun intended) the handle being destroyed + const dragElement = (this._handle ? this._handle.element : this.element).nativeElement; + dragElement.addEventListener('mousedown', this._pointerDown); + dragElement.addEventListener('touchstart', this._pointerDown); + + // Webkit won't preventDefault on a dynamically-added `touchmove` listener, which means that + // we need to add one ahead of time. See https://bugs.webkit.org/show_bug.cgi?id=184250. + // TODO: move into a central registry. + this._ngZone.runOutsideAngular(() => { + this._document.addEventListener('touchmove', this._preventScrollListener, activeEventOptions); + }); + } + + ngOnDestroy() { + this._removeDocumentEvents(); + this._destroyPreview(); + this._destroyPlaceholder(); + this._document.removeEventListener('touchmove', this._preventScrollListener, + activeEventOptions as any); + + if (this._isDragging) { + // Since we move out the element to the end of the body while it's being + // dragged, we have to make sure that it's removed if it gets destroyed. + this._removeElement(this.element.nativeElement); + } + } + + /** Handler for when the pointer is pressed down on the element or the handle. */ + private _pointerDown = (event: MouseEvent | TouchEvent) => { + if (this._isDragging) { + return; + } + + this._isDragging = true; + this._initialContainer = this.dropContainer; + + // If we have a custom preview template, the element won't be visible anyway so we avoid the + // extra `getBoundingClientRect` calls and just move the preview next to the cursor. + this._pickupPositionInElement = this._previewTemplate ? {x: 0, y: 0} : + this._getPointerPositionInElement(event); + this._pickupPositionOnPage = this._getPointerPositionOnPage(event); + this._registerMoveListeners(event); + + // Emit the event on the item before the one on the container. + this.started.emit({source: this}); + + if (this.dropContainer) { + const element = this.element.nativeElement; + const preview = this._preview = this._createPreviewElement(); + const placeholder = this._placeholder = this._createPlaceholderElement(); + + // We move the element out at the end of the body and we make it hidden, because keeping it in + // place will throw off the consumer's `:last-child` selectors. We can't remove the element + // from the DOM completely, because iOS will stop firing all subsequent events in the chain. + element.style.display = 'none'; + this._document.body.appendChild(element.parentNode!.replaceChild(placeholder, element)); + this._document.body.appendChild(preview); + this.dropContainer.start(); + } + } + + /** Handler that is invoked when the user moves their pointer after they've initiated a drag. */ + private _pointerMove = (event: MouseEvent | TouchEvent) => { + if (!this._isDragging) { + return; + } + + this._hasMoved = true; + event.preventDefault(); + + if (this.dropContainer) { + this._updateActiveDropContainer(event); + } else { + const activeTransform = this._activeTransform; + const {x: pageX, y: pageY} = this._getPointerPositionOnPage(event); + activeTransform.x = pageX - this._pickupPositionOnPage.x + this._passiveTransform.x; + activeTransform.y = pageY - this._pickupPositionOnPage.y + this._passiveTransform.y; + this._setTransform(this.element.nativeElement, activeTransform.x, activeTransform.y); + } + } + + /** Handler that is invoked when the user lifts their pointer up, after initiating a drag. */ + private _pointerUp = () => { + if (!this._isDragging) { + return; + } + + this._removeDocumentEvents(); + this._isDragging = false; + + if (!this.dropContainer) { + // Convert the active transform into a passive one. This means that next time + // the user starts dragging the item, its position will be calculated relatively + // to the new passive transform. + this._passiveTransform.x = this._activeTransform.x; + this._passiveTransform.y = this._activeTransform.y; + this._ngZone.run(() => this.ended.emit({source: this})); + return; + } + + this._animatePreviewToPlaceholder().then(() => this._cleanupDragArtifacts()); + } + + /** Cleans up the DOM artifacts that were added to facilitate the element being dragged. */ + private _cleanupDragArtifacts() { + this._destroyPreview(); + this._placeholder.parentNode!.insertBefore(this.element.nativeElement, this._placeholder); + this._destroyPlaceholder(); + this.element.nativeElement.style.display = ''; + + // Re-enter the NgZone since we bound `document` events on the outside. + this._ngZone.run(() => { + const currentIndex = this._getElementIndexInDom(); + + this.ended.emit({source: this}); + this.dropped.emit({ + item: this, + currentIndex, + previousIndex: this._initialContainer.getItemIndex(this), + container: this.dropContainer, + previousContainer: this._initialContainer + }); + this.dropContainer.drop(this, currentIndex, this._initialContainer); + }); + } + + /** + * Updates the item's position in its drop container, or moves it + * into a new one, depending on its current drag position. + */ + private _updateActiveDropContainer(event: MouseEvent | TouchEvent) { + const {x, y} = this._getPointerPositionOnPage(event); + + // Drop container that draggable has been moved into. + const newContainer = this.dropContainer._getSiblingContainerFromPosition(x, y); + + if (newContainer) { + this._ngZone.run(() => { + // Notify the old container that the item has left. + this.exited.emit({ item: this, container: this.dropContainer }); + this.dropContainer.exit(this); + // Notify the new container that the item has entered. + this.entered.emit({ item: this, container: newContainer }); + this.dropContainer = newContainer; + this.dropContainer.enter(this); + }); + } + + this.dropContainer._sortItem(this, y); + this._setTransform(this._preview, + x - this._pickupPositionInElement.x, + y - this._pickupPositionInElement.y); + } + + /** + * Creates the element that will be rendered next to the user's pointer + * and will be used as a preview of the element that is being dragged. + */ + private _createPreviewElement(): HTMLElement { + let preview: HTMLElement; + + if (this._previewTemplate) { + const viewRef = this._viewContainerRef.createEmbeddedView(this._previewTemplate.templateRef, + this._previewTemplate.data); + + preview = viewRef.rootNodes[0]; + this._previewRef = viewRef; + this._setTransform(preview, this._pickupPositionOnPage.x, this._pickupPositionOnPage.y); + } else { + const element = this.element.nativeElement; + const elementRect = element.getBoundingClientRect(); + + preview = element.cloneNode(true) as HTMLElement; + preview.style.width = `${elementRect.width}px`; + preview.style.height = `${elementRect.height}px`; + this._setTransform(preview, elementRect.left, elementRect.top); + } + + preview.classList.add('cdk-drag-preview'); + return preview; + } + + /** Creates an element that will be shown instead of the current element while dragging. */ + private _createPlaceholderElement(): HTMLElement { + let placeholder: HTMLElement; + + if (this._placeholderTemplate) { + this._placeholderRef = this._viewContainerRef.createEmbeddedView( + this._placeholderTemplate.templateRef, + this._placeholderTemplate.data + ); + placeholder = this._placeholderRef.rootNodes[0]; + } else { + placeholder = this.element.nativeElement.cloneNode(true) as HTMLElement; + } + + placeholder.classList.add('cdk-drag-placeholder'); + return placeholder; + } + + /** Gets the index of the dragable element, based on its index in the DOM. */ + private _getElementIndexInDom(): number { + // Note: we may be able to figure this in memory while sorting, but doing so won't be very + // reliable when transferring between containers, because the new container doesn't have + // the proper indices yet. Also this will work better for the case where the consumer + // isn't using an `ngFor` to render the list. + const element = this.element.nativeElement; + + if (!element.parentElement) { + return -1; + } + + // Avoid accessing `children` and `children.length` too much since they're a "live collection". + let index = 0; + const siblings = element.parentElement.children; + const siblingsLength = siblings.length; + const draggableElements = this.dropContainer._draggables + .filter(item => item !== this) + .map(item => item.element.nativeElement); + + // Loop through the sibling elements to find out the index of the + // current one, while skipping any elements that aren't draggable. + for (let i = 0; i < siblingsLength; i++) { + if (siblings[i] === element) { + return index; + } else if (draggableElements.indexOf(siblings[i] as HTMLElement) > -1) { + index++; + } + } + + return -1; + } + + /** + * Figures out the coordinates at which an element was picked up. + * @param event Event that initiated the dragging. + */ + private _getPointerPositionInElement(event: MouseEvent | TouchEvent): Point { + const elementRect = this.element.nativeElement.getBoundingClientRect(); + const handleElement = this._handle ? this._handle.element.nativeElement : null; + const referenceRect = handleElement ? handleElement.getBoundingClientRect() : elementRect; + const x = this._isTouchEvent(event) ? event.targetTouches[0].pageX - referenceRect.left : + event.offsetX; + const y = this._isTouchEvent(event) ? event.targetTouches[0].pageY - referenceRect.top : + event.offsetY; + + return { + x: referenceRect.left - elementRect.left + x, + y: referenceRect.top - elementRect.top + y + }; + } + + /** + * Animates the preview element from its current position to the location of the drop placeholder. + * @returns Promise that resolves when the animation completes. + */ + private _animatePreviewToPlaceholder(): Promise { + // If the user hasn't moved yet, the transitionend event won't fire. + if (!this._hasMoved) { + return Promise.resolve(); + } + + const placeholderRect = this._placeholder.getBoundingClientRect(); + + // Apply the class that adds a transition to the preview. + this._preview.classList.add('cdk-drag-animating'); + + // Move the preview to the placeholder position. + this._setTransform(this._preview, placeholderRect.left, placeholderRect.top); + + // If the element doesn't have a `transition`, the `transitionend` event won't fire. Since + // we need to trigger a style recalculation in order for the `cdk-drag-animating` class to + // apply its style, we take advantage of the available info to figure out whether we need to + // bind the event in the first place. + const duration = getComputedStyle(this._preview).getPropertyValue('transition-duration'); + + if (parseFloat(duration) === 0) { + return Promise.resolve(); + } + + return this._ngZone.runOutsideAngular(() => { + return new Promise(resolve => { + const handler = (event: Event) => { + if (event.target === this._preview) { + this._preview.removeEventListener('transitionend', handler); + resolve(); + } + }; + + this._preview.addEventListener('transitionend', handler); + }); + }); + } + + /** + * Sets the `transform` style on an element. + * @param element Element on which to set the transform. + * @param x Desired position of the element along the X axis. + * @param y Desired position of the element along the Y axis. + */ + private _setTransform(element: HTMLElement, x: number, y: number) { + element.style.transform = `translate3d(${x}px, ${y}px, 0)`; + } + + /** + * Helper to remove an element from the DOM and to do all the necessary null checks. + * @param element Element to be removed. + */ + private _removeElement(element: HTMLElement | null) { + if (element && element.parentNode) { + element.parentNode.removeChild(element); + } + } + + /** Removes the global event listeners that were bound by this draggable. */ + private _removeDocumentEvents() { + this._document.removeEventListener('mousemove', this._pointerMove, + activeEventOptions as any); + this._document.removeEventListener('touchmove', this._pointerMove, + activeEventOptions as any); + this._document.removeEventListener('mouseup', this._pointerUp); + this._document.removeEventListener('touchend', this._pointerUp); + } + + /** Determines the point of the page that was touched by the user. */ + private _getPointerPositionOnPage(event: MouseEvent | TouchEvent): Point { + return this._isTouchEvent(event) ? {x: event.touches[0].pageX, y: event.touches[0].pageY} : + {x: event.pageX, y: event.pageY}; + } + + /** Listener used to prevent `touchmove` events while the element is being dragged. */ + private _preventScrollListener = (event: TouchEvent) => { + if (this._isDragging) { + event.preventDefault(); + } + } + + /** Determines whether an event is a touch event. */ + private _isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { + return event.type.startsWith('touch'); + } + + /** Destroys the preview element and its ViewRef. */ + private _destroyPreview() { + if (this._preview) { + this._removeElement(this._preview); + } + + if (this._previewRef) { + this._previewRef.destroy(); + } + + this._preview = this._previewRef = null!; + } + + /** Destroys the placeholder element and its ViewRef. */ + private _destroyPlaceholder() { + if (this._placeholder) { + this._removeElement(this._placeholder); + } + + if (this._placeholderRef) { + this._placeholderRef.destroy(); + } + + this._placeholder = this._placeholderRef = null!; + } + + /** + * Registers global event listeners that are used for moving the element. + * @param event Event that initiated the dragging. + */ + private _registerMoveListeners(event: MouseEvent | TouchEvent) { + this._ngZone.runOutsideAngular(() => { + const isTouchEvent = this._isTouchEvent(event); + + // We explicitly bind __active__ listeners here, because newer browsers + // will default to passive ones for `mousemove` and `touchmove`. + // TODO: this should be bound in `mousemove` and after a certain threshold, + // otherwise it'll interfere with clicks on the element. + this._document.addEventListener(isTouchEvent ? 'touchmove' : 'mousemove', this._pointerMove, + activeEventOptions); + this._document.addEventListener(isTouchEvent ? 'touchend' : 'mouseup', this._pointerUp); + }); + } +} + +/** Point on the page or within an element. */ +interface Point { + x: number; + y: number; +} diff --git a/src/cdk-experimental/drag-drop/drop-container.ts b/src/cdk-experimental/drag-drop/drop-container.ts new file mode 100644 index 000000000000..39bf9f8ca9e7 --- /dev/null +++ b/src/cdk-experimental/drag-drop/drop-container.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {InjectionToken, QueryList} from '@angular/core'; +import {CdkDrag} from './drag'; + +/** @docs-private */ +export interface CdkDropContainer { + /** Arbitrary data to attach to all events emitted by this container. */ + data: T; + + /** Starts dragging an item. */ + start(): void; + + /** + * Drops an item into this container. + * @param item Item being dropped into the container. + * @param currentIndex Index at which the item should be inserted. + * @param previousContainer Container from which the item got dragged in. + */ + drop(item: CdkDrag, currentIndex: number, previousContainer?: CdkDropContainer): void; + + /** + * Emits an event to indicate that the user moved an item into the container. + * @param item Item that was moved into the container. + */ + enter(item: CdkDrag): void; + + /** + * Removes an item from the container after it was dragged into another container by the user. + * @param item Item that was dragged out. + */ + exit(item: CdkDrag): void; + + /** + * Figures out the index of an item in the container. + * @param item Item whose index should be determined. + */ + getItemIndex(item: CdkDrag): number; + _sortItem(item: CdkDrag, yOffset: number): void; + _draggables: QueryList; + _getSiblingContainerFromPosition(x: number, y: number): CdkDropContainer | null; +} + +/** + * Injection token that is used to provide a CdkDrop instance to CdkDrag. + * Used for avoiding circular imports. + * @docs-private + */ +export const CDK_DROP_CONTAINER = new InjectionToken('CDK_DROP_CONTAINER'); diff --git a/src/cdk-experimental/drag-drop/drop.scss b/src/cdk-experimental/drag-drop/drop.scss new file mode 100644 index 000000000000..e8baddc2df0b --- /dev/null +++ b/src/cdk-experimental/drag-drop/drop.scss @@ -0,0 +1,8 @@ +$cdk-z-index-drag-preview: 1000; + +.cdk-drag-preview { + position: absolute; + top: 0; + left: 0; + z-index: $cdk-z-index-drag-preview; +} diff --git a/src/cdk-experimental/drag-drop/drop.ts b/src/cdk-experimental/drag-drop/drop.ts new file mode 100644 index 000000000000..b91606f498ca --- /dev/null +++ b/src/cdk-experimental/drag-drop/drop.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Component, + ContentChildren, + forwardRef, + Input, + ViewEncapsulation, + ChangeDetectionStrategy, + Output, + EventEmitter, + ElementRef, + QueryList, +} from '@angular/core'; +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, + selector: 'cdk-drop', + exportAs: 'cdkDrop', + template: '', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['drop.css'], + providers: [ + {provide: CDK_DROP_CONTAINER, useExisting: CdkDrop}, + ], + host: { + 'class': 'cdk-drop', + '[class.cdk-drop-dragging]': '_dragging' + } +}) +export class CdkDrop { + /** Draggable items in the container. */ + @ContentChildren(forwardRef(() => CdkDrag)) _draggables: QueryList; + + /** + * Other draggable containers that this container is connected + * to and into which the container's items can be transferred. + */ + @Input() connectedTo: CdkDrop[] = []; + + /** Arbitrary data to attach to all events emitted by this container. */ + @Input() data: T; + + /** Emits when the user drops an item inside the container. */ + @Output() dropped = new EventEmitter>(); + + /** + * Emits when the user has moved a new drag item into this container. + */ + @Output() entered = new EventEmitter>(); + + /** + * Emits when the user removes an item from the container + * by dragging it into another container. + */ + @Output() exited = new EventEmitter>(); + + constructor(public element: ElementRef) {} + + /** Whether an item in the container is being dragged. */ + _dragging = false; + + /** Cache of the dimensions of all the items and the sibling containers. */ + private _positionCache = { + items: [] as {drag: CdkDrag, clientRect: ClientRect}[], + siblings: [] as {drop: CdkDrop, clientRect: ClientRect}[] + }; + + /** Starts dragging an item. */ + start(): void { + this._dragging = true; + this._refreshPositions(); + } + + /** + * Drops an item into this container. + * @param item Item being dropped into the container. + * @param currentIndex Index at which the item should be inserted. + * @param previousContainer Container from which the item got dragged in. + */ + drop(item: CdkDrag, currentIndex: number, previousContainer: CdkDrop): void { + this.dropped.emit({ + item, + currentIndex, + previousIndex: previousContainer.getItemIndex(item), + container: this, + // TODO: reconsider whether to make this null if the containers are the same. + previousContainer + }); + + this._reset(); + } + + /** + * Emits an event to indicate that the user moved an item into the container. + * @param item Item that was moved into the container. + */ + enter(item: CdkDrag): void { + this.entered.emit({item, container: this}); + this.start(); + } + + /** + * Removes an item from the container after it was dragged into another container by the user. + * @param item Item that was dragged out. + */ + exit(item: CdkDrag): void { + this._reset(); + this.exited.emit({item, container: this}); + } + + /** + * Figures out the index of an item in the container. + * @param item Item whose index should be determined. + */ + getItemIndex(item: CdkDrag): number { + return this._draggables.toArray().indexOf(item); + } + + /** + * Sorts an item inside the container based on its position. + * @param item Item to be sorted. + * @param yOffset Position of the item along the Y axis. + */ + _sortItem(item: CdkDrag, yOffset: number): void { + // TODO: only covers Y axis sorting. + const siblings = this._positionCache.items; + const newPosition = siblings.find(({drag, clientRect}) => { + return drag !== item && yOffset > clientRect.top && yOffset < clientRect.bottom; + }); + + if (!newPosition && siblings.length > 0) { + return; + } + + const element = newPosition ? newPosition.drag.element.nativeElement : null; + const next = element ? element!.nextSibling : null; + const parent = element ? element.parentElement! : this.element.nativeElement; + const placeholder = item.getPlaceholderElement(); + + if (next) { + parent.insertBefore(placeholder, next === placeholder ? element : next); + } else { + parent.appendChild(placeholder); + } + + this._refreshPositions(); + } + + /** + * Figures out whether an item should be moved into a sibling + * drop container, based on its current position. + * @param x Position of the item along the X axis. + * @param y Position of the item along the Y axis. + */ + _getSiblingContainerFromPosition(x: number, y: number): CdkDrop | null { + const result = this._positionCache.siblings.find(({clientRect}) => { + const {top, bottom, left, right} = clientRect; + return y >= top && y <= bottom && x >= left && x <= right; + }); + + return result ? result.drop : null; + } + + /** Refreshes the position cache of the items and sibling containers. */ + private _refreshPositions() { + this._positionCache.items = this._draggables + .map(drag => ({drag, clientRect: drag.element.nativeElement.getBoundingClientRect()})) + .sort((a, b) => a.clientRect.top - b.clientRect.top); + + // TODO: add filter here that ensures that the current container isn't being passed to itself. + this._positionCache.siblings = this.connectedTo + .map(drop => ({drop, clientRect: drop.element.nativeElement.getBoundingClientRect()})); + } + + /** Resets the container to its initial state. */ + private _reset() { + this._dragging = false; + this._positionCache.items = []; + this._positionCache.siblings = []; + } +} diff --git a/src/cdk-experimental/drag-drop/index.ts b/src/cdk-experimental/drag-drop/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/cdk-experimental/drag-drop/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/drag-drop/public-api.ts b/src/cdk-experimental/drag-drop/public-api.ts new file mode 100644 index 000000000000..d5389296c30a --- /dev/null +++ b/src/cdk-experimental/drag-drop/public-api.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './drop'; +export * from './drag'; +export * from './drag-handle'; +export * from './drag-events'; +export * from './drag-utils'; +export * from './drag-preview'; +export * from './drag-placeholder'; +export * from './drag-drop-module'; diff --git a/src/cdk-experimental/drag-drop/tsconfig-build.json b/src/cdk-experimental/drag-drop/tsconfig-build.json new file mode 100644 index 000000000000..ef320fb92218 --- /dev/null +++ b/src/cdk-experimental/drag-drop/tsconfig-build.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public-api.ts", + "./typings.d.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/cdk-experimental/drag-drop", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} diff --git a/src/cdk-experimental/drag-drop/typings.d.ts b/src/cdk-experimental/drag-drop/typings.d.ts new file mode 100644 index 000000000000..ce4ae9b66cf0 --- /dev/null +++ b/src/cdk-experimental/drag-drop/typings.d.ts @@ -0,0 +1 @@ +declare var module: {id: string}; diff --git a/src/cdk/testing/event-objects.ts b/src/cdk/testing/event-objects.ts index b205a3b198ad..7d155ff490a3 100644 --- a/src/cdk/testing/event-objects.ts +++ b/src/cdk/testing/event-objects.ts @@ -41,7 +41,8 @@ export function createTouchEvent(type: string, pageX = 0, pageY = 0) { // Most of the browsers don't have a "initTouchEvent" method that can be used to define // the touch details. Object.defineProperties(event, { - touches: {value: [touchDetails]} + touches: {value: [touchDetails]}, + targetTouches: {value: [touchDetails]} }); return event; diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 5c873d400dfd..72134f7e9344 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -62,6 +62,7 @@ export class DemoApp { {name: 'Datepicker', route: '/datepicker'}, {name: 'Dialog', route: '/dialog'}, {name: 'Drawer', route: '/drawer'}, + {name: 'Drag and Drop', route: '/drag-drop'}, {name: 'Expansion Panel', route: '/expansion'}, {name: 'Focus Origin', route: '/focus-origin'}, {name: 'Gestures', route: '/gestures'}, diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index 7fac4e483593..ba633e837b0d 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -63,6 +63,7 @@ import {TypographyDemo} from '../typography/typography-demo'; import {VirtualScrollDemo} from '../virtual-scroll/virtual-scroll-demo'; import {DemoApp, Home} from './demo-app'; import {DEMO_APP_ROUTES} from './routes'; +import {DragAndDropDemo} from '../drag-drop/drag-drop-demo'; @NgModule({ imports: [ @@ -96,6 +97,7 @@ import {DEMO_APP_ROUTES} from './routes'; DialogDemo, DrawerDemo, ExampleBottomSheet, + DragAndDropDemo, ExpansionDemo, FocusOriginDemo, GesturesDemo, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index b3b4e33b5217..e212766267fa 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -55,6 +55,7 @@ import {PaginatorDemo} from '../paginator/paginator-demo'; import {ExamplesPage} from '../examples-page/examples-page'; import {TableDemo} from '../table/table-demo'; +import {DragAndDropDemo} from '../drag-drop/drag-drop-demo'; export const DEMO_APP_ROUTES: Routes = [ {path: '', component: DemoApp, children: [ @@ -71,6 +72,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'datepicker', component: DatepickerDemo}, {path: 'dialog', component: DialogDemo}, {path: 'drawer', component: DrawerDemo}, + {path: 'drag-drop', component: DragAndDropDemo}, {path: 'expansion', component: ExpansionDemo}, {path: 'focus-origin', component: FocusOriginDemo}, {path: 'gestures', component: GesturesDemo}, diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts index eeb003dc9380..2691a04a68f4 100644 --- a/src/demo-app/demo-material-module.ts +++ b/src/demo-app/demo-material-module.ts @@ -8,6 +8,7 @@ import {ScrollingModule} from '@angular/cdk-experimental/scrolling'; import {DialogModule} from '@angular/cdk-experimental/dialog'; +import {DragDropModule} from '@angular/cdk-experimental/drag-drop'; import {A11yModule} from '@angular/cdk/a11y'; import {CdkAccordionModule} from '@angular/cdk/accordion'; import {BidiModule} from '@angular/cdk/bidi'; @@ -112,6 +113,7 @@ import { PortalModule, ScrollingModule, DialogModule, + DragDropModule, ] }) export class DemoMaterialModule {} diff --git a/src/demo-app/drag-drop/drag-drop-demo.html b/src/demo-app/drag-drop/drag-drop-demo.html new file mode 100644 index 000000000000..1cf13b842bfe --- /dev/null +++ b/src/demo-app/drag-drop/drag-drop-demo.html @@ -0,0 +1,38 @@ +
+

To do

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

Done

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

Data

+
{{todo.join(', ')}}
+
{{done.join(', ')}}
+
+ +
+

Free dragging

+
Drag me around
+
diff --git a/src/demo-app/drag-drop/drag-drop-demo.scss b/src/demo-app/drag-drop/drag-drop-demo.scss new file mode 100644 index 000000000000..f9aa313d0769 --- /dev/null +++ b/src/demo-app/drag-drop/drag-drop-demo.scss @@ -0,0 +1,71 @@ +.list { + width: 500px; + max-width: 100%; + margin-bottom: 25px; + display: inline-block; + margin-right: 25px; + vertical-align: top; +} + +.cdk-drop { + border: solid 1px #ccc; + min-height: 60px; + display: block; +} + +.cdk-drag { + padding: 20px 10px; + border-bottom: solid 1px #ccc; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + + .cdk-drop &:last-child { + border: none; + } + + .cdk-drop-dragging & { + transition: transform 500ms ease; + } +} + +.cdk-drag-preview { + box-sizing: border-box; + opacity: 0.5; +} + +.cdk-drag-animating { + transition: transform 500ms ease; +} + +.cdk-drag-placeholder { + opacity: 0.5; +} + +.wrapper { + border: solid 1px red; +} + +.cdk-drag-handle { + cursor: move; + + svg { + fill: rgba(0, 0, 0, 0.5); + } +} + +pre { + white-space: normal; +} + +.free-draggable { + width: 200px; + height: 200px; + border: solid 1px #ccc; + cursor: move; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/demo-app/drag-drop/drag-drop-demo.ts b/src/demo-app/drag-drop/drag-drop-demo.ts new file mode 100644 index 000000000000..bc2e16543fe3 --- /dev/null +++ b/src/demo-app/drag-drop/drag-drop-demo.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ViewEncapsulation} from '@angular/core'; +import {MatIconRegistry} from '@angular/material/icon'; +import {DomSanitizer} from '@angular/platform-browser'; +import {CdkDragDrop, moveItemInArray, transferArrayItem} from '@angular/cdk-experimental/drag-drop'; + +@Component({ + moduleId: module.id, + selector: 'drag-drop-demo', + templateUrl: 'drag-drop-demo.html', + styleUrls: ['drag-drop-demo.css'], + encapsulation: ViewEncapsulation.None, +}) +export class DragAndDropDemo { + todo = [ + 'Come up with catchy start-up name', + 'Add "blockchain" to name', + 'Sell out', + 'Profit', + 'Go to sleep' + ]; + done = [ + 'Get up', + 'Have breakfast', + 'Brush teeth', + 'Check reddit' + ]; + + constructor(iconRegistry: MatIconRegistry, sanitizer: DomSanitizer) { + iconRegistry.addSvgIconLiteral('dnd-move', sanitizer.bypassSecurityTrustHtml(` + + + + + `)); + } + + drop(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + } else { + transferArrayItem(event.previousContainer.data, + event.container.data, + event.previousIndex, + event.currentIndex); + } + } +} diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts index 38094f42ddd9..cf93228015cb 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -64,6 +64,8 @@ System.config({ '@angular/cdk-experimental/scrolling': 'dist/packages/cdk-experimental/scrolling/index.js', '@angular/cdk-experimental/dialog': 'dist/packages/cdk-experimental/dialog/index.js', + '@angular/cdk-experimental/drag-drop': + 'dist/packages/cdk-experimental/drag-drop/index.js', '@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js', '@angular/material/bottom-sheet': 'dist/packages/material/bottom-sheet/index.js', diff --git a/src/e2e-app/e2e-app-module.ts b/src/e2e-app/e2e-app-module.ts index 534041a5d56d..2d9c4f329b36 100644 --- a/src/e2e-app/e2e-app-module.ts +++ b/src/e2e-app/e2e-app-module.ts @@ -1,5 +1,6 @@ import {ScrollingModule} from '@angular/cdk-experimental/scrolling'; import {DialogModule} from '@angular/cdk-experimental/dialog'; +import {DragDropModule} from '@angular/cdk-experimental/drag-drop'; import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {NgModule} from '@angular/core'; import {ReactiveFormsModule} from '@angular/forms'; @@ -71,6 +72,7 @@ import {VirtualScrollE2E} from './virtual-scroll/virtual-scroll-e2e'; MatNativeDateModule, ScrollingModule, DialogModule, + DragDropModule, ] }) export class E2eMaterialModule {} diff --git a/src/e2e-app/system-config.ts b/src/e2e-app/system-config.ts index b5fc698f6f2e..a3b88b569af1 100644 --- a/src/e2e-app/system-config.ts +++ b/src/e2e-app/system-config.ts @@ -54,6 +54,7 @@ System.config({ '@angular/cdk-experimental/scrolling': 'dist/bundles/cdk-experimental-scrolling.umd.js', '@angular/cdk-experimental/dialog': 'dist/bundles/cdk-experimental-dialog.umd.js', + '@angular/cdk-experimental/drag-drop': 'dist/bundles/cdk-experimental-drag-drop.umd.js', '@angular/material/autocomplete': 'dist/bundles/material-autocomplete.umd.js', '@angular/material/bottom-sheet': 'dist/bundles/material-bottom-sheet.umd.js', diff --git a/test/karma-test-shim.js b/test/karma-test-shim.js index 7f6eb94644c9..0f8b8797fde3 100644 --- a/test/karma-test-shim.js +++ b/test/karma-test-shim.js @@ -74,6 +74,8 @@ System.config({ '@angular/cdk-experimental/scrolling': 'dist/packages/cdk-experimental/scrolling/index.js', '@angular/cdk-experimental/dialog': 'dist/packages/cdk-experimental/dialog/index.js', + '@angular/cdk-experimental/drag-drop': + 'dist/packages/cdk-experimental/drag-drop/index.js', '@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js', '@angular/material/badge': 'dist/packages/material/badge/index.js',