From 78144688e14c39917fa520e9f815876037c73102 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Wed, 12 Sep 2018 19:25:25 +0200 Subject: [PATCH] feat(overlay): add support for swappable position strategies Adds the ability for the consumer to swap out one position strategy for another, even while an overlay is open. This allows us to handle cases like having a menu that is a dropdown on desktop, but becomes a full-screen overlay on a mobile device. --- src/cdk/overlay/overlay-ref.ts | 39 ++++++++--- src/cdk/overlay/overlay.spec.ts | 64 +++++++++++++++++++ ...exible-connected-position-strategy.spec.ts | 40 ++++++++++++ .../flexible-connected-position-strategy.ts | 45 +++++++++++-- .../position/global-position-strategy.spec.ts | 21 ++++++ .../position/global-position-strategy.ts | 29 +++++++-- 6 files changed, 218 insertions(+), 20 deletions(-) diff --git a/src/cdk/overlay/overlay-ref.ts b/src/cdk/overlay/overlay-ref.ts index e0bfeceb593d..db001d4ab79a 100644 --- a/src/cdk/overlay/overlay-ref.ts +++ b/src/cdk/overlay/overlay-ref.ts @@ -15,6 +15,7 @@ import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher' import {OverlayConfig} from './overlay-config'; import {coerceCssPixelValue, coerceArray} from '@angular/cdk/coercion'; import {OverlayReference} from './overlay-reference'; +import {PositionStrategy} from './position/position-strategy'; /** An object where all of its properties cannot be written. */ @@ -31,12 +32,14 @@ export class OverlayRef implements PortalOutlet, OverlayReference { private _backdropClick: Subject = new Subject(); private _attachments = new Subject(); private _detachments = new Subject(); + private _positionStrategy: PositionStrategy | undefined; /** * Reference to the parent of the `_host` at the time it was detached. Used to restore * the `_host` to its original position in the DOM when it gets re-attached. */ private _previousHostParent: HTMLElement; + private _keydownEventsObservable: Observable = Observable.create(observer => { const subscription = this._keydownEvents.subscribe(observer); this._keydownEventSubscriptions++; @@ -65,6 +68,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference { if (_config.scrollStrategy) { _config.scrollStrategy.attach(this); } + + this._positionStrategy = _config.positionStrategy; } /** The overlay's HTML element */ @@ -100,8 +105,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference { attach(portal: Portal): any { let attachResult = this._portalOutlet.attach(portal); - if (this._config.positionStrategy) { - this._config.positionStrategy.attach(this); + if (this._positionStrategy) { + this._positionStrategy.attach(this); } // Update the pane element with the given configuration. @@ -166,8 +171,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference { // pointer events therefore. Depends on the position strategy and the applied pane boundaries. this._togglePointerEvents(false); - if (this._config.positionStrategy && this._config.positionStrategy.detach) { - this._config.positionStrategy.detach(); + if (this._positionStrategy && this._positionStrategy.detach) { + this._positionStrategy.detach(); } if (this._config.scrollStrategy) { @@ -213,8 +218,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference { dispose(): void { const isAttached = this.hasAttached(); - if (this._config.positionStrategy) { - this._config.positionStrategy.dispose(); + if (this._positionStrategy) { + this._positionStrategy.dispose(); } if (this._config.scrollStrategy) { @@ -274,8 +279,26 @@ export class OverlayRef implements PortalOutlet, OverlayReference { /** Updates the position of the overlay based on the position strategy. */ updatePosition() { - if (this._config.positionStrategy) { - this._config.positionStrategy.apply(); + if (this._positionStrategy) { + this._positionStrategy.apply(); + } + } + + /** Switches to a new position strategy and updates the overlay position. */ + updatePositionStrategy(strategy: PositionStrategy) { + if (strategy === this._positionStrategy) { + return; + } + + if (this._positionStrategy) { + this._positionStrategy.dispose(); + } + + this._positionStrategy = strategy; + + if (this.hasAttached()) { + strategy.attach(this); + this.updatePosition(); } } diff --git a/src/cdk/overlay/overlay.spec.ts b/src/cdk/overlay/overlay.spec.ts index 21a400bc2c14..0e42e4cd861b 100644 --- a/src/cdk/overlay/overlay.spec.ts +++ b/src/cdk/overlay/overlay.spec.ts @@ -411,6 +411,70 @@ describe('Overlay', () => { expect(config.positionStrategy.apply).not.toHaveBeenCalled(); })); + it('should be able to swap position strategies', fakeAsync(() => { + const firstStrategy = new FakePositionStrategy(); + const secondStrategy = new FakePositionStrategy(); + + [firstStrategy, secondStrategy].forEach(strategy => { + spyOn(strategy, 'attach'); + spyOn(strategy, 'apply'); + spyOn(strategy, 'dispose'); + }); + + config.positionStrategy = firstStrategy; + + const overlayRef = overlay.create(config); + overlayRef.attach(componentPortal); + viewContainerFixture.detectChanges(); + zone.simulateZoneExit(); + tick(); + + expect(firstStrategy.attach).toHaveBeenCalledTimes(1); + expect(firstStrategy.apply).toHaveBeenCalledTimes(1); + + expect(secondStrategy.attach).not.toHaveBeenCalled(); + expect(secondStrategy.apply).not.toHaveBeenCalled(); + + overlayRef.updatePositionStrategy(secondStrategy); + viewContainerFixture.detectChanges(); + tick(); + + expect(firstStrategy.attach).toHaveBeenCalledTimes(1); + expect(firstStrategy.apply).toHaveBeenCalledTimes(1); + expect(firstStrategy.dispose).toHaveBeenCalledTimes(1); + + expect(secondStrategy.attach).toHaveBeenCalledTimes(1); + expect(secondStrategy.apply).toHaveBeenCalledTimes(1); + })); + + it('should not do anything when trying to swap a strategy with itself', fakeAsync(() => { + const strategy = new FakePositionStrategy(); + + spyOn(strategy, 'attach'); + spyOn(strategy, 'apply'); + spyOn(strategy, 'dispose'); + + config.positionStrategy = strategy; + + const overlayRef = overlay.create(config); + overlayRef.attach(componentPortal); + viewContainerFixture.detectChanges(); + zone.simulateZoneExit(); + tick(); + + expect(strategy.attach).toHaveBeenCalledTimes(1); + expect(strategy.apply).toHaveBeenCalledTimes(1); + expect(strategy.dispose).not.toHaveBeenCalled(); + + overlayRef.updatePositionStrategy(strategy); + viewContainerFixture.detectChanges(); + tick(); + + expect(strategy.attach).toHaveBeenCalledTimes(1); + expect(strategy.apply).toHaveBeenCalledTimes(1); + expect(strategy.dispose).not.toHaveBeenCalled(); + })); + }); describe('size', () => { diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts index 7992d8a53be6..fa776e1a5a94 100644 --- a/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts @@ -151,6 +151,46 @@ describe('FlexibleConnectedPositionStrategy', () => { document.body.removeChild(originElement); }); + it('should clean up after itself when disposed', () => { + const origin = document.createElement('div'); + const positionStrategy = overlay.position() + .flexibleConnectedTo(origin) + .withPositions([{ + overlayX: 'start', + overlayY: 'top', + originX: 'start', + originY: 'bottom' + }]); + + // Needs to be in the DOM for IE not to throw an "Unspecified error". + document.body.appendChild(origin); + attachOverlay({positionStrategy}); + + const boundingBox = overlayRef.hostElement; + const pane = overlayRef.overlayElement; + + positionStrategy.dispose(); + + expect(boundingBox.style.top).toBeFalsy(); + expect(boundingBox.style.bottom).toBeFalsy(); + expect(boundingBox.style.left).toBeFalsy(); + expect(boundingBox.style.right).toBeFalsy(); + expect(boundingBox.style.width).toBeFalsy(); + expect(boundingBox.style.height).toBeFalsy(); + expect(boundingBox.style.alignItems).toBeFalsy(); + expect(boundingBox.style.justifyContent).toBeFalsy(); + expect(boundingBox.classList).not.toContain('cdk-overlay-connected-position-bounding-box'); + + expect(pane.style.top).toBeFalsy(); + expect(pane.style.bottom).toBeFalsy(); + expect(pane.style.left).toBeFalsy(); + expect(pane.style.right).toBeFalsy(); + expect(pane.style.position).toBeFalsy(); + + overlayRef.dispose(); + document.body.removeChild(origin); + }); + describe('without flexible dimensions and pushing', () => { const ORIGIN_HEIGHT = DEFAULT_HEIGHT; const ORIGIN_WIDTH = DEFAULT_WIDTH; diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.ts index 41b2b8f3060f..d8c38365ef85 100644 --- a/src/cdk/overlay/position/flexible-connected-position-strategy.ts +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.ts @@ -26,6 +26,9 @@ import {OverlayContainer} from '../overlay-container'; // TODO: refactor clipping detection into a separate thing (part of scrolling module) // TODO: doesn't handle both flexible width and height when it has to scroll along both axis. +/** Class to be added to the overlay bounding box. */ +const boundingBoxClass = 'cdk-overlay-connected-position-bounding-box'; + /** * A strategy for positioning overlays. Using this strategy, an overlay is given an * implicit position relative some origin element. The relative position is defined in terms of @@ -38,7 +41,7 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { private _overlayRef: OverlayReference; /** Whether we're performing the very first positioning of the overlay. */ - private _isInitialRender = true; + private _isInitialRender: boolean; /** Last size used for the bounding box. Used to avoid resizing the overlay after open. */ private _lastBoundingBoxSize = {width: 0, height: 0}; @@ -152,11 +155,14 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { this._validatePositions(); - overlayRef.hostElement.classList.add('cdk-overlay-connected-position-bounding-box'); + overlayRef.hostElement.classList.add(boundingBoxClass); this._overlayRef = overlayRef; this._boundingBox = overlayRef.hostElement; this._pane = overlayRef.overlayElement; + this._isDisposed = false; + this._isInitialRender = true; + this._lastPosition = null; this._resizeSubscription.unsubscribe(); this._resizeSubscription = this._viewportRuler.change().subscribe(() => { // When the window is resized, we want to trigger the next reposition as if it @@ -303,12 +309,37 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { /** Cleanup after the element gets destroyed. */ dispose() { - if (!this._isDisposed) { - this.detach(); - this._boundingBox = null; - this._positionChanges.complete(); - this._isDisposed = true; + if (this._isDisposed) { + return; } + + // We can't use `_resetBoundingBoxStyles` here, because it resets + // some properties to zero, rather than removing them. + if (this._boundingBox) { + extendStyles(this._boundingBox.style, { + top: '', + left: '', + right: '', + bottom: '', + height: '', + width: '', + alignItems: '', + justifyContent: '', + } as CSSStyleDeclaration); + } + + if (this._pane) { + this._resetOverlayElementStyles(); + } + + if (this._overlayRef) { + this._overlayRef.hostElement.classList.remove(boundingBoxClass); + } + + this.detach(); + this._positionChanges.complete(); + this._overlayRef = this._boundingBox = null!; + this._isDisposed = true; } /** diff --git a/src/cdk/overlay/position/global-position-strategy.spec.ts b/src/cdk/overlay/position/global-position-strategy.spec.ts index 00941834c04f..bc5cdd74ef2e 100644 --- a/src/cdk/overlay/position/global-position-strategy.spec.ts +++ b/src/cdk/overlay/position/global-position-strategy.spec.ts @@ -303,6 +303,27 @@ describe('GlobalPositonStrategy', () => { const parentStyle = (overlayRef.overlayElement.parentNode as HTMLElement).style; expect(parentStyle.justifyContent).toBe('flex-start'); }); + + it('should clean up after itself when it has been disposed', () => { + const positionStrategy = overlay.position().global().top('10px').left('40px'); + + attachOverlay({positionStrategy}); + + const elementStyle = overlayRef.overlayElement.style; + const parentStyle = (overlayRef.overlayElement.parentNode as HTMLElement).style; + + positionStrategy.dispose(); + + expect(elementStyle.marginTop).toBeFalsy(); + expect(elementStyle.marginLeft).toBeFalsy(); + expect(elementStyle.marginBottom).toBeFalsy(); + expect(elementStyle.marginBottom).toBeFalsy(); + expect(elementStyle.position).toBeFalsy(); + + expect(parentStyle.justifyContent).toBeFalsy(); + expect(parentStyle.alignItems).toBeFalsy(); + }); + }); diff --git a/src/cdk/overlay/position/global-position-strategy.ts b/src/cdk/overlay/position/global-position-strategy.ts index 1787e5832355..074c7c2fcccf 100644 --- a/src/cdk/overlay/position/global-position-strategy.ts +++ b/src/cdk/overlay/position/global-position-strategy.ts @@ -9,6 +9,8 @@ import {PositionStrategy} from './position-strategy'; import {OverlayReference} from '../overlay-reference'; +/** Class to be added to the overlay pane wrapper. */ +const wrapperClass = 'cdk-global-overlay-wrapper'; /** * A strategy for positioning overlays. Using this strategy, an overlay is given an @@ -28,6 +30,7 @@ export class GlobalPositionStrategy implements PositionStrategy { private _justifyContent: string = ''; private _width: string = ''; private _height: string = ''; + private _isDisposed: boolean; attach(overlayRef: OverlayReference): void { const config = overlayRef.getConfig(); @@ -42,7 +45,8 @@ export class GlobalPositionStrategy implements PositionStrategy { overlayRef.updateSize({height: this._height}); } - overlayRef.hostElement.classList.add('cdk-global-overlay-wrapper'); + overlayRef.hostElement.classList.add(wrapperClass); + this._isDisposed = false; } /** @@ -153,7 +157,7 @@ export class GlobalPositionStrategy implements PositionStrategy { // Since the overlay ref applies the strategy asynchronously, it could // have been disposed before it ends up being applied. If that is the // case, we shouldn't do anything. - if (!this._overlayRef.hasAttached()) { + if (!this._overlayRef || !this._overlayRef.hasAttached()) { return; } @@ -170,7 +174,7 @@ export class GlobalPositionStrategy implements PositionStrategy { if (config.width === '100%') { parentStyles.justifyContent = 'flex-start'; } else if (this._justifyContent === 'center') { - parentStyles.justifyContent = 'center'; + parentStyles.justifyContent = 'center'; } else if (this._overlayRef.getConfig().direction === 'rtl') { // In RTL the browser will invert `flex-start` and `flex-end` automatically, but we // don't want that because our positioning is explicitly `left` and `right`, hence @@ -189,8 +193,23 @@ export class GlobalPositionStrategy implements PositionStrategy { } /** - * Noop implemented as a part of the PositionStrategy interface. + * Cleans up the DOM changes from the position strategy. * @docs-private */ - dispose(): void { } + dispose(): void { + if (this._isDisposed || !this._overlayRef) { + return; + } + + const styles = this._overlayRef.overlayElement.style; + const parent = this._overlayRef.hostElement; + const parentStyles = parent.style; + + parent.classList.remove(wrapperClass); + parentStyles.justifyContent = parentStyles.alignItems = styles.marginTop = + styles.marginBottom = styles.marginLeft = styles.marginRight = styles.position = ''; + + this._overlayRef = null!; + this._isDisposed = true; + } }