diff --git a/src/cdk/drag-drop/directives/config.ts b/src/cdk/drag-drop/directives/config.ts index 06a05c9c72cf..3734137ef226 100644 --- a/src/cdk/drag-drop/directives/config.ts +++ b/src/cdk/drag-drop/directives/config.ts @@ -19,7 +19,7 @@ export type DragAxis = 'x' | 'y'; export type DragConstrainPosition = (point: Point, dragRef: DragRef) => Point; /** Possible orientations for a drop list. */ -export type DropListOrientation = 'horizontal' | 'vertical'; +export type DropListOrientation = 'horizontal' | 'vertical' | 'mixed'; /** * Injection token that can be used to configure the diff --git a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts index 10d1808caaa4..ad3f25577d00 100644 --- a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts +++ b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts @@ -1,5 +1,5 @@ import {Directionality} from '@angular/cdk/bidi'; -import {_supportsShadowDom} from '@angular/cdk/platform'; +import {Platform, _supportsShadowDom} from '@angular/cdk/platform'; import {CdkScrollable, ViewportRuler} from '@angular/cdk/scrolling'; import { createMouseEvent, @@ -54,6 +54,9 @@ import {CdkDragPlaceholder} from './drag-placeholder'; export const ITEM_HEIGHT = 25; export const ITEM_WIDTH = 75; +/** Function that can be used to get the sorted siblings of an element. */ +type SortedSiblingsFunction = (element: Element, direction: 'top' | 'left') => Element[]; + export function defineCommonDropListTests(config: { /** Orientation value that will be passed to tests checking vertical orientation. */ verticalListOrientation: Exclude; @@ -61,17 +64,8 @@ export function defineCommonDropListTests(config: { /** Orientation value that will be passed to tests checking horizontal orientation. */ horizontalListOrientation: Exclude; - /** Asserts that sorting an element up works correctly. */ - assertUpwardSorting: (fixture: ComponentFixture, items: Element[]) => void; - - /** Asserts that sorting an element down works correctly. */ - assertDownwardSorting: (fixture: ComponentFixture, items: Element[]) => void; - - /** Gets the index of an element among its siblings, based on their visible position. */ - getElementIndexByPosition: (element: Element, direction: 'top' | 'left') => number; - /** Gets the siblings of an element, sorted by their visible position. */ - getElementSibligsByPosition: (element: Element, direction: 'top' | 'left') => Element[]; + getSortedSiblings: SortedSiblingsFunction; }) { const { DraggableInHorizontalDropZone, @@ -405,31 +399,27 @@ export function defineCommonDropListTests(config: { flush(); })); - it( - 'should not dispatch the `sorted` event when an item is dragged inside ' + - 'a single-item list', - fakeAsync(() => { - const fixture = createComponent(DraggableInDropZone); - fixture.componentInstance.items = [fixture.componentInstance.items[0]]; - fixture.detectChanges(); + it('should not dispatch the `sorted` event when an item is dragged inside a single-item list', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.componentInstance.items = [fixture.componentInstance.items[0]]; + fixture.detectChanges(); - const draggedItem = fixture.componentInstance.dragItems.first.element.nativeElement; - const {top, left} = draggedItem.getBoundingClientRect(); + const draggedItem = fixture.componentInstance.dragItems.first.element.nativeElement; + const {top, left} = draggedItem.getBoundingClientRect(); - startDraggingViaMouse(fixture, draggedItem, left, top); + startDraggingViaMouse(fixture, draggedItem, left, top); - for (let i = 0; i < 5; i++) { - dispatchMouseEvent(document, 'mousemove', left, top + 1); - fixture.detectChanges(); + for (let i = 0; i < 5; i++) { + dispatchMouseEvent(document, 'mousemove', left, top + 1); + fixture.detectChanges(); - expect(fixture.componentInstance.sortedSpy).not.toHaveBeenCalled(); - } + expect(fixture.componentInstance.sortedSpy).not.toHaveBeenCalled(); + } - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); - flush(); - }), - ); + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + })); it('should not move items in a vertical list if the pointer is too far away', fakeAsync(() => { const fixture = createComponent(DraggableInDropZone); @@ -813,6 +803,26 @@ export function defineCommonDropListTests(config: { scrollTo(0, 0); })); + it('should remove the anchor node once dragging stops', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + const list = fixture.componentInstance.dropInstance.element.nativeElement; + + startDraggingViaMouse(fixture, item); + + const anchor = Array.from(list.childNodes).find( + node => node.textContent === 'cdk-drag-anchor', + ); + expect(anchor).toBeTruthy(); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + + expect(anchor!.parentNode).toBeFalsy(); + })); + it('should create a preview element while the item is dragged', fakeAsync(() => { const fixture = createComponent(DraggableInDropZone); fixture.detectChanges(); @@ -1499,11 +1509,11 @@ export function defineCommonDropListTests(config: { it('should move the placeholder as an item is being sorted down', fakeAsync(() => { const fixture = createComponent(DraggableInDropZone); fixture.detectChanges(); - config.assertDownwardSorting( + assertStartToEndSorting( + 'vertical', fixture, - fixture.componentInstance.dragItems.map(item => { - return item.element.nativeElement; - }), + config.getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), ); })); @@ -1512,12 +1522,12 @@ export function defineCommonDropListTests(config: { fixture.detectChanges(); const cleanup = makeScrollable(); - scrollTo(0, 500); - config.assertDownwardSorting( + scrollTo(0, 5000); + assertStartToEndSorting( + 'vertical', fixture, - fixture.componentInstance.dragItems.map(item => { - return item.element.nativeElement; - }), + config.getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), ); cleanup(); })); @@ -1525,11 +1535,11 @@ export function defineCommonDropListTests(config: { it('should move the placeholder as an item is being sorted up', fakeAsync(() => { const fixture = createComponent(DraggableInDropZone); fixture.detectChanges(); - config.assertUpwardSorting( + assertEndToStartSorting( + 'vertical', fixture, - fixture.componentInstance.dragItems.map(item => { - return item.element.nativeElement; - }), + config.getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), ); })); @@ -1538,12 +1548,12 @@ export function defineCommonDropListTests(config: { fixture.detectChanges(); const cleanup = makeScrollable(); - scrollTo(0, 500); - config.assertUpwardSorting( + scrollTo(0, 5000); + assertEndToStartSorting( + 'vertical', fixture, - fixture.componentInstance.dragItems.map(item => { - return item.element.nativeElement; - }), + config.getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), ); cleanup(); })); @@ -1551,87 +1561,54 @@ export function defineCommonDropListTests(config: { 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(); - - startDraggingViaMouse(fixture, draggedItem, left, top); - - 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(config.getElementIndexByPosition(placeholder, 'left')).toBe(i); - } - - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); - flush(); + assertStartToEndSorting( + 'horizontal', + fixture, + config.getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), + ); })); it('should move the placeholder as an item is being sorted to the left', fakeAsync(() => { const fixture = createComponent(DraggableInHorizontalDropZone); fixture.detectChanges(); + assertEndToStartSorting( + 'horizontal', + fixture, + config.getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), + ); + })); + + it('should lay out the elements correctly, if an element skips multiple positions when sorting vertically', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); - const items = fixture.componentInstance.dragItems.toArray(); - const draggedItem = items[items.length - 1].element.nativeElement; + const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement); + const draggedItem = items[0]; const {top, left} = draggedItem.getBoundingClientRect(); startDraggingViaMouse(fixture, draggedItem, left, top); const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; + const targetRect = items[items.length - 1].getBoundingClientRect(); - // 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(); + // Add a few pixels to the top offset so we get some overlap. + dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.top + 5); + fixture.detectChanges(); - // Remove a few pixels from the right offset so we get some overlap. - dispatchMouseEvent(document, 'mousemove', elementRect.right - 5, elementRect.top); - fixture.detectChanges(); - expect(config.getElementIndexByPosition(placeholder, 'left')).toBe(i); - } + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())).toEqual([ + 'One', + 'Two', + 'Three', + 'Zero', + ]); dispatchMouseEvent(document, 'mouseup'); fixture.detectChanges(); flush(); })); - it( - 'should lay out the elements correctly, if an element skips multiple positions when ' + - 'sorting vertically', - fakeAsync(() => { - const fixture = createComponent(DraggableInDropZone); - fixture.detectChanges(); - - const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement); - const draggedItem = items[0]; - const {top, left} = draggedItem.getBoundingClientRect(); - - startDraggingViaMouse(fixture, draggedItem, left, top); - - const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; - const targetRect = items[items.length - 1].getBoundingClientRect(); - - // Add a few pixels to the top offset so we get some overlap. - dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.top + 5); - fixture.detectChanges(); - - expect( - config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()), - ).toEqual(['One', 'Two', 'Three', 'Zero']); - - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); - flush(); - }), - ); - it('should lay out the elements correctly, if an element skips multiple positions when sorting horizontally', fakeAsync(() => { const fixture = createComponent(DraggableInHorizontalDropZone); fixture.detectChanges(); @@ -1649,9 +1626,9 @@ export function defineCommonDropListTests(config: { dispatchMouseEvent(document, 'mousemove', targetRect.right - 5, targetRect.top); fixture.detectChanges(); - expect( - config.getElementSibligsByPosition(placeholder, 'left').map(e => e.textContent!.trim()), - ).toEqual(['One', 'Two', 'Three', 'Zero']); + expect(config.getSortedSiblings(placeholder, 'left').map(e => e.textContent!.trim())).toEqual( + ['One', 'Two', 'Three', 'Zero'], + ); dispatchMouseEvent(document, 'mouseup'); fixture.detectChanges(); @@ -1674,9 +1651,12 @@ export function defineCommonDropListTests(config: { const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; - expect( - config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()), - ).toEqual(['Zero', 'One', 'Two', 'Three']); + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())).toEqual([ + 'Zero', + 'One', + 'Two', + 'Three', + ]); const targetRect = target.getBoundingClientRect(); const pointerTop = targetRect.top + 20; @@ -1684,14 +1664,14 @@ export function defineCommonDropListTests(config: { // Move over the target so there's a 20px overlap. dispatchMouseEvent(document, 'mousemove', targetRect.left, pointerTop); fixture.detectChanges(); - expect(config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim())) + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())) .withContext('Expected position to swap.') .toEqual(['One', 'Zero', 'Two', 'Three']); // Move down a further 1px. dispatchMouseEvent(document, 'mousemove', targetRect.left, pointerTop + 1); fixture.detectChanges(); - expect(config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim())) + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())) .withContext('Expected positions not to swap.') .toEqual(['One', 'Zero', 'Two', 'Three']); @@ -1716,9 +1696,12 @@ export function defineCommonDropListTests(config: { const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; - expect( - config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()), - ).toEqual(['Zero', 'One', 'Two', 'Three']); + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())).toEqual([ + 'Zero', + 'One', + 'Two', + 'Three', + ]); const targetRect = target.getBoundingClientRect(); const pointerTop = targetRect.top + 20; @@ -1726,14 +1709,14 @@ export function defineCommonDropListTests(config: { // Move over the target so there's a 20px overlap. dispatchMouseEvent(document, 'mousemove', targetRect.left, pointerTop); fixture.detectChanges(); - expect(config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim())) + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())) .withContext('Expected position to swap.') .toEqual(['One', 'Zero', 'Two', 'Three']); // Move up 10px. dispatchMouseEvent(document, 'mousemove', targetRect.left, pointerTop - 10); fixture.detectChanges(); - expect(config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim())) + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())) .withContext('Expected positions to swap again.') .toEqual(['Zero', 'One', 'Two', 'Three']); @@ -1742,53 +1725,58 @@ export function defineCommonDropListTests(config: { flush(); })); - it( - 'it should allow item swaps in the same drag direction, if the pointer did not ' + - 'overlap with the sibling item after the previous swap', - fakeAsync(() => { - const fixture = createComponent(DraggableInDropZone); - fixture.detectChanges(); + it('it should allow item swaps in the same drag direction, if the pointer did not overlap with the sibling item after the previous swap', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); - const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement); - const draggedItem = items[0]; - const target = items[items.length - 1]; - const itemRect = draggedItem.getBoundingClientRect(); + const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement); + const draggedItem = items[0]; + const target = items[items.length - 1]; + const itemRect = draggedItem.getBoundingClientRect(); - startDraggingViaMouse(fixture, draggedItem, itemRect.left, itemRect.top); + startDraggingViaMouse(fixture, draggedItem, itemRect.left, itemRect.top); - const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; + const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; - expect( - config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()), - ).toEqual(['Zero', 'One', 'Two', 'Three']); + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())).toEqual([ + 'Zero', + 'One', + 'Two', + 'Three', + ]); - let targetRect = target.getBoundingClientRect(); + let targetRect = target.getBoundingClientRect(); - // Trigger a mouse move coming from the bottom so that the list thinks that we're - // sorting upwards. This usually how a user would behave with a mouse pointer. - dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.bottom + 50); - fixture.detectChanges(); - dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.bottom - 1); - fixture.detectChanges(); + // Trigger a mouse move coming from the bottom so that the list thinks that we're + // sorting upwards. This usually how a user would behave with a mouse pointer. + dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.bottom + 50); + fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.bottom - 1); + fixture.detectChanges(); - expect( - config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()), - ).toEqual(['One', 'Two', 'Three', 'Zero']); + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())).toEqual([ + 'One', + 'Two', + 'Three', + 'Zero', + ]); - // Refresh the rect since the element position has changed. - targetRect = target.getBoundingClientRect(); - dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.bottom - 1); - fixture.detectChanges(); + // Refresh the rect since the element position has changed. + targetRect = target.getBoundingClientRect(); + dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.bottom - 1); + fixture.detectChanges(); - expect( - config.getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()), - ).toEqual(['One', 'Two', 'Zero', 'Three']); + expect(config.getSortedSiblings(placeholder, 'top').map(e => e.textContent!.trim())).toEqual([ + 'One', + 'Two', + 'Zero', + 'Three', + ]); - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); - flush(); - }), - ); + 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); @@ -1933,24 +1921,39 @@ export function defineCommonDropListTests(config: { })); it('should keep the preview next to the trigger if the page was scrolled', fakeAsync(() => { + const extractTransform = (element: HTMLElement) => { + const match = element.style.transform.match(/translate3d\(\d+px, (\d+)px, \d+px\)/); + return match ? parseInt(match[1]) : 0; + }; + const fixture = createComponent(DraggableInDropZoneWithCustomPreview); fixture.detectChanges(); + const platform = TestBed.inject(Platform); + + // The programmatic scrolling inside the Karma iframe doesn't seem to work on iOS in the CI. + // Skip the test since the logic is the same for all other browsers which are covered. + if (platform.IOS) { + return; + } + const cleanup = makeScrollable(); const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; startDraggingViaMouse(fixture, item, 50, 50); const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement; - expect(preview.style.transform).toBe('translate3d(50px, 50px, 0px)'); + expect(extractTransform(preview)).toBe(50); - scrollTo(0, 500); + scrollTo(0, 5000); fixture.detectChanges(); // Move the pointer a bit so the preview has to reposition. dispatchMouseEvent(document, 'mousemove', 55, 55); fixture.detectChanges(); - expect(preview.style.transform).toBe('translate3d(55px, 555px, 0px)'); + // Note that here we just check that the value is greater, because on the + // CI the values end up being inconsistent between browsers. + expect(extractTransform(preview)).toBeGreaterThan(1000); cleanup(); })); @@ -2320,7 +2323,7 @@ export function defineCommonDropListTests(config: { dispatchMouseEvent(document, 'mousemove', targetX, targetY); fixture.detectChanges(); - expect(config.getElementIndexByPosition(placeholder, 'top')) + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) .withContext('Expected placeholder to stay in place.') .toBe(0); @@ -2602,7 +2605,7 @@ export function defineCommonDropListTests(config: { document, 'mousemove', listRect.left + listRect.width / 2, - listRect.top + listRect.height / 2, + listRect.bottom + listRect.height / 2, ); fixture.detectChanges(); tickAnimationFrames(20); @@ -2635,6 +2638,8 @@ export function defineCommonDropListTests(config: { dispatchMouseEvent(document, 'mouseup'); fixture.detectChanges(); tickAnimationFrames(20); + flush(); + fixture.detectChanges(); expect(list.scrollTop).toBe(previousScrollTop); })); @@ -2727,87 +2732,79 @@ export function defineCommonDropListTests(config: { cleanup(); })); - it( - 'should auto-scroll the list, not the viewport, when the pointer is over the edge of ' + - 'both the list and the viewport', - fakeAsync(() => { - const fixture = createComponent(DraggableInScrollableVerticalDropZone); - fixture.detectChanges(); + it('should auto-scroll the list, not the viewport, when the pointer is over the edge of both the list and the viewport', fakeAsync(() => { + const fixture = createComponent(DraggableInScrollableVerticalDropZone); + fixture.detectChanges(); - const list = fixture.componentInstance.dropInstance.element.nativeElement; - const viewportRuler = TestBed.inject(ViewportRuler); - const item = fixture.componentInstance.dragItems.first.element.nativeElement; + const list = fixture.componentInstance.dropInstance.element.nativeElement; + const viewportRuler = TestBed.inject(ViewportRuler); + const item = fixture.componentInstance.dragItems.first.element.nativeElement; - // Position the list so that its top aligns with the viewport top. That way the pointer - // will both over its top edge and the viewport's. We use top instead of bottom, because - // bottom behaves weirdly when we run tests on mobile devices. - list.style.position = 'fixed'; - list.style.left = '50%'; - list.style.top = '0'; - list.style.margin = '0'; + // Position the list so that its top aligns with the viewport top. That way the pointer + // will both over its top edge and the viewport's. We use top instead of bottom, because + // bottom behaves weirdly when we run tests on mobile devices. + list.style.position = 'fixed'; + list.style.left = '50%'; + list.style.top = '0'; + list.style.margin = '0'; - const listRect = list.getBoundingClientRect(); - const cleanup = makeScrollable(); + const listRect = list.getBoundingClientRect(); + const cleanup = makeScrollable(); - scrollTo(0, viewportRuler.getViewportSize().height * 5); - list.scrollTop = 50; + scrollTo(0, viewportRuler.getViewportSize().height * 5); + list.scrollTop = 50; - const initialScrollDistance = viewportRuler.getViewportScrollPosition().top; - expect(initialScrollDistance).toBeGreaterThan(0); - expect(list.scrollTop).toBe(50); + const initialScrollDistance = viewportRuler.getViewportScrollPosition().top; + expect(initialScrollDistance).toBeGreaterThan(0); + expect(list.scrollTop).toBe(50); - startDraggingViaMouse(fixture, item); - dispatchMouseEvent(document, 'mousemove', listRect.left + listRect.width / 2, 0); - fixture.detectChanges(); - tickAnimationFrames(20); + startDraggingViaMouse(fixture, item); + dispatchMouseEvent(document, 'mousemove', listRect.left + listRect.width / 2, 0); + fixture.detectChanges(); + tickAnimationFrames(20); - expect(viewportRuler.getViewportScrollPosition().top).toBe(initialScrollDistance); - expect(list.scrollTop).toBeLessThan(50); + expect(viewportRuler.getViewportScrollPosition().top).toBe(initialScrollDistance); + expect(list.scrollTop).toBeLessThan(50); - cleanup(); - }), - ); + cleanup(); + })); - it( - 'should auto-scroll the viewport, when the pointer is over the edge of both the list ' + - 'and the viewport, if the list cannot be scrolled in that direction', - fakeAsync(() => { - const fixture = createComponent(DraggableInScrollableVerticalDropZone); - fixture.detectChanges(); + it('should auto-scroll the viewport, when the pointer is over the edge of both the list and the viewport, if the list cannot be scrolled in that direction', fakeAsync(() => { + const fixture = createComponent(DraggableInScrollableVerticalDropZone); + fixture.detectChanges(); - const list = fixture.componentInstance.dropInstance.element.nativeElement; - const viewportRuler = TestBed.inject(ViewportRuler); - const item = fixture.componentInstance.dragItems.first.element.nativeElement; + const list = fixture.componentInstance.dropInstance.element.nativeElement; + const viewportRuler = TestBed.inject(ViewportRuler); + const item = fixture.componentInstance.dragItems.first.element.nativeElement; - // Position the list so that its top aligns with the viewport top. That way the pointer - // will both over its top edge and the viewport's. We use top instead of bottom, because - // bottom behaves weirdly when we run tests on mobile devices. - list.style.position = 'fixed'; - list.style.left = '50%'; - list.style.top = '0'; - list.style.margin = '0'; + // Position the list so that its top aligns with the viewport top. That way the pointer + // will both over its top edge and the viewport's. We use top instead of bottom, because + // bottom behaves weirdly when we run tests on mobile devices. + list.style.position = 'fixed'; + list.style.left = '50%'; + list.style.top = '0'; + list.style.margin = '0'; - const listRect = list.getBoundingClientRect(); - const cleanup = makeScrollable(); + const listRect = list.getBoundingClientRect(); + const cleanup = makeScrollable(); - scrollTo(0, viewportRuler.getViewportSize().height * 5); - list.scrollTop = 0; + scrollTo(0, viewportRuler.getViewportSize().height * 5); + list.scrollTop = 0; - const initialScrollDistance = viewportRuler.getViewportScrollPosition().top; - expect(initialScrollDistance).toBeGreaterThan(0); - expect(list.scrollTop).toBe(0); + const initialScrollDistance = viewportRuler.getViewportScrollPosition().top; + expect(initialScrollDistance).toBeGreaterThan(0); + expect(list.scrollTop).toBe(0); - startDraggingViaMouse(fixture, item); - dispatchMouseEvent(document, 'mousemove', listRect.left + listRect.width / 2, 0); - fixture.detectChanges(); - tickAnimationFrames(20); + startDraggingViaMouse(fixture, item); + dispatchMouseEvent(document, 'mousemove', listRect.left + listRect.width / 2, 0); + fixture.detectChanges(); + tickAnimationFrames(20); - expect(viewportRuler.getViewportScrollPosition().top).toBeLessThan(initialScrollDistance); - expect(list.scrollTop).toBe(0); + expect(viewportRuler.getViewportScrollPosition().top).toBeLessThan(initialScrollDistance); + expect(list.scrollTop).toBe(0); - cleanup(); - }), - ); + cleanup(); + })); it('should be able to auto-scroll a parent container', fakeAsync(() => { const fixture = createComponent(DraggableInScrollableParentContainer); @@ -3168,13 +3165,13 @@ export function defineCommonDropListTests(config: { fixture.detectChanges(); documentElement.style.position = 'absolute'; - documentElement.style.top = '-100px'; + documentElement.style.top = '100px'; - config.assertDownwardSorting( + assertStartToEndSorting( + 'vertical', fixture, - fixture.componentInstance.dragItems.map(item => { - return item.element.nativeElement; - }), + config.getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), ); documentElement.style.position = ''; @@ -3256,51 +3253,47 @@ export function defineCommonDropListTests(config: { expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); })); - it( - 'should be able to move the element over a new container and return it to the initial ' + - 'one, even if it no longer matches the enterPredicate', - fakeAsync(() => { - const fixture = createComponent(ConnectedDropZones); - fixture.detectChanges(); + it('should be able to move the element over a new container and return it to the initial one, even if it no longer matches the enterPredicate', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); - const groups = fixture.componentInstance.groupedDragItems; - const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); - const item = groups[0][1]; - const initialRect = item.element.nativeElement.getBoundingClientRect(); - const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); + const groups = fixture.componentInstance.groupedDragItems; + const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); + const item = groups[0][1]; + const initialRect = item.element.nativeElement.getBoundingClientRect(); + const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); - fixture.componentInstance.dropInstances.first.enterPredicate = () => false; - fixture.detectChanges(); + fixture.componentInstance.dropInstances.first.enterPredicate = () => false; + fixture.detectChanges(); - startDraggingViaMouse(fixture, item.element.nativeElement); + startDraggingViaMouse(fixture, item.element.nativeElement); - const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!; + const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!; - expect(placeholder).toBeTruthy(); - expect(dropZones[0].contains(placeholder)) - .withContext('Expected placeholder to be inside the first container.') - .toBe(true); + expect(placeholder).toBeTruthy(); + expect(dropZones[0].contains(placeholder)) + .withContext('Expected placeholder to be inside the first container.') + .toBe(true); - dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); - expect(dropZones[1].contains(placeholder)) - .withContext('Expected placeholder to be inside second container.') - .toBe(true); + expect(dropZones[1].contains(placeholder)) + .withContext('Expected placeholder to be inside second container.') + .toBe(true); - dispatchMouseEvent(document, 'mousemove', initialRect.left + 1, initialRect.top + 1); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', initialRect.left + 1, initialRect.top + 1); + fixture.detectChanges(); - expect(dropZones[0].contains(placeholder)) - .withContext('Expected placeholder to be back inside first container.') - .toBe(true); + expect(dropZones[0].contains(placeholder)) + .withContext('Expected placeholder to be back inside first container.') + .toBe(true); - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); - expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); - }), - ); + expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); + })); it('should transfer the DOM element from one drop zone to another', fakeAsync(() => { const fixture = createComponent(ConnectedDropZones); @@ -3438,7 +3431,12 @@ export function defineCommonDropListTests(config: { fixture.detectChanges(); }); - config.assertDownwardSorting(fixture, Array.from(dropZone.querySelectorAll('.cdk-drag'))); + assertStartToEndSorting( + 'vertical', + fixture, + config.getSortedSiblings, + Array.from(dropZone.querySelectorAll('.cdk-drag')), + ); })); it('should be able to return the last item inside its initial container', fakeAsync(() => { @@ -3579,45 +3577,6 @@ export function defineCommonDropListTests(config: { dispatchMouseEvent(document, 'mouseup'); })); - it('should enter as last child if entering from top in reversed container', fakeAsync(() => { - const fixture = createComponent(ConnectedDropZones); - - // Make sure there's only one item in the first list. - fixture.componentInstance.todo = ['things']; - fixture.detectChanges(); - - const groups = fixture.componentInstance.groupedDragItems; - const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); - const item = groups[0][0]; - - // Add some initial padding as the target drop zone - const targetDropZoneStyle = dropZones[1].style; - targetDropZoneStyle.paddingTop = '10px'; - targetDropZoneStyle.display = 'flex'; - targetDropZoneStyle.flexDirection = 'column-reverse'; - - const targetRect = dropZones[1].getBoundingClientRect(); - - startDraggingViaMouse(fixture, item.element.nativeElement); - - const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!; - - expect(placeholder).toBeTruthy(); - - expect(dropZones[0].contains(placeholder)) - .withContext('Expected placeholder to be inside the first container.') - .toBe(true); - - dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.top); - fixture.detectChanges(); - - expect(dropZones[1].lastChild === placeholder) - .withContext('Expected placeholder to be last child inside second container.') - .toBe(true); - - dispatchMouseEvent(document, 'mouseup'); - })); - it('should not throw when entering from the top with an intermediate sibling present', fakeAsync(() => { const fixture = createComponent(ConnectedDropZonesWithIntermediateSibling); @@ -3749,112 +3708,104 @@ export function defineCommonDropListTests(config: { }); })); - it( - 'should return DOM element to its initial container after it is dropped, in a container ' + - 'with one draggable item', - fakeAsync(() => { - const fixture = createComponent(ConnectedDropZonesWithSingleItems); - fixture.detectChanges(); - - const items = fixture.componentInstance.dragItems.toArray(); - const item = items[0]; - const targetRect = items[1].element.nativeElement.getBoundingClientRect(); - const dropContainers = fixture.componentInstance.dropInstances.map( - drop => drop.element.nativeElement, - ); + it('should return DOM element to its initial container after it is dropped, in a container with one draggable item', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZonesWithSingleItems); + fixture.detectChanges(); - expect(dropContainers[0].contains(item.element.nativeElement)) - .withContext('Expected DOM element to be in first container') - .toBe(true); - expect(item.dropContainer) - .withContext('Expected CdkDrag to be in first container in memory') - .toBe(fixture.componentInstance.dropInstances.first); + const items = fixture.componentInstance.dragItems.toArray(); + const item = items[0]; + const targetRect = items[1].element.nativeElement.getBoundingClientRect(); + const dropContainers = fixture.componentInstance.dropInstances.map( + drop => drop.element.nativeElement, + ); - dragElementViaMouse( - fixture, - item.element.nativeElement, - targetRect.left + 1, - targetRect.top + 1, - ); - flush(); - fixture.detectChanges(); + expect(dropContainers[0].contains(item.element.nativeElement)) + .withContext('Expected DOM element to be in first container') + .toBe(true); + expect(item.dropContainer) + .withContext('Expected CdkDrag to be in first container in memory') + .toBe(fixture.componentInstance.dropInstances.first); - expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1); + dragElementViaMouse( + fixture, + item.element.nativeElement, + targetRect.left + 1, + targetRect.top + 1, + ); + flush(); + fixture.detectChanges(); - const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0]; + expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1); - expect(event).toEqual({ - previousIndex: 0, - currentIndex: 0, - item, - container: fixture.componentInstance.dropInstances.toArray()[1], - previousContainer: fixture.componentInstance.dropInstances.first, - isPointerOverContainer: true, - distance: {x: jasmine.any(Number), y: jasmine.any(Number)}, - dropPoint: {x: jasmine.any(Number), y: jasmine.any(Number)}, - event: jasmine.anything(), - }); + const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0]; - expect(dropContainers[0].contains(item.element.nativeElement)) - .withContext('Expected DOM element to be returned to first container') - .toBe(true); - expect(item.dropContainer) - .withContext('Expected CdkDrag to be returned to first container in memory') - .toBe(fixture.componentInstance.dropInstances.first); - }), - ); + expect(event).toEqual({ + previousIndex: 0, + currentIndex: 0, + item, + container: fixture.componentInstance.dropInstances.toArray()[1], + previousContainer: fixture.componentInstance.dropInstances.first, + isPointerOverContainer: true, + distance: {x: jasmine.any(Number), y: jasmine.any(Number)}, + dropPoint: {x: jasmine.any(Number), y: jasmine.any(Number)}, + event: jasmine.anything(), + }); - it( - 'should be able to return an element to its initial container in the same sequence, ' + - 'even if it is not connected to the current container', - fakeAsync(() => { - const fixture = createComponent(ConnectedDropZones); - fixture.detectChanges(); + expect(dropContainers[0].contains(item.element.nativeElement)) + .withContext('Expected DOM element to be returned to first container') + .toBe(true); + expect(item.dropContainer) + .withContext('Expected CdkDrag to be returned to first container in memory') + .toBe(fixture.componentInstance.dropInstances.first); + })); - const groups = fixture.componentInstance.groupedDragItems; - const [todoDropInstance, doneDropInstance] = - fixture.componentInstance.dropInstances.toArray(); - const todoZone = todoDropInstance.element.nativeElement; - const doneZone = doneDropInstance.element.nativeElement; - const item = groups[0][1]; - const initialRect = item.element.nativeElement.getBoundingClientRect(); - const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); - - // Change the `connectedTo` so the containers are only connected one-way. - fixture.componentInstance.todoConnectedTo.set([doneDropInstance]); - fixture.componentInstance.doneConnectedTo.set([]); - fixture.detectChanges(); + it('should be able to return an element to its initial container in the same sequence, even if it is not connected to the current container', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); - startDraggingViaMouse(fixture, item.element.nativeElement); - fixture.detectChanges(); + const groups = fixture.componentInstance.groupedDragItems; + const [todoDropInstance, doneDropInstance] = + fixture.componentInstance.dropInstances.toArray(); + const todoZone = todoDropInstance.element.nativeElement; + const doneZone = doneDropInstance.element.nativeElement; + const item = groups[0][1]; + const initialRect = item.element.nativeElement.getBoundingClientRect(); + const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); - const placeholder = todoZone.querySelector('.cdk-drag-placeholder')!; + // Change the `connectedTo` so the containers are only connected one-way. + fixture.componentInstance.todoConnectedTo.set([doneDropInstance]); + fixture.componentInstance.doneConnectedTo.set([]); + fixture.detectChanges(); - expect(placeholder).toBeTruthy(); - expect(todoZone.contains(placeholder)) - .withContext('Expected placeholder to be inside the first container.') - .toBe(true); + startDraggingViaMouse(fixture, item.element.nativeElement); + fixture.detectChanges(); - dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); - fixture.detectChanges(); + const placeholder = todoZone.querySelector('.cdk-drag-placeholder')!; - expect(doneZone.contains(placeholder)) - .withContext('Expected placeholder to be inside second container.') - .toBe(true); + expect(placeholder).toBeTruthy(); + expect(todoZone.contains(placeholder)) + .withContext('Expected placeholder to be inside the first container.') + .toBe(true); - dispatchMouseEvent(document, 'mousemove', initialRect.left + 1, initialRect.top + 1); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); - expect(todoZone.contains(placeholder)) - .withContext('Expected placeholder to be back inside first container.') - .toBe(true); + expect(doneZone.contains(placeholder)) + .withContext('Expected placeholder to be inside second container.') + .toBe(true); - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', initialRect.left + 1, initialRect.top + 1); + fixture.detectChanges(); - expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); - }), - ); + expect(todoZone.contains(placeholder)) + .withContext('Expected placeholder to be back inside first container.') + .toBe(true); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + + expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); + })); it('should not add child drop lists to the same group as their parents', fakeAsync(() => { const fixture = createComponent(NestedDropListGroups); @@ -3986,46 +3937,42 @@ export function defineCommonDropListTests(config: { .toBe(true); })); - it( - 'should set the receiving class on the source container, even if the enter predicate ' + - 'does not match', - fakeAsync(() => { - const fixture = createComponent(ConnectedDropZones); - fixture.detectChanges(); - fixture.componentInstance.dropInstances.toArray()[0].enterPredicate = () => false; + it('should set the receiving class on the source container, even if the enter predicate does not match', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); + fixture.componentInstance.dropInstances.toArray()[0].enterPredicate = () => false; - const groups = fixture.componentInstance.groupedDragItems; - const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); - const item = groups[0][1]; - const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); + const groups = fixture.componentInstance.groupedDragItems; + const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); + const item = groups[0][1]; + const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); - expect(dropZones.every(c => !c.classList.contains('cdk-drop-list-receiving'))) - .withContext('Expected neither of the containers to have the class.') - .toBe(true); + expect(dropZones.every(c => !c.classList.contains('cdk-drop-list-receiving'))) + .withContext('Expected neither of the containers to have the class.') + .toBe(true); - startDraggingViaMouse(fixture, item.element.nativeElement); + startDraggingViaMouse(fixture, item.element.nativeElement); - expect(dropZones[0].classList) - .not.withContext('Expected source container not to have the receiving class.') - .toContain('cdk-drop-list-receiving'); + expect(dropZones[0].classList) + .not.withContext('Expected source container not to have the receiving class.') + .toContain('cdk-drop-list-receiving'); - expect(dropZones[1].classList) - .withContext('Expected target container to have the receiving class.') - .toContain('cdk-drop-list-receiving'); + expect(dropZones[1].classList) + .withContext('Expected target container to have the receiving class.') + .toContain('cdk-drop-list-receiving'); - dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); - expect(dropZones[0].classList) - .withContext('Expected old container to have the receiving class after exiting.') - .toContain('cdk-drop-list-receiving'); + expect(dropZones[0].classList) + .withContext('Expected old container to have the receiving class after exiting.') + .toContain('cdk-drop-list-receiving'); - expect(dropZones[1].classList).not.toContain( - 'cdk-drop-list-receiving', - 'Expected new container not to have the receiving class after exiting.', - ); - }), - ); + expect(dropZones[1].classList).not.toContain( + 'cdk-drop-list-receiving', + 'Expected new container not to have the receiving class after exiting.', + ); + })); it('should set the receiving class when the list is wrapped in an OnPush component', fakeAsync(() => { const fixture = createComponent(ConnectedDropListsInOnPush); @@ -4052,261 +3999,245 @@ export function defineCommonDropListTests(config: { .toContain('cdk-drop-list-receiving'); })); - it( - 'should be able to move the item over an intermediate container before ' + - 'dropping it into the final one', - fakeAsync(() => { - const fixture = createComponent(ConnectedDropZones); - fixture.detectChanges(); + it('should be able to move the item over an intermediate container before dropping it into the final one', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); - const [todoDropInstance, doneDropInstance, extraDropInstance] = - fixture.componentInstance.dropInstances.toArray(); - fixture.componentInstance.todoConnectedTo.set([doneDropInstance, extraDropInstance]); - fixture.componentInstance.doneConnectedTo.set([]); - fixture.componentInstance.extraConnectedTo.set([]); - fixture.detectChanges(); + const [todoDropInstance, doneDropInstance, extraDropInstance] = + fixture.componentInstance.dropInstances.toArray(); + fixture.componentInstance.todoConnectedTo.set([doneDropInstance, extraDropInstance]); + fixture.componentInstance.doneConnectedTo.set([]); + fixture.componentInstance.extraConnectedTo.set([]); + fixture.detectChanges(); - const groups = fixture.componentInstance.groupedDragItems; - const todoZone = todoDropInstance.element.nativeElement; - const doneZone = doneDropInstance.element.nativeElement; - const extraZone = extraDropInstance.element.nativeElement; - const item = groups[0][1]; - const intermediateRect = doneZone.getBoundingClientRect(); - const finalRect = extraZone.getBoundingClientRect(); + const groups = fixture.componentInstance.groupedDragItems; + const todoZone = todoDropInstance.element.nativeElement; + const doneZone = doneDropInstance.element.nativeElement; + const extraZone = extraDropInstance.element.nativeElement; + const item = groups[0][1]; + const intermediateRect = doneZone.getBoundingClientRect(); + const finalRect = extraZone.getBoundingClientRect(); - startDraggingViaMouse(fixture, item.element.nativeElement); + startDraggingViaMouse(fixture, item.element.nativeElement); - const placeholder = todoZone.querySelector('.cdk-drag-placeholder')!; + const placeholder = todoZone.querySelector('.cdk-drag-placeholder')!; - expect(placeholder).toBeTruthy(); - expect(todoZone.contains(placeholder)) - .withContext('Expected placeholder to be inside the first container.') - .toBe(true); + expect(placeholder).toBeTruthy(); + expect(todoZone.contains(placeholder)) + .withContext('Expected placeholder to be inside the first container.') + .toBe(true); - dispatchMouseEvent( - document, - 'mousemove', - intermediateRect.left + 1, - intermediateRect.top + 1, - ); - fixture.detectChanges(); + dispatchMouseEvent( + document, + 'mousemove', + intermediateRect.left + 1, + intermediateRect.top + 1, + ); + fixture.detectChanges(); - expect(doneZone.contains(placeholder)) - .withContext('Expected placeholder to be inside second container.') - .toBe(true); + expect(doneZone.contains(placeholder)) + .withContext('Expected placeholder to be inside second container.') + .toBe(true); - dispatchMouseEvent(document, 'mousemove', finalRect.left + 1, finalRect.top + 1); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', finalRect.left + 1, finalRect.top + 1); + fixture.detectChanges(); - expect(extraZone.contains(placeholder)) - .withContext('Expected placeholder to be inside third container.') - .toBe(true); + expect(extraZone.contains(placeholder)) + .withContext('Expected placeholder to be inside third container.') + .toBe(true); - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); - flush(); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); - const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0]; - - expect(event).toBeTruthy(); - expect(event).toEqual( - jasmine.objectContaining({ - previousIndex: 1, - currentIndex: 0, - item: groups[0][1], - container: extraDropInstance, - previousContainer: todoDropInstance, - isPointerOverContainer: false, - distance: {x: jasmine.any(Number), y: jasmine.any(Number)}, - dropPoint: {x: jasmine.any(Number), y: jasmine.any(Number)}, - event: jasmine.anything(), - }), - ); - }), - ); + const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0]; - it( - 'should not be able to move an item into a drop container that the initial container is ' + - 'not connected to by passing it over an intermediate one that is', - fakeAsync(() => { - const fixture = createComponent(ConnectedDropZones); - fixture.detectChanges(); + expect(event).toBeTruthy(); + expect(event).toEqual( + jasmine.objectContaining({ + previousIndex: 1, + currentIndex: 0, + item: groups[0][1], + container: extraDropInstance, + previousContainer: todoDropInstance, + isPointerOverContainer: false, + distance: {x: jasmine.any(Number), y: jasmine.any(Number)}, + dropPoint: {x: jasmine.any(Number), y: jasmine.any(Number)}, + event: jasmine.anything(), + }), + ); + })); - const [todoDropInstance, doneDropInstance, extraDropInstance] = - fixture.componentInstance.dropInstances.toArray(); - fixture.componentInstance.todoConnectedTo.set([doneDropInstance]); - fixture.componentInstance.doneConnectedTo.set([todoDropInstance, extraDropInstance]); - fixture.componentInstance.extraConnectedTo.set([doneDropInstance]); - fixture.detectChanges(); + it('should not be able to move an item into a drop container that the initial container is not connected to by passing it over an intermediate one that is', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); - const groups = fixture.componentInstance.groupedDragItems; - const todoZone = todoDropInstance.element.nativeElement; - const doneZone = doneDropInstance.element.nativeElement; - const extraZone = extraDropInstance.element.nativeElement; - const item = groups[0][1]; - const intermediateRect = doneZone.getBoundingClientRect(); - const finalRect = extraZone.getBoundingClientRect(); + const [todoDropInstance, doneDropInstance, extraDropInstance] = + fixture.componentInstance.dropInstances.toArray(); + fixture.componentInstance.todoConnectedTo.set([doneDropInstance]); + fixture.componentInstance.doneConnectedTo.set([todoDropInstance, extraDropInstance]); + fixture.componentInstance.extraConnectedTo.set([doneDropInstance]); + fixture.detectChanges(); - startDraggingViaMouse(fixture, item.element.nativeElement); + const groups = fixture.componentInstance.groupedDragItems; + const todoZone = todoDropInstance.element.nativeElement; + const doneZone = doneDropInstance.element.nativeElement; + const extraZone = extraDropInstance.element.nativeElement; + const item = groups[0][1]; + const intermediateRect = doneZone.getBoundingClientRect(); + const finalRect = extraZone.getBoundingClientRect(); - const placeholder = todoZone.querySelector('.cdk-drag-placeholder')!; + startDraggingViaMouse(fixture, item.element.nativeElement); - expect(placeholder).toBeTruthy(); - expect(todoZone.contains(placeholder)) - .withContext('Expected placeholder to be inside the first container.') - .toBe(true); + const placeholder = todoZone.querySelector('.cdk-drag-placeholder')!; - dispatchMouseEvent( - document, - 'mousemove', - intermediateRect.left + 1, - intermediateRect.top + 1, - ); - fixture.detectChanges(); + expect(placeholder).toBeTruthy(); + expect(todoZone.contains(placeholder)) + .withContext('Expected placeholder to be inside the first container.') + .toBe(true); - expect(doneZone.contains(placeholder)) - .withContext('Expected placeholder to be inside second container.') - .toBe(true); + dispatchMouseEvent( + document, + 'mousemove', + intermediateRect.left + 1, + intermediateRect.top + 1, + ); + fixture.detectChanges(); - dispatchMouseEvent(document, 'mousemove', finalRect.left + 1, finalRect.top + 1); - fixture.detectChanges(); + expect(doneZone.contains(placeholder)) + .withContext('Expected placeholder to be inside second container.') + .toBe(true); - expect(doneZone.contains(placeholder)) - .withContext('Expected placeholder to remain in the second container.') - .toBe(true); + dispatchMouseEvent(document, 'mousemove', finalRect.left + 1, finalRect.top + 1); + fixture.detectChanges(); - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); - flush(); - fixture.detectChanges(); + expect(doneZone.contains(placeholder)) + .withContext('Expected placeholder to remain in the second container.') + .toBe(true); - const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0]; - - expect(event).toBeTruthy(); - expect(event).toEqual( - jasmine.objectContaining({ - previousIndex: 1, - currentIndex: 1, - item: groups[0][1], - container: doneDropInstance, - previousContainer: todoDropInstance, - isPointerOverContainer: false, - }), - ); - }), - ); + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); - it( - 'should return the item to its initial position, if sorting in the source container ' + - 'was disabled', - fakeAsync(() => { - const fixture = createComponent(ConnectedDropZones); - fixture.detectChanges(); + const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0]; - const groups = fixture.componentInstance.groupedDragItems; - const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); - const item = groups[0][1]; - const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); + expect(event).toBeTruthy(); + expect(event).toEqual( + jasmine.objectContaining({ + previousIndex: 1, + currentIndex: 1, + item: groups[0][1], + container: doneDropInstance, + previousContainer: todoDropInstance, + isPointerOverContainer: false, + }), + ); + })); - fixture.componentInstance.dropInstances.first.sortingDisabled = true; - startDraggingViaMouse(fixture, item.element.nativeElement); + it('should return the item to its initial position, if sorting in the source container was disabled', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); - const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!; + const groups = fixture.componentInstance.groupedDragItems; + const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); + const item = groups[0][1]; + const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); - expect(placeholder).toBeTruthy(); - expect(dropZones[0].contains(placeholder)) - .withContext('Expected placeholder to be inside the first container.') - .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) - .withContext('Expected placeholder to be at item index.') - .toBe(1); + fixture.componentInstance.dropInstances.first.sortingDisabled = true; + startDraggingViaMouse(fixture, item.element.nativeElement); - dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); - fixture.detectChanges(); + const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!; - expect(dropZones[1].contains(placeholder)) - .withContext('Expected placeholder to be inside second container.') - .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) - .withContext('Expected placeholder to be at the target index.') - .toBe(3); - - const firstInitialSiblingRect = groups[0][0].element.nativeElement.getBoundingClientRect(); - - // Return the item to an index that is different from the initial one. - dispatchMouseEvent( - document, - 'mousemove', - firstInitialSiblingRect.left + 1, - firstInitialSiblingRect.top + 1, - ); - fixture.detectChanges(); + expect(placeholder).toBeTruthy(); + expect(dropZones[0].contains(placeholder)) + .withContext('Expected placeholder to be inside the first container.') + .toBe(true); + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) + .withContext('Expected placeholder to be at item index.') + .toBe(1); - expect(dropZones[0].contains(placeholder)) - .withContext('Expected placeholder to be back inside first container.') - .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) - .withContext('Expected placeholder to be back at the initial index.') - .toBe(1); + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); - dispatchMouseEvent(document, 'mouseup'); - fixture.detectChanges(); + expect(dropZones[1].contains(placeholder)) + .withContext('Expected placeholder to be inside second container.') + .toBe(true); + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) + .withContext('Expected placeholder to be at the target index.') + .toBe(3); - expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); - }), - ); + const firstInitialSiblingRect = groups[0][0].element.nativeElement.getBoundingClientRect(); - it( - 'should enter an item into the correct index when returning to the initial container, if ' + - 'sorting is enabled', - fakeAsync(() => { - const fixture = createComponent(ConnectedDropZones); - fixture.detectChanges(); + // Return the item to an index that is different from the initial one. + dispatchMouseEvent( + document, + 'mousemove', + firstInitialSiblingRect.left + 1, + firstInitialSiblingRect.top + 1, + ); + fixture.detectChanges(); - const groups = fixture.componentInstance.groupedDragItems; - const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); - const item = groups[0][1]; - const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); + expect(dropZones[0].contains(placeholder)) + .withContext('Expected placeholder to be back inside first container.') + .toBe(true); + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) + .withContext('Expected placeholder to be back at the initial index.') + .toBe(1); - // Explicitly enable just in case. - fixture.componentInstance.dropInstances.first.sortingDisabled = false; - startDraggingViaMouse(fixture, item.element.nativeElement); + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); - const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!; + expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); + })); - expect(placeholder).toBeTruthy(); - expect(dropZones[0].contains(placeholder)) - .withContext('Expected placeholder to be inside the first container.') - .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) - .withContext('Expected placeholder to be at item index.') - .toBe(1); + it('should enter an item into the correct index when returning to the initial container, if sorting is enabled', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); - dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); - fixture.detectChanges(); + const groups = fixture.componentInstance.groupedDragItems; + const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); + const item = groups[0][1]; + const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); - expect(dropZones[1].contains(placeholder)) - .withContext('Expected placeholder to be inside second container.') - .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) - .withContext('Expected placeholder to be at the target index.') - .toBe(3); + // Explicitly enable just in case. + fixture.componentInstance.dropInstances.first.sortingDisabled = false; + startDraggingViaMouse(fixture, item.element.nativeElement); - const nextTargetRect = groups[0][3].element.nativeElement.getBoundingClientRect(); + const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!; - // Return the item to an index that is different from the initial one. - dispatchMouseEvent(document, 'mousemove', nextTargetRect.left + 1, nextTargetRect.top + 1); - fixture.detectChanges(); + expect(placeholder).toBeTruthy(); + expect(dropZones[0].contains(placeholder)) + .withContext('Expected placeholder to be inside the first container.') + .toBe(true); + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) + .withContext('Expected placeholder to be at item index.') + .toBe(1); - expect(dropZones[0].contains(placeholder)) - .withContext('Expected placeholder to be back inside first container.') - .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) - .withContext('Expected placeholder to be at the index at which it entered.') - .toBe(2); - }), - ); + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); + + expect(dropZones[1].contains(placeholder)) + .withContext('Expected placeholder to be inside second container.') + .toBe(true); + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) + .withContext('Expected placeholder to be at the target index.') + .toBe(3); + + const nextTargetRect = groups[0][3].element.nativeElement.getBoundingClientRect(); + + // Return the item to an index that is different from the initial one. + dispatchMouseEvent(document, 'mousemove', nextTargetRect.left + 1, nextTargetRect.top + 1); + fixture.detectChanges(); + + expect(dropZones[0].contains(placeholder)) + .withContext('Expected placeholder to be back inside first container.') + .toBe(true); + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) + .withContext('Expected placeholder to be at the index at which it entered.') + .toBe(2); + })); it('should return the last item to initial position when dragging back into a container with disabled sorting', fakeAsync(() => { const fixture = createComponent(ConnectedDropZones); @@ -4327,7 +4258,7 @@ export function defineCommonDropListTests(config: { expect(dropZones[0].contains(placeholder)) .withContext('Expected placeholder to be inside the first container.') .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) .withContext('Expected placeholder to be at item index.') .toBe(lastIndex); @@ -4337,7 +4268,7 @@ export function defineCommonDropListTests(config: { expect(dropZones[1].contains(placeholder)) .withContext('Expected placeholder to be inside second container.') .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) .withContext('Expected placeholder to be at the target index.') .toBe(3); @@ -4355,7 +4286,7 @@ export function defineCommonDropListTests(config: { expect(dropZones[0].contains(placeholder)) .withContext('Expected placeholder to be back inside first container.') .toBe(true); - expect(config.getElementIndexByPosition(placeholder, 'top')) + expect(config.getSortedSiblings(placeholder, 'top').indexOf(placeholder)) .withContext('Expected placeholder to be back at the initial index.') .toBe(lastIndex); @@ -4365,48 +4296,42 @@ export function defineCommonDropListTests(config: { expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); })); - it( - 'should toggle a class when dragging an item inside a wrapper component component ' + - 'with OnPush change detection', - fakeAsync(() => { - const fixture = createComponent(ConnectedWrappedDropZones); - fixture.detectChanges(); + it('should toggle a class when dragging an item inside a wrapper component component with OnPush change detection', fakeAsync(() => { + const fixture = createComponent(ConnectedWrappedDropZones); + fixture.detectChanges(); - const [startZone, targetZone] = fixture.nativeElement.querySelectorAll('.cdk-drop-list'); - const item = startZone.querySelector('.cdk-drag'); - const targetRect = targetZone.getBoundingClientRect(); + const [startZone, targetZone] = fixture.nativeElement.querySelectorAll('.cdk-drop-list'); + const item = startZone.querySelector('.cdk-drag'); + const targetRect = targetZone.getBoundingClientRect(); - expect(startZone.classList).not.toContain( - 'cdk-drop-list-dragging', - 'Expected start not to have dragging class on init.', - ); - expect(targetZone.classList).not.toContain( - 'cdk-drop-list-dragging', - 'Expected target not to have dragging class on init.', - ); + expect(startZone.classList).not.toContain( + 'cdk-drop-list-dragging', + 'Expected start not to have dragging class on init.', + ); + expect(targetZone.classList).not.toContain( + 'cdk-drop-list-dragging', + 'Expected target not to have dragging class on init.', + ); - startDraggingViaMouse(fixture, item); + startDraggingViaMouse(fixture, item); - expect(startZone.classList) - .withContext('Expected start to have dragging class after dragging has started.') - .toContain('cdk-drop-list-dragging'); - expect(targetZone.classList) - .not.withContext('Expected target not to have dragging class after dragging has started.') - .toContain('cdk-drop-list-dragging'); + expect(startZone.classList) + .withContext('Expected start to have dragging class after dragging has started.') + .toContain('cdk-drop-list-dragging'); + expect(targetZone.classList) + .not.withContext('Expected target not to have dragging class after dragging has started.') + .toContain('cdk-drop-list-dragging'); - dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); - fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); - expect(startZone.classList) - .not.withContext( - 'Expected start not to have dragging class once item has been moved over.', - ) - .toContain('cdk-drop-list-dragging'); - expect(targetZone.classList) - .withContext('Expected target to have dragging class once item has been moved over.') - .toContain('cdk-drop-list-dragging'); - }), - ); + expect(startZone.classList) + .not.withContext('Expected start not to have dragging class once item has been moved over.') + .toContain('cdk-drop-list-dragging'); + expect(targetZone.classList) + .withContext('Expected target to have dragging class once item has been moved over.') + .toContain('cdk-drop-list-dragging'); + })); it('should dispatch an event when an item enters a new container', fakeAsync(() => { const fixture = createComponent(ConnectedDropZones); @@ -4464,7 +4389,7 @@ export function defineCommonDropListTests(config: { // Make the page scrollable and scroll the items out of view. const cleanup = makeScrollable(); - scrollTo(0, 4000); + scrollTo(0, 0); dispatchFakeEvent(document, 'scroll'); fixture.detectChanges(); flush(); @@ -4475,7 +4400,7 @@ export function defineCommonDropListTests(config: { // Start dragging and then scroll the elements back into view. startDraggingViaMouse(fixture, item.element.nativeElement); - scrollTo(0, 0); + scrollTo(0, 5000); dispatchFakeEvent(document, 'scroll'); const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); @@ -4786,6 +4711,85 @@ export function defineCommonDropListTests(config: { }); } +export function assertStartToEndSorting( + listOrientation: 'vertical' | 'horizontal', + fixture: ComponentFixture, + getSortedSiblings: SortedSiblingsFunction, + items: Element[], +) { + const draggedItem = items[0]; + const {top, left} = draggedItem.getBoundingClientRect(); + + startDraggingViaMouse(fixture, draggedItem, left, top); + + 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].getBoundingClientRect(); + + // Add a few pixels to the top offset so we get some overlap. + if (listOrientation === 'vertical') { + dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.top + 5); + } else { + dispatchMouseEvent(document, 'mousemove', elementRect.left + 5, elementRect.top); + } + + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + const sortedSiblings = getSortedSiblings( + placeholder, + listOrientation === 'vertical' ? 'top' : 'left', + ); + expect(sortedSiblings.indexOf(placeholder)).toBe(i); + } + + dispatchMouseEvent(document, 'mouseup'); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + flush(); +} + +export function assertEndToStartSorting( + listOrientation: 'vertical' | 'horizontal', + fixture: ComponentFixture, + getSortedSiblings: SortedSiblingsFunction, + items: Element[], +) { + const draggedItem = items[items.length - 1]; + const {top, left} = draggedItem.getBoundingClientRect(); + + startDraggingViaMouse(fixture, draggedItem, left, top); + + 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].getBoundingClientRect(); + + // Remove a few pixels from the bottom offset so we get some overlap. + if (listOrientation === 'vertical') { + dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.bottom - 5); + } else { + dispatchMouseEvent(document, 'mousemove', elementRect.right - 5, elementRect.top); + } + + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect( + getSortedSiblings(placeholder, listOrientation === 'vertical' ? 'top' : 'left').indexOf( + placeholder, + ), + ).toBe(i); + } + + dispatchMouseEvent(document, 'mouseup'); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + flush(); +} + /** * Dynamically creates the horizontal list fixtures. They need to be * generated so that the list orientation can be changed between tests. @@ -5348,7 +5352,7 @@ const CONNECTED_DROP_ZONES_TEMPLATE = ` standalone: true, imports: [CdkDropList, CdkDrag], }) -class ConnectedDropZones implements AfterViewInit { +export class ConnectedDropZones implements AfterViewInit { @ViewChildren(CdkDrag) rawDragItems: QueryList; @ViewChildren(CdkDropList) dropInstances: QueryList; changeDetectorRef = inject(ChangeDetectorRef); diff --git a/src/cdk/drag-drop/directives/mixed-drop-list.spec.ts b/src/cdk/drag-drop/directives/mixed-drop-list.spec.ts new file mode 100644 index 000000000000..8deaae351dd5 --- /dev/null +++ b/src/cdk/drag-drop/directives/mixed-drop-list.spec.ts @@ -0,0 +1,144 @@ +import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core'; +import {fakeAsync, flush} from '@angular/core/testing'; +import {CdkDropList} from './drop-list'; +import {CdkDrag} from './drag'; +import {moveItemInArray} from '../drag-utils'; +import {CdkDragDrop} from '../drag-events'; +import { + ITEM_HEIGHT, + ITEM_WIDTH, + assertStartToEndSorting, + assertEndToStartSorting, + defineCommonDropListTests, +} from './drop-list-shared.spec'; +import {createComponent, dragElementViaMouse} from './test-utils.spec'; + +describe('mixed drop list', () => { + defineCommonDropListTests({ + verticalListOrientation: 'mixed', + horizontalListOrientation: 'mixed', + getSortedSiblings, + }); + + it('should dispatch the `dropped` event in a wrapping drop zone', fakeAsync(() => { + const fixture = createComponent(DraggableInHorizontalWrappingDropZone); + fixture.detectChanges(); + const dragItems = fixture.componentInstance.dragItems; + + expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim())).toEqual([ + 'Zero', + 'One', + 'Two', + 'Three', + 'Four', + 'Five', + 'Six', + 'Seven', + ]); + + const firstItem = dragItems.first; + const seventhItemRect = dragItems.toArray()[6].element.nativeElement.getBoundingClientRect(); + + dragElementViaMouse( + fixture, + firstItem.element.nativeElement, + seventhItemRect.left + 1, + seventhItemRect.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: 6, + item: firstItem, + container: fixture.componentInstance.dropInstance, + previousContainer: fixture.componentInstance.dropInstance, + isPointerOverContainer: true, + distance: {x: jasmine.any(Number), y: jasmine.any(Number)}, + dropPoint: {x: jasmine.any(Number), y: jasmine.any(Number)}, + event: jasmine.anything(), + }); + + expect(dragItems.map(drag => drag.element.nativeElement.textContent!.trim())).toEqual([ + 'One', + 'Two', + 'Three', + 'Four', + 'Five', + 'Six', + 'Zero', + 'Seven', + ]); + })); + + it('should move the placeholder as an item is being sorted to the right in a wrapping drop zone', fakeAsync(() => { + const fixture = createComponent(DraggableInHorizontalWrappingDropZone); + fixture.detectChanges(); + assertStartToEndSorting( + 'horizontal', + fixture, + getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), + ); + })); + + it('should move the placeholder as an item is being sorted to the left in a wrapping drop zone', fakeAsync(() => { + const fixture = createComponent(DraggableInHorizontalWrappingDropZone); + fixture.detectChanges(); + assertEndToStartSorting( + 'horizontal', + fixture, + getSortedSiblings, + fixture.componentInstance.dragItems.map(item => item.element.nativeElement), + ); + })); +}); + +function getSortedSiblings(item: Element) { + return Array.from(item.parentElement?.children || []); +} + +@Component({ + styles: ` + .cdk-drop-list { + display: block; + width: ${ITEM_WIDTH * 3}px; + background: pink; + font-size: 0; + } + + .cdk-drag { + height: ${ITEM_HEIGHT * 2}px; + width: ${ITEM_WIDTH}px; + background: red; + display: inline-block; + } + `, + template: ` +
+ @for (item of items; track item) { +
{{item}}
+ } +
+ `, + standalone: true, + imports: [CdkDropList, CdkDrag], +}) +class DraggableInHorizontalWrappingDropZone { + @ViewChildren(CdkDrag) dragItems: QueryList; + @ViewChild(CdkDropList) dropInstance: CdkDropList; + items = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven']; + droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop) => { + moveItemInArray(this.items, event.previousIndex, event.currentIndex); + }); +} diff --git a/src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts b/src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts index ecaf013d33c5..324ccc8b8776 100644 --- a/src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts +++ b/src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts @@ -1,8 +1,9 @@ -import {ComponentFixture, fakeAsync, flush} from '@angular/core/testing'; +import {fakeAsync, flush} from '@angular/core/testing'; import {dispatchMouseEvent} from '@angular/cdk/testing/private'; import {_supportsShadowDom} from '@angular/cdk/platform'; import {createComponent, startDraggingViaMouse} from './test-utils.spec'; import { + ConnectedDropZones, DraggableInDropZone, DraggableInScrollableVerticalDropZone, ITEM_HEIGHT, @@ -17,10 +18,13 @@ describe('Single-axis drop list', () => { defineCommonDropListTests({ verticalListOrientation: 'vertical', horizontalListOrientation: 'horizontal', - getElementIndexByPosition, - getElementSibligsByPosition, - assertUpwardSorting, - assertDownwardSorting, + getSortedSiblings: (element, direction) => { + return element.parentElement + ? Array.from(element.parentElement.children).sort((a, b) => { + return a.getBoundingClientRect()[direction] - b.getBoundingClientRect()[direction]; + }) + : []; + }, }); it('should lay out the elements correctly, when swapping down with a taller element', fakeAsync(() => { @@ -268,66 +272,43 @@ describe('Single-axis drop list', () => { .withContext('Expected placeholder to preserve transform when dragging stops.') .toBe(true); })); -}); - -function getElementIndexByPosition(element: Element, direction: 'top' | 'left') { - return getElementSibligsByPosition(element, direction).indexOf(element); -} - -function getElementSibligsByPosition(element: Element, direction: 'top' | 'left') { - return element.parentElement - ? Array.from(element.parentElement.children).sort((a, b) => { - return a.getBoundingClientRect()[direction] - b.getBoundingClientRect()[direction]; - }) - : []; -} - -function assertDownwardSorting(fixture: ComponentFixture, items: Element[]) { - const draggedItem = items[0]; - const {top, left} = draggedItem.getBoundingClientRect(); - startDraggingViaMouse(fixture, draggedItem, left, top); + it('should enter as last child if entering from top in reversed container', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); - const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; + // Make sure there's only one item in the first list. + fixture.componentInstance.todo = ['things']; + fixture.detectChanges(); - // Drag over each item one-by-one going downwards. - for (let i = 0; i < items.length; i++) { - const elementRect = items[i].getBoundingClientRect(); + const groups = fixture.componentInstance.groupedDragItems; + const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement); + const item = groups[0][0]; - // Add a few pixels to the top offset so we get some overlap. - dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.top + 5); - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - expect(getElementIndexByPosition(placeholder, 'top')).toBe(i); - } + // Add some initial padding as the target drop zone + const targetDropZoneStyle = dropZones[1].style; + targetDropZoneStyle.paddingTop = '10px'; + targetDropZoneStyle.display = 'flex'; + targetDropZoneStyle.flexDirection = 'column-reverse'; - dispatchMouseEvent(document, 'mouseup'); - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - flush(); -} + const targetRect = dropZones[1].getBoundingClientRect(); -function assertUpwardSorting(fixture: ComponentFixture, items: Element[]) { - const draggedItem = items[items.length - 1]; - const {top, left} = draggedItem.getBoundingClientRect(); + startDraggingViaMouse(fixture, item.element.nativeElement); - startDraggingViaMouse(fixture, draggedItem, left, top); + const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!; - const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; + expect(placeholder).toBeTruthy(); - // Drag over each item one-by-one going upwards. - for (let i = items.length - 1; i > -1; i--) { - const elementRect = items[i].getBoundingClientRect(); + expect(dropZones[0].contains(placeholder)) + .withContext('Expected placeholder to be inside the first container.') + .toBe(true); - // Remove a few pixels from the bottom offset so we get some overlap. - dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.bottom - 5); - fixture.changeDetectorRef.markForCheck(); + dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.top); fixture.detectChanges(); - expect(getElementIndexByPosition(placeholder, 'top')).toBe(i); - } - dispatchMouseEvent(document, 'mouseup'); - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - flush(); -} + expect(dropZones[1].lastChild === placeholder) + .withContext('Expected placeholder to be last child inside second container.') + .toBe(true); + + dispatchMouseEvent(document, 'mouseup'); + })); +}); diff --git a/src/cdk/drag-drop/directives/test-utils.spec.ts b/src/cdk/drag-drop/directives/test-utils.spec.ts index 267f4c06817f..2c0c12027eac 100644 --- a/src/cdk/drag-drop/directives/test-utils.spec.ts +++ b/src/cdk/drag-drop/directives/test-utils.spec.ts @@ -153,7 +153,7 @@ export function makeScrollable( const veryTallElement = document.createElement('div'); veryTallElement.style.width = direction === 'vertical' ? '100%' : '4000px'; veryTallElement.style.height = direction === 'vertical' ? '2000px' : '5px'; - element.appendChild(veryTallElement); + element.prepend(veryTallElement); return () => { scrollTo(0, 0); diff --git a/src/cdk/drag-drop/drag-drop.md b/src/cdk/drag-drop/drag-drop.md index 0d90da0800fc..f9f7ad1b02e8 100644 --- a/src/cdk/drag-drop/drag-drop.md +++ b/src/cdk/drag-drop/drag-drop.md @@ -157,10 +157,22 @@ directive: ### List orientation The `cdkDropList` directive assumes that lists are vertical by default. This can be -changed by setting the `orientation` property to `"horizontal". +changed by setting the `cdkDropListOrientation` property to `horizontal`. +### List wrapping +By default the `cdkDropList` sorts the items by moving them around using a CSS `transform`. This +allows for the sorting to be animated which provides a better user experience, but comes with the +drawback that it works only one direction: vertically or horizontally. + +If you have a sortable list that needs to wrap, you can set `cdkDropListOrientation="mixed"` which +will use a different strategy of sorting the elements that works by moving them in the DOM. It has +the advantage of allowing the items to wrap to the next line, but it **cannot** animate the +sorting action. + + + ### Restricting movement within an element If you want to stop the user from being able to drag a `cdkDrag` element outside of another element, diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index b961326e84be..2b751e373171 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -822,7 +822,8 @@ export class DragRef { const element = this._rootElement; const parent = element.parentNode as HTMLElement; const placeholder = (this._placeholder = this._createPlaceholderElement()); - const anchor = (this._anchor = this._anchor || this._document.createComment('')); + const anchor = (this._anchor = + this._anchor || this._document.createComment(ngDevMode ? 'cdk-drag-anchor' : '')); // Insert an anchor node so that we can restore the element's position in the DOM. parent.insertBefore(anchor, element); diff --git a/src/cdk/drag-drop/drop-list-ref.ts b/src/cdk/drag-drop/drop-list-ref.ts index e648c40a93db..a1bf214aeb42 100644 --- a/src/cdk/drag-drop/drop-list-ref.ts +++ b/src/cdk/drag-drop/drop-list-ref.ts @@ -20,6 +20,8 @@ import {ParentPositionTracker} from './dom/parent-position-tracker'; import {DragCSSStyleDeclaration} from './dom/styling'; import {DropListSortStrategy} from './sorting/drop-list-sort-strategy'; import {SingleAxisSortStrategy} from './sorting/single-axis-sort-strategy'; +import {MixedSortStrategy} from './sorting/mixed-sort-strategy'; +import {DropListOrientation} from './directives/config'; /** * Proximity, as a ratio to width/height, at which a @@ -146,7 +148,7 @@ export class DropListRef { private _parentPositions: ParentPositionTracker; /** Strategy being used to sort items within the list. */ - private _sortStrategy: DropListSortStrategy; + private _sortStrategy: DropListSortStrategy; /** Cached `DOMRect` of the drop list. */ private _domRect: DOMRect | undefined; @@ -187,6 +189,9 @@ export class DropListRef { /** Initial value for the element's `scroll-snap-type` style. */ private _initialScrollSnap: string; + /** Direction of the list's layout. */ + private _direction: Direction = 'ltr'; + constructor( element: ElementRef | HTMLElement, private _dragDropRegistry: DragDropRegistry, @@ -196,11 +201,9 @@ export class DropListRef { ) { this.element = coerceElement(element); this._document = _document; - this.withScrollableParents([this.element]); + this.withScrollableParents([this.element]).withOrientation('vertical'); _dragDropRegistry.registerDropContainer(this); this._parentPositions = new ParentPositionTracker(_document); - this._sortStrategy = new SingleAxisSortStrategy(this.element, _dragDropRegistry); - this._sortStrategy.withSortPredicate((index, item) => this.sortPredicate(index, item, this)); } /** Removes the drop list functionality from the DOM element. */ @@ -332,7 +335,10 @@ export class DropListRef { /** Sets the layout direction of the drop list. */ withDirection(direction: Direction): this { - this._sortStrategy.direction = direction; + this._direction = direction; + if (this._sortStrategy instanceof SingleAxisSortStrategy) { + this._sortStrategy.direction = direction; + } return this; } @@ -350,10 +356,23 @@ export class DropListRef { * Sets the orientation of the container. * @param orientation New orientation for the container. */ - withOrientation(orientation: 'vertical' | 'horizontal'): this { - // TODO(crisbeto): eventually we should be constructing the new sort strategy here based on - // the new orientation. For now we can assume that it'll always be `SingleAxisSortStrategy`. - (this._sortStrategy as SingleAxisSortStrategy).orientation = orientation; + withOrientation(orientation: DropListOrientation): this { + if (orientation === 'mixed') { + this._sortStrategy = new MixedSortStrategy( + coerceElement(this.element), + this._document, + this._dragDropRegistry, + ); + } else { + const strategy = new SingleAxisSortStrategy( + coerceElement(this.element), + this._dragDropRegistry, + ); + strategy.direction = this._direction; + strategy.orientation = orientation; + this._sortStrategy = strategy; + } + this._sortStrategy.withSortPredicate((index, item) => this.sortPredicate(index, item, this)); return this; } @@ -455,7 +474,7 @@ export class DropListRef { [verticalScrollDirection, horizontalScrollDirection] = getElementScrollDirections( element as HTMLElement, position.clientRect, - this._sortStrategy.direction, + this._direction, pointerX, pointerY, ); diff --git a/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts b/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts index 68046ffadfc4..f6574811bfe6 100644 --- a/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Direction} from '@angular/cdk/bidi'; +import type {DragRef} from '../drag-ref'; /** * Function that is used to determine whether an item can be sorted into a particular index. @@ -14,38 +14,23 @@ import {Direction} from '@angular/cdk/bidi'; */ export type SortPredicate = (index: number, item: T) => boolean; -/** - * Item that can be sorted within `DropListSortStrategy`. This is a limited representation of - * `DragRef` used to avoid circular dependencies. It is intended to only be used within - * `DropListSortStrategy`. - * @docs-private - */ -export interface DropListSortStrategyItem { - isDragging(): boolean; - getPlaceholderElement(): HTMLElement; - getRootElement(): HTMLElement; - _sortFromLastPointerPosition(): void; - getVisibleElement(): HTMLElement; -} - /** * Strategy used to sort and position items within a drop list. * @docs-private */ -export interface DropListSortStrategy { - direction: Direction; - start(items: readonly T[]): void; +export interface DropListSortStrategy { + start(items: readonly DragRef[]): void; sort( - item: T, + item: DragRef, pointerX: number, pointerY: number, pointerDelta: {x: number; y: number}, ): {previousIndex: number; currentIndex: number} | null; - enter(item: T, pointerX: number, pointerY: number, index?: number): void; - withItems(items: readonly T[]): void; - withSortPredicate(predicate: SortPredicate): void; + enter(item: DragRef, pointerX: number, pointerY: number, index?: number): void; + withItems(items: readonly DragRef[]): void; + withSortPredicate(predicate: SortPredicate): void; reset(): void; - getActiveItemsSnapshot(): readonly T[]; - getItemIndex(item: T): number; + getActiveItemsSnapshot(): readonly DragRef[]; + getItemIndex(item: DragRef): number; updateOnScroll(topDifference: number, leftDifference: number): void; } diff --git a/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts b/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts new file mode 100644 index 000000000000..ab728b64aed1 --- /dev/null +++ b/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts @@ -0,0 +1,305 @@ +/** + * @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 {_getShadowRoot} from '@angular/cdk/platform'; +import {moveItemInArray} from '../drag-utils'; +import {DropListSortStrategy, SortPredicate} from './drop-list-sort-strategy'; +import {DragDropRegistry} from '../drag-drop-registry'; +import type {DragRef} from '../drag-ref'; + +/** + * Strategy that only supports sorting on a list that might wrap. + * Items are reordered by moving their DOM nodes around. + * @docs-private + */ +export class MixedSortStrategy implements DropListSortStrategy { + /** Function used to determine if an item can be sorted into a specific index. */ + private _sortPredicate: SortPredicate; + + /** Lazily-resolved root node containing the list. Use `_getRootNode` to read this. */ + private _rootNode: DocumentOrShadowRoot | undefined; + + /** + * Draggable items that are currently active inside the container. Includes the items + * that were there at the start of the sequence, as well as any items that have been dragged + * in, but haven't been dropped yet. + */ + private _activeItems: DragRef[]; + + /** + * Keeps track of the item that was last swapped with the dragged item, as well as what direction + * the pointer was moving in when the swap occurred and whether the user's pointer continued to + * overlap with the swapped item after the swapping occurred. + */ + private _previousSwap = { + drag: null as DragRef | null, + deltaX: 0, + deltaY: 0, + overlaps: false, + }; + + /** + * Keeps track of the relationship between a node and its next sibling. This information + * is used to restore the DOM to the order it was in before dragging started. + */ + private _relatedNodes: [node: Node, nextSibling: Node | null][] = []; + + constructor( + private _element: HTMLElement, + private _document: Document, + private _dragDropRegistry: DragDropRegistry, + ) {} + + /** + * To be called when the drag sequence starts. + * @param items Items that are currently in the list. + */ + start(items: readonly DragRef[]): void { + const childNodes = this._element.childNodes; + this._relatedNodes = []; + + for (let i = 0; i < childNodes.length; i++) { + const node = childNodes[i]; + this._relatedNodes.push([node, node.nextSibling]); + } + + this.withItems(items); + } + + /** + * To be called when an item is being sorted. + * @param item Item to be sorted. + * @param pointerX Position of the item along the X axis. + * @param pointerY Position of the item along the Y axis. + * @param pointerDelta Direction in which the pointer is moving along each axis. + */ + sort( + item: DragRef, + pointerX: number, + pointerY: number, + pointerDelta: {x: number; y: number}, + ): {previousIndex: number; currentIndex: number} | null { + const newIndex = this._getItemIndexFromPointerPosition(item, pointerX, pointerY); + const previousSwap = this._previousSwap; + + if (newIndex === -1 || this._activeItems[newIndex] === item) { + return null; + } + + const toSwapWith = this._activeItems[newIndex]; + + // Prevent too many swaps over the same item. + if ( + previousSwap.drag === toSwapWith && + previousSwap.overlaps && + previousSwap.deltaX === pointerDelta.x && + previousSwap.deltaY === pointerDelta.y + ) { + return null; + } + + const previousIndex = this.getItemIndex(item); + const current = item.getPlaceholderElement(); + const overlapElement = toSwapWith.getRootElement(); + + if (newIndex > previousIndex) { + overlapElement.after(current); + } else { + overlapElement.before(current); + } + + moveItemInArray(this._activeItems, previousIndex, newIndex); + + const newOverlapElement = this._getRootNode().elementFromPoint(pointerX, pointerY); + // Note: it's tempting to save the entire `pointerDelta` object here, however that'll + // break this functionality, because the same object is passed for all `sort` calls. + previousSwap.deltaX = pointerDelta.x; + previousSwap.deltaY = pointerDelta.y; + previousSwap.drag = toSwapWith; + previousSwap.overlaps = + overlapElement === newOverlapElement || overlapElement.contains(newOverlapElement); + + return { + previousIndex, + currentIndex: newIndex, + }; + } + + /** + * Called when an item is being moved into the container. + * @param item Item that was moved into the container. + * @param pointerX Position of the item along the X axis. + * @param pointerY Position of the item along the Y axis. + * @param index Index at which the item entered. If omitted, the container will try to figure it + * out automatically. + */ + enter(item: DragRef, pointerX: number, pointerY: number, index?: number): void { + let enterIndex = + index == null || index < 0 + ? this._getItemIndexFromPointerPosition(item, pointerX, pointerY) + : index; + + // In some cases (e.g. when the container has padding) we might not be able to figure + // out which item to insert the dragged item next to, because the pointer didn't overlap + // with anything. In that case we find the item that's closest to the pointer. + if (enterIndex === -1) { + enterIndex = this._getClosestItemIndexToPointer(item, pointerX, pointerY); + } + + const targetItem = this._activeItems[enterIndex] as DragRef | undefined; + const currentIndex = this._activeItems.indexOf(item); + + if (currentIndex > -1) { + this._activeItems.splice(currentIndex, 1); + } + + if (targetItem && !this._dragDropRegistry.isDragging(targetItem)) { + this._activeItems.splice(enterIndex, 0, item); + targetItem.getRootElement().before(item.getPlaceholderElement()); + } else { + this._activeItems.push(item); + this._element.appendChild(item.getPlaceholderElement()); + } + } + + /** Sets the items that are currently part of the list. */ + withItems(items: readonly DragRef[]): void { + this._activeItems = items.slice(); + } + + /** Assigns a sort predicate to the strategy. */ + withSortPredicate(predicate: SortPredicate): void { + this._sortPredicate = predicate; + } + + /** Resets the strategy to its initial state before dragging was started. */ + reset(): void { + const root = this._element; + const previousSwap = this._previousSwap; + + // Moving elements around in the DOM can break things like the `@for` loop, because it + // uses comment nodes to know where to insert elements. To avoid such issues, we restore + // the DOM nodes in the list to their original order when the list is reset. + // Note that this could be simpler if we just saved all the nodes, cleared the root + // and then appended them in the original order. We don't do it, because it can break + // down depending on when the snapshot was taken. E.g. we may end up snapshotting the + // placeholder element which is removed after dragging. + for (let i = this._relatedNodes.length - 1; i > -1; i--) { + const [node, nextSibling] = this._relatedNodes[i]; + if (node.parentNode === root && node.nextSibling !== nextSibling) { + if (nextSibling === null) { + root.appendChild(node); + } else if (nextSibling.parentNode === root) { + root.insertBefore(node, nextSibling); + } + } + } + + this._relatedNodes = []; + this._activeItems = []; + previousSwap.drag = null; + previousSwap.deltaX = previousSwap.deltaY = 0; + previousSwap.overlaps = false; + } + + /** + * Gets a snapshot of items currently in the list. + * Can include items that we dragged in from another list. + */ + getActiveItemsSnapshot(): readonly DragRef[] { + return this._activeItems; + } + + /** Gets the index of a specific item. */ + getItemIndex(item: DragRef): number { + return this._activeItems.indexOf(item); + } + + /** Used to notify the strategy that the scroll position has changed. */ + updateOnScroll(): void { + this._activeItems.forEach(item => { + if (this._dragDropRegistry.isDragging(item)) { + // We need to re-sort the item manually, because the pointer move + // events won't be dispatched while the user is scrolling. + item._sortFromLastPointerPosition(); + } + }); + } + + /** + * Gets the index of an item in the drop container, based on the position of the user's pointer. + * @param item Item that is being sorted. + * @param pointerX Position of the user's pointer along the X axis. + * @param pointerY Position of the user's pointer along the Y axis. + * @param delta Direction in which the user is moving their pointer. + */ + private _getItemIndexFromPointerPosition( + item: DragRef, + pointerX: number, + pointerY: number, + ): number { + const elementAtPoint = this._getRootNode().elementFromPoint( + Math.floor(pointerX), + Math.floor(pointerY), + ); + const index = elementAtPoint + ? this._activeItems.findIndex(item => { + const root = item.getRootElement(); + return elementAtPoint === root || root.contains(elementAtPoint); + }) + : -1; + return index === -1 || !this._sortPredicate(index, item) ? -1 : index; + } + + /** Lazily resolves the list's root node. */ + private _getRootNode(): DocumentOrShadowRoot { + // Resolve the root node lazily to ensure that the drop list is in its final place in the DOM. + if (!this._rootNode) { + this._rootNode = _getShadowRoot(this._element) || this._document; + } + return this._rootNode; + } + + /** + * Finds the index of the item that's closest to the item being dragged. + * @param item Item being dragged. + * @param pointerX Position of the user's pointer along the X axis. + * @param pointerY Position of the user's pointer along the Y axis. + */ + private _getClosestItemIndexToPointer(item: DragRef, pointerX: number, pointerY: number): number { + if (this._activeItems.length === 0) { + return -1; + } + + if (this._activeItems.length === 1) { + return 0; + } + + let minDistance = Infinity; + let minIndex = -1; + + // Find the Euclidean distance (https://en.wikipedia.org/wiki/Euclidean_distance) between each + // item and the pointer, and return the smallest one. Note that this is a bit flawed in that DOM + // nodes are rectangles, not points, so we use the top/left coordinates. It should be enough + // for our purposes. + for (let i = 0; i < this._activeItems.length; i++) { + const current = this._activeItems[i]; + if (current !== item) { + const {x, y} = current.getRootElement().getBoundingClientRect(); + const distance = Math.hypot(pointerX - x, pointerY - y); + + if (distance < minDistance) { + minDistance = distance; + minIndex = i; + } + } + } + + return minIndex; + } +} diff --git a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts index f7177bbf5052..24cb859bf97c 100644 --- a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts @@ -7,17 +7,12 @@ */ import {Direction} from '@angular/cdk/bidi'; -import {ElementRef} from '@angular/core'; -import {coerceElement} from '@angular/cdk/coercion'; import {DragDropRegistry} from '../drag-drop-registry'; import {moveItemInArray} from '../drag-utils'; import {combineTransforms} from '../dom/styling'; import {adjustDomRect, getMutableClientRect, isInsideClientRect} from '../dom/dom-rect'; -import { - DropListSortStrategy, - DropListSortStrategyItem, - SortPredicate, -} from './drop-list-sort-strategy'; +import {DropListSortStrategy, SortPredicate} from './drop-list-sort-strategy'; +import type {DragRef} from '../drag-ref'; /** * Entry in the position cache for draggable items. @@ -39,21 +34,19 @@ interface CachedItemPosition { * Items are reordered using CSS transforms which allows for sorting to be animated. * @docs-private */ -export class SingleAxisSortStrategy - implements DropListSortStrategy -{ +export class SingleAxisSortStrategy implements DropListSortStrategy { /** Function used to determine if an item can be sorted into a specific index. */ - private _sortPredicate: SortPredicate; + private _sortPredicate: SortPredicate; /** Cache of the dimensions of all the items inside the container. */ - private _itemPositions: CachedItemPosition[] = []; + private _itemPositions: CachedItemPosition[] = []; /** * Draggable items that are currently active inside the container. Includes the items * that were there at the start of the sequence, as well as any items that have been dragged * in, but haven't been dropped yet. */ - private _activeDraggables: T[]; + private _activeDraggables: DragRef[]; /** Direction in which the list is oriented. */ orientation: 'vertical' | 'horizontal' = 'vertical'; @@ -62,8 +55,8 @@ export class SingleAxisSortStrategy direction: Direction; constructor( - private _element: HTMLElement | ElementRef, - private _dragDropRegistry: DragDropRegistry, + private _element: HTMLElement, + private _dragDropRegistry: DragDropRegistry, ) {} /** @@ -72,7 +65,7 @@ export class SingleAxisSortStrategy * overlap with the swapped item after the swapping occurred. */ private _previousSwap = { - drag: null as T | null, + drag: null as DragRef | null, delta: 0, overlaps: false, }; @@ -81,7 +74,7 @@ export class SingleAxisSortStrategy * To be called when the drag sequence starts. * @param items Items that are currently in the list. */ - start(items: readonly T[]) { + start(items: readonly DragRef[]) { this.withItems(items); } @@ -92,7 +85,7 @@ export class SingleAxisSortStrategy * @param pointerY Position of the item along the Y axis. * @param pointerDelta Direction in which the pointer is moving along each axis. */ - sort(item: T, pointerX: number, pointerY: number, pointerDelta: {x: number; y: number}) { + sort(item: DragRef, pointerX: number, pointerY: number, pointerDelta: {x: number; y: number}) { const siblings = this._itemPositions; const newIndex = this._getItemIndexFromPointerPosition(item, pointerX, pointerY, pointerDelta); @@ -172,7 +165,7 @@ export class SingleAxisSortStrategy * @param index Index at which the item entered. If omitted, the container will try to figure it * out automatically. */ - enter(item: T, pointerX: number, pointerY: number, index?: number): void { + enter(item: DragRef, pointerX: number, pointerY: number, index?: number): void { const newIndex = index == null || index < 0 ? // We use the coordinates of where the item entered the drop @@ -183,7 +176,7 @@ export class SingleAxisSortStrategy const activeDraggables = this._activeDraggables; const currentIndex = activeDraggables.indexOf(item); const placeholder = item.getPlaceholderElement(); - let newPositionReference: T | undefined = activeDraggables[newIndex]; + let newPositionReference: DragRef | undefined = activeDraggables[newIndex]; // If the item at the new position is the same as the item that is being dragged, // it means that we're trying to restore the item to its initial position. In this @@ -215,7 +208,7 @@ export class SingleAxisSortStrategy element.parentElement!.insertBefore(placeholder, element); activeDraggables.splice(newIndex, 0, item); } else { - coerceElement(this._element).appendChild(placeholder); + this._element.appendChild(placeholder); activeDraggables.push(item); } @@ -229,13 +222,13 @@ export class SingleAxisSortStrategy } /** Sets the items that are currently part of the list. */ - withItems(items: readonly T[]): void { + withItems(items: readonly DragRef[]): void { this._activeDraggables = items.slice(); this._cacheItemPositions(); } /** Assigns a sort predicate to the strategy. */ - withSortPredicate(predicate: SortPredicate): void { + withSortPredicate(predicate: SortPredicate): void { this._sortPredicate = predicate; } @@ -262,12 +255,12 @@ export class SingleAxisSortStrategy * Gets a snapshot of items currently in the list. * Can include items that we dragged in from another list. */ - getActiveItemsSnapshot(): readonly T[] { + getActiveItemsSnapshot(): readonly DragRef[] { return this._activeDraggables; } /** Gets the index of a specific item. */ - getItemIndex(item: T): number { + getItemIndex(item: DragRef): number { // Items are sorted always by top/left in the cache, however they flow differently in RTL. // The rest of the logic still stands no matter what orientation we're in, however // we need to invert the array when determining the index. @@ -351,7 +344,7 @@ export class SingleAxisSortStrategy */ private _getSiblingOffsetPx( currentIndex: number, - siblings: CachedItemPosition[], + siblings: CachedItemPosition[], delta: 1 | -1, ) { const isHorizontal = this.orientation === 'horizontal'; @@ -410,7 +403,7 @@ export class SingleAxisSortStrategy * @param delta Direction in which the user is moving their pointer. */ private _getItemIndexFromPointerPosition( - item: T, + item: DragRef, pointerX: number, pointerY: number, delta?: {x: number; y: number}, diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.css b/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.css new file mode 100644 index 000000000000..eec65dd76c5c --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.css @@ -0,0 +1,42 @@ +.example-list { + display: flex; + flex-wrap: wrap; + width: 505px; + max-width: 100%; + gap: 15px; + padding: 15px; + border: solid 1px #ccc; + min-height: 60px; + border-radius: 4px; + overflow: hidden; +} + +.example-box { + padding: 20px 10px; + border: solid 1px #ccc; + border-radius: 4px; + color: rgba(0, 0, 0, 0.87); + display: inline-block; + box-sizing: border-box; + cursor: move; + background: white; + text-align: center; + font-size: 14px; + min-width: 115px; +} + +.cdk-drag-preview { + box-sizing: border-box; + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.html b/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.html new file mode 100644 index 000000000000..e081bbe4b11b --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.html @@ -0,0 +1,5 @@ +
+ @for (item of items; track item) { +
{{item}}
+ } +
diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.ts b/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.ts new file mode 100644 index 000000000000..f2fd955665ad --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example.ts @@ -0,0 +1,20 @@ +import {Component} from '@angular/core'; +import {CdkDragDrop, CdkDrag, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop'; + +/** + * @title Drag&Drop horizontal wrapping list + */ +@Component({ + selector: 'cdk-drag-drop-mixed-sorting-example', + templateUrl: 'cdk-drag-drop-mixed-sorting-example.html', + styleUrl: 'cdk-drag-drop-mixed-sorting-example.css', + standalone: true, + imports: [CdkDropList, CdkDrag], +}) +export class CdkDragDropMixedSortingExample { + items = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine']; + + drop(event: CdkDragDrop) { + moveItemInArray(this.items, event.previousIndex, event.currentIndex); + } +} diff --git a/src/components-examples/cdk/drag-drop/index.ts b/src/components-examples/cdk/drag-drop/index.ts index 7c41155fbf58..712fe39ac20f 100644 --- a/src/components-examples/cdk/drag-drop/index.ts +++ b/src/components-examples/cdk/drag-drop/index.ts @@ -16,3 +16,4 @@ export {CdkDragDropRootElementExample} from './cdk-drag-drop-root-element/cdk-dr export {CdkDragDropSortingExample} from './cdk-drag-drop-sorting/cdk-drag-drop-sorting-example'; export {CdkDragDropSortPredicateExample} from './cdk-drag-drop-sort-predicate/cdk-drag-drop-sort-predicate-example'; export {CdkDragDropTableExample} from './cdk-drag-drop-table/cdk-drag-drop-table-example'; +export {CdkDragDropMixedSortingExample} from './cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example'; diff --git a/src/dev-app/drag-drop/BUILD.bazel b/src/dev-app/drag-drop/BUILD.bazel index 416ba4b592d8..d41ec8215880 100644 --- a/src/dev-app/drag-drop/BUILD.bazel +++ b/src/dev-app/drag-drop/BUILD.bazel @@ -11,6 +11,7 @@ ng_module( ], deps = [ "//src/cdk/drag-drop", + "//src/material/checkbox", "//src/material/form-field", "//src/material/icon", "//src/material/input", diff --git a/src/dev-app/drag-drop/drag-drop-demo.html b/src/dev-app/drag-drop/drag-drop-demo.html index 5c011e0a1042..104101ae2496 100644 --- a/src/dev-app/drag-drop/drag-drop-demo.html +++ b/src/dev-app/drag-drop/drag-drop-demo.html @@ -68,6 +68,52 @@

Preferred Ages

+

Mixed orientation

+ +

+ Wrap list +

+ +
+
+
+ @for (item of mixedTodo; track item) { +
+ {{item}} + +
+ } +
+
+ +
+
+ @for (item of mixedDone; track item) { +
+ {{item}} + +
+ } +
+
+
+

Free dragging

{ @@ -544,7 +544,7 @@ export class DropListRef { _stopScrolling(): void; withDirection(direction: Direction): this; withItems(items: DragRef[]): this; - withOrientation(orientation: 'vertical' | 'horizontal'): this; + withOrientation(orientation: DropListOrientation): this; withScrollableParents(elements: HTMLElement[]): this; }