From b92f97f55c8ed974f988897374622402fa3361c5 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 12 Feb 2021 22:25:32 +0100 Subject: [PATCH] feat(cdk/drag-drop): allow for preview container to be customized (#21830) Currently we always insert the drag preview at the `body`, because it allows us to avoid dealing with `overflow` and `z-index`. The problem is that it doesn't allow the preview to retain its inherited styles. These changes add a new input which allows the consumer to configure the place into which the preview will be inserted. Fixes #13288. --- src/cdk/drag-drop/directives/config.ts | 1 + src/cdk/drag-drop/directives/drag.spec.ts | 52 ++++++++++++++++- src/cdk/drag-drop/directives/drag.ts | 28 +++++++-- src/cdk/drag-drop/drag-drop.md | 13 +++++ src/cdk/drag-drop/drag-ref.ts | 69 ++++++++++++++++++----- src/cdk/drag-drop/public-api.ts | 2 +- src/dev-app/drag-drop/drag-drop-demo.scss | 2 +- tools/public_api_guard/cdk/drag-drop.d.ts | 7 ++- 8 files changed, 150 insertions(+), 24 deletions(-) diff --git a/src/cdk/drag-drop/directives/config.ts b/src/cdk/drag-drop/directives/config.ts index f66a2619a701..514ebf23e15a 100644 --- a/src/cdk/drag-drop/directives/config.ts +++ b/src/cdk/drag-drop/directives/config.ts @@ -43,4 +43,5 @@ export interface DragDropConfig extends Partial { listAutoScrollDisabled?: boolean; listOrientation?: DropListOrientation; zIndex?: number; + previewContainer?: 'global' | 'parent'; } diff --git a/src/cdk/drag-drop/directives/drag.spec.ts b/src/cdk/drag-drop/directives/drag.spec.ts index 5acf46d0af8a..085c6d77fb84 100644 --- a/src/cdk/drag-drop/directives/drag.spec.ts +++ b/src/cdk/drag-drop/directives/drag.spec.ts @@ -29,7 +29,7 @@ import {of as observableOf} from 'rxjs'; import {DragDropModule} from '../drag-drop-module'; import {CdkDragDrop, CdkDragEnter, CdkDragStart} from '../drag-events'; -import {Point, DragRef} from '../drag-ref'; +import {Point, DragRef, PreviewContainer} from '../drag-ref'; import {extendStyles} from '../drag-styling'; import {moveItemInArray} from '../drag-utils'; @@ -1235,7 +1235,8 @@ describe('CdkDrag', () => { constrainPosition: () => ({x: 1337, y: 42}), previewClass: 'custom-preview-class', boundaryElement: '.boundary', - rootElementSelector: '.root' + rootElementSelector: '.root', + previewContainer: 'parent' }; const fixture = createComponent(PlainStandaloneDraggable, [{ @@ -1251,6 +1252,7 @@ describe('CdkDrag', () => { expect(drag.previewClass).toBe('custom-preview-class'); expect(drag.boundaryElement).toBe('.boundary'); expect(drag.rootElementSelector).toBe('.root'); + expect(drag.previewContainer).toBe('parent'); })); it('should not throw if touches and changedTouches are empty', fakeAsync(() => { @@ -2580,6 +2582,47 @@ describe('CdkDrag', () => { expect(placeholder.parentNode).toBeFalsy('Expected placeholder to be removed from the DOM'); })); + it('should insert the preview into the `body` if previewContainer is set to `global`', + fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.componentInstance.previewContainer = 'global'; + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + + startDraggingViaMouse(fixture, item); + const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement; + expect(preview.parentNode).toBe(document.body); + })); + + it('should insert the preview into the parent node if previewContainer is set to `parent`', + fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.componentInstance.previewContainer = 'parent'; + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + const list = fixture.nativeElement.querySelector('.drop-list'); + + startDraggingViaMouse(fixture, item); + const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement; + expect(list).toBeTruthy(); + expect(preview.parentNode).toBe(list); + })); + + it('should insert the preview into a particular element, if specified', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; + const previewContainer = fixture.componentInstance.alternatePreviewContainer; + + expect(previewContainer).toBeTruthy(); + fixture.componentInstance.previewContainer = previewContainer; + fixture.detectChanges(); + + startDraggingViaMouse(fixture, item); + const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement; + expect(preview.parentNode).toBe(previewContainer.nativeElement); + })); + it('should remove the id from the placeholder', fakeAsync(() => { const fixture = createComponent(DraggableInDropZone); fixture.detectChanges(); @@ -5789,17 +5832,21 @@ const DROP_ZONE_FIXTURE_TEMPLATE = ` [cdkDragData]="item" [cdkDragBoundary]="boundarySelector" [cdkDragPreviewClass]="previewClass" + [cdkDragPreviewContainer]="previewContainer" [style.height.px]="item.height" [style.margin-bottom.px]="item.margin" (cdkDragStarted)="startedSpy($event)" style="width: 100%; background: red;">{{item.value}} + +
`; @Component({template: DROP_ZONE_FIXTURE_TEMPLATE}) class DraggableInDropZone implements AfterViewInit { @ViewChildren(CdkDrag) dragItems: QueryList; @ViewChild(CdkDropList) dropInstance: CdkDropList; + @ViewChild('alternatePreviewContainer') alternatePreviewContainer: ElementRef; items = [ {value: 'Zero', height: ITEM_HEIGHT, margin: 0}, {value: 'One', height: ITEM_HEIGHT, margin: 0}, @@ -5814,6 +5861,7 @@ class DraggableInDropZone implements AfterViewInit { moveItemInArray(this.items, event.previousIndex, event.currentIndex); }); startedSpy = jasmine.createSpy('started spy'); + previewContainer: PreviewContainer = 'global'; constructor(protected _elementRef: ElementRef) {} diff --git a/src/cdk/drag-drop/directives/drag.ts b/src/cdk/drag-drop/directives/drag.ts index 6893cd85d768..588e4da21513 100644 --- a/src/cdk/drag-drop/directives/drag.ts +++ b/src/cdk/drag-drop/directives/drag.ts @@ -50,7 +50,7 @@ import {CDK_DRAG_HANDLE, CdkDragHandle} from './drag-handle'; import {CDK_DRAG_PLACEHOLDER, CdkDragPlaceholder} from './drag-placeholder'; import {CDK_DRAG_PREVIEW, CdkDragPreview} from './drag-preview'; import {CDK_DRAG_PARENT} from '../drag-parent'; -import {DragRef, Point} from '../drag-ref'; +import {DragRef, Point, PreviewContainer} from '../drag-ref'; import {CDK_DROP_LIST, CdkDropListInternal as CdkDropList} from './drop-list'; import {DragDrop} from '../drag-drop'; import {CDK_DRAG_CONFIG, DragDropConfig, DragStartDelay, DragAxis} from './config'; @@ -140,6 +140,21 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { /** Class to be added to the preview element. */ @Input('cdkDragPreviewClass') previewClass: string | string[]; + /** + * Configures the place into which the preview of the item will be inserted. Can be configured + * globally through `CDK_DROP_LIST`. Possible values: + * - `global` - Preview will be inserted at the bottom of the ``. The advantage is that + * you don't have to worry about `overflow: hidden` or `z-index`, but the item won't retain + * its inherited styles. + * - `parent` - Preview will be inserted into the parent of the drag item. The advantage is that + * inherited styles will be preserved, but it may be clipped by `overflow: hidden` or not be + * visible due to `z-index`. Furthermore, the preview is going to have an effect over selectors + * like `:nth-child` and some flexbox configurations. + * - `ElementRef | HTMLElement` - Preview will be inserted into a specific element. + * Same advantages and disadvantages as `parent`. + */ + @Input('cdkDragPreviewContainer') previewContainer: PreviewContainer; + /** Emits when the user starts dragging the item. */ @Output('cdkDragStarted') started: EventEmitter = new EventEmitter(); @@ -396,7 +411,8 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { ref .withBoundaryElement(this._getBoundaryElement()) .withPlaceholderTemplate(placeholder) - .withPreviewTemplate(preview); + .withPreviewTemplate(preview) + .withPreviewContainer(this.previewContainer || 'global'); if (dir) { ref.withDirection(dir.value); @@ -481,8 +497,8 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { /** Assigns the default input values based on a provided config object. */ private _assignDefaults(config: DragDropConfig) { const { - lockAxis, dragStartDelay, constrainPosition, previewClass, - boundaryElement, draggingDisabled, rootElementSelector + lockAxis, dragStartDelay, constrainPosition, previewClass, boundaryElement, draggingDisabled, + rootElementSelector, previewContainer } = config; this.disabled = draggingDisabled == null ? false : draggingDisabled; @@ -507,6 +523,10 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { if (rootElementSelector) { this.rootElementSelector = rootElementSelector; } + + if (previewContainer) { + this.previewContainer = previewContainer; + } } static ngAcceptInputType_disabled: BooleanInput; diff --git a/src/cdk/drag-drop/drag-drop.md b/src/cdk/drag-drop/drag-drop.md index 96b16bdc06a8..6b3829a97332 100644 --- a/src/cdk/drag-drop/drag-drop.md +++ b/src/cdk/drag-drop/drag-drop.md @@ -128,6 +128,19 @@ to be applied. +### Drag preview insertion point +By default, the preview of a `cdkDrag` will be inserted into the `` of the page in order to +avoid issues with `z-index` and `overflow: hidden`. This may not be desireable in some cases, +because the preview won't retain its inherited styles. You can control where the preview is inserted +using the `cdkDrawPreviewContainer` input. The possible values are: + +| Value | Description | Advantages | Disadvantages | +|-------------------|-------------------------|------------------------|---------------------------| +| `global` | Default value. Preview is inserted into the `` or the closest shadow root. | Preview won't be affected by `z-index` or `overflow: hidden`. It also won't affect `:nth-child` selectors and flex layouts. | Doesn't retain inherited styles. +| `parent` | Preview is inserted inside the parent of the item that is being dragged. | Preview inherits the same styles as the dragged item. | Preview may be clipped by `overflow: hidden` or be placed under other elements due to `z-index`. Furthermore, it can affect `:nth-child` selectors and some flex layouts. +| `ElementRef` or `HTMLElement` | Preview will be inserted into the specified element. | Preview inherits styles from the specified container element. | Preview may be clipped by `overflow: hidden` or be placed under other elements due to `z-index`. Furthermore, it can affect `:nth-child` selectors and some flex layouts. + + ### Customizing the drag placeholder While a `cdkDrag` element is being dragged, the CDK will create a placeholder element that will show where it will be placed when it's dropped. By default the placeholder is a clone of the element diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index 885801887879..fb784b2bff3f 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -83,6 +83,20 @@ export interface Point { y: number; } +/** + * Possible places into which the preview of a drag item can be inserted. + * - `global` - Preview will be inserted at the bottom of the ``. The advantage is that + * you don't have to worry about `overflow: hidden` or `z-index`, but the item won't retain + * its inherited styles. + * - `parent` - Preview will be inserted into the parent of the drag item. The advantage is that + * inherited styles will be preserved, but it may be clipped by `overflow: hidden` or not be + * visible due to `z-index`. Furthermore, the preview is going to have an effect over selectors + * like `:nth-child` and some flexbox configurations. + * - `ElementRef | HTMLElement` - Preview will be inserted into a specific element. + * Same advantages and disadvantages as `parent`. + */ +export type PreviewContainer = 'global' | 'parent' | ElementRef | HTMLElement; + /** * Reference to a draggable item. Used to manipulate or dispose of the item. */ @@ -93,6 +107,9 @@ export class DragRef { /** Reference to the view of the preview element. */ private _previewRef: EmbeddedViewRef | null; + /** Container into which to insert the preview. */ + private _previewContainer: PreviewContainer | undefined; + /** Reference to the view of the placeholder element. */ private _placeholderRef: EmbeddedViewRef | null; @@ -542,6 +559,15 @@ export class DragRef { return this; } + /** + * Sets the container into which to insert the preview element. + * @param value Container into which to insert the preview. + */ + withPreviewContainer(value: PreviewContainer): this { + this._previewContainer = value; + return this; + } + /** Updates the item's sort order based on the last-known pointer position. */ _sortFromLastPointerPosition() { const position = this._lastKnownPointerPosition; @@ -762,7 +788,7 @@ export class DragRef { if (dropContainer) { const element = this._rootElement; - const parent = element.parentNode!; + const parent = element.parentNode as HTMLElement; const preview = this._preview = this._createPreviewElement(); const placeholder = this._placeholder = this._createPlaceholderElement(); const anchor = this._anchor = this._anchor || this._document.createComment(''); @@ -778,7 +804,7 @@ export class DragRef { // from the DOM completely, because iOS will stop firing all subsequent events in the chain. toggleVisibility(element, false); this._document.body.appendChild(parent.replaceChild(placeholder, element)); - getPreviewInsertionPoint(this._document, shadowRoot).appendChild(preview); + this._getPreviewInsertionPoint(parent, shadowRoot).appendChild(preview); this.started.next({source: this}); // Emit before notifying the container. dropContainer.start(); this._initialContainer = dropContainer; @@ -1361,6 +1387,32 @@ export class DragRef { return this._cachedShadowRoot; } + + /** Gets the element into which the drag preview should be inserted. */ + private _getPreviewInsertionPoint(initialParent: HTMLElement, + shadowRoot: ShadowRoot | null): HTMLElement { + const previewContainer = this._previewContainer || 'global'; + + if (previewContainer === 'parent') { + return initialParent; + } + + if (previewContainer === 'global') { + const documentRef = this._document; + + // We can't use the body if the user is in fullscreen mode, + // because the preview will render under the fullscreen element. + // TODO(crisbeto): dedupe this with the `FullscreenOverlayContainer` eventually. + return shadowRoot || + documentRef.fullscreenElement || + (documentRef as any).webkitFullscreenElement || + (documentRef as any).mozFullScreenElement || + (documentRef as any).msFullscreenElement || + documentRef.body; + } + + return coerceElement(previewContainer); + } } /** @@ -1397,19 +1449,6 @@ function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { return event.type[0] === 't'; } -/** Gets the element into which the drag preview should be inserted. */ -function getPreviewInsertionPoint(documentRef: any, shadowRoot: ShadowRoot | null): HTMLElement { - // We can't use the body if the user is in fullscreen mode, - // because the preview will render under the fullscreen element. - // TODO(crisbeto): dedupe this with the `FullscreenOverlayContainer` eventually. - return shadowRoot || - documentRef.fullscreenElement || - documentRef.webkitFullscreenElement || - documentRef.mozFullScreenElement || - documentRef.msFullscreenElement || - documentRef.body; -} - /** * Gets the root HTML element of an embedded view. * If the root is not an HTML element it gets wrapped in one. diff --git a/src/cdk/drag-drop/public-api.ts b/src/cdk/drag-drop/public-api.ts index 5c6fa34bf65a..431ab3ec4a74 100644 --- a/src/cdk/drag-drop/public-api.ts +++ b/src/cdk/drag-drop/public-api.ts @@ -7,7 +7,7 @@ */ export {DragDrop} from './drag-drop'; -export {DragRef, DragRefConfig, Point} from './drag-ref'; +export {DragRef, DragRefConfig, Point, PreviewContainer} from './drag-ref'; export {DropListRef} from './drop-list-ref'; export {CDK_DRAG_PARENT} from './drag-parent'; diff --git a/src/dev-app/drag-drop/drag-drop-demo.scss b/src/dev-app/drag-drop/drag-drop-demo.scss index ce97633fa757..db92087975c0 100644 --- a/src/dev-app/drag-drop/drag-drop-demo.scss +++ b/src/dev-app/drag-drop/drag-drop-demo.scss @@ -43,7 +43,7 @@ justify-content: space-between; box-sizing: border-box; - .cdk-drop-list-dragging &:not(.cdk-drag-placeholder) { + .cdk-drop-list-dragging &:not(.cdk-drag-placeholder):not(.cdk-drag-preview) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } diff --git a/tools/public_api_guard/cdk/drag-drop.d.ts b/tools/public_api_guard/cdk/drag-drop.d.ts index cfc4447935d8..b12acbaee7a3 100644 --- a/tools/public_api_guard/cdk/drag-drop.d.ts +++ b/tools/public_api_guard/cdk/drag-drop.d.ts @@ -36,6 +36,7 @@ export declare class CdkDrag implements AfterViewInit, OnChanges, OnDes lockAxis: DragAxis; moved: Observable>; previewClass: string | string[]; + previewContainer: PreviewContainer; released: EventEmitter; rootElementSelector: string; started: EventEmitter; @@ -54,7 +55,7 @@ export declare class CdkDrag implements AfterViewInit, OnChanges, OnDes ngOnDestroy(): void; reset(): void; static ngAcceptInputType_disabled: BooleanInput; - static ɵdir: i0.ɵɵDirectiveDefWithMeta, "[cdkDrag]", ["cdkDrag"], { "data": "cdkDragData"; "lockAxis": "cdkDragLockAxis"; "rootElementSelector": "cdkDragRootElement"; "boundaryElement": "cdkDragBoundary"; "dragStartDelay": "cdkDragStartDelay"; "freeDragPosition": "cdkDragFreeDragPosition"; "disabled": "cdkDragDisabled"; "constrainPosition": "cdkDragConstrainPosition"; "previewClass": "cdkDragPreviewClass"; }, { "started": "cdkDragStarted"; "released": "cdkDragReleased"; "ended": "cdkDragEnded"; "entered": "cdkDragEntered"; "exited": "cdkDragExited"; "dropped": "cdkDragDropped"; "moved": "cdkDragMoved"; }, ["_previewTemplate", "_placeholderTemplate", "_handles"]>; + static ɵdir: i0.ɵɵDirectiveDefWithMeta, "[cdkDrag]", ["cdkDrag"], { "data": "cdkDragData"; "lockAxis": "cdkDragLockAxis"; "rootElementSelector": "cdkDragRootElement"; "boundaryElement": "cdkDragBoundary"; "dragStartDelay": "cdkDragStartDelay"; "freeDragPosition": "cdkDragFreeDragPosition"; "disabled": "cdkDragDisabled"; "constrainPosition": "cdkDragConstrainPosition"; "previewClass": "cdkDragPreviewClass"; "previewContainer": "cdkDragPreviewContainer"; }, { "started": "cdkDragStarted"; "released": "cdkDragReleased"; "ended": "cdkDragEnded"; "entered": "cdkDragEntered"; "exited": "cdkDragExited"; "dropped": "cdkDragDropped"; "moved": "cdkDragMoved"; }, ["_previewTemplate", "_placeholderTemplate", "_handles"]>; static ɵfac: i0.ɵɵFactoryDef, [null, { optional: true; skipSelf: true; }, null, null, null, { optional: true; }, { optional: true; }, null, null, { optional: true; self: true; }, { optional: true; skipSelf: true; }]>; } @@ -220,6 +221,7 @@ export interface DragDropConfig extends Partial { listOrientation?: DropListOrientation; lockAxis?: DragAxis; previewClass?: string | string[]; + previewContainer?: 'global' | 'parent'; rootElementSelector?: string; sortingDisabled?: boolean; zIndex?: number; @@ -320,6 +322,7 @@ export declare class DragRef { withHandles(handles: (HTMLElement | ElementRef)[]): this; withParent(parent: DragRef | null): this; withPlaceholderTemplate(template: DragHelperTemplate | null): this; + withPreviewContainer(value: PreviewContainer): this; withPreviewTemplate(template: DragPreviewTemplate | null): this; withRootElement(rootElement: ElementRef | HTMLElement): this; } @@ -408,4 +411,6 @@ export interface Point { y: number; } +export declare type PreviewContainer = 'global' | 'parent' | ElementRef | HTMLElement; + export declare function transferArrayItem(currentArray: T[], targetArray: T[], currentIndex: number, targetIndex: number): void;