diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 67c5b5a2557d..86b5e736c165 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -100,6 +100,7 @@ /src/demo-app/card/** @jelbourn /src/demo-app/checkbox/** @tinayuangao @devversion /src/demo-app/chips/** @tinayuangao +/src/demo-app/connected-overlay/** @jelbourn @crisbeto /src/demo-app/dataset/** @andrewseguin /src/demo-app/datepicker/** @mmalerba /src/demo-app/demo-app/** @jelbourn diff --git a/src/cdk/overlay/_overlay.scss b/src/cdk/overlay/_overlay.scss index 332e7256f478..d7f7edcc1df0 100644 --- a/src/cdk/overlay/_overlay.scss +++ b/src/cdk/overlay/_overlay.scss @@ -52,9 +52,16 @@ $backdrop-animation-timing-function: cubic-bezier(0.25, 0.8, 0.25, 1) !default; // A single overlay pane. .cdk-overlay-pane { position: absolute; + pointer-events: auto; box-sizing: border-box; z-index: $cdk-z-index-overlay; + + // For connected-position overlays, we set `display: flex` in + // order to force `max-width` and `max-height` to take effect. + display: flex; + max-width: 100%; + max-height: 100%; } .cdk-overlay-backdrop { @@ -96,6 +103,24 @@ $backdrop-animation-timing-function: cubic-bezier(0.25, 0.8, 0.25, 1) !default; } } + // Overlay parent element used with the connected position strategy. Used to constrain the + // overlay element's size to fit within the viewport. + .cdk-overlay-connected-position-bounding-box { + position: absolute; + z-index: $cdk-z-index-overlay; + + // We use `display: flex` on this element exclusively for centering connected overlays. + // When *not* centering, a top/left/bottom/right will be set which overrides the normal + // flex layout. + display: flex; + justify-content: center; + align-items: center; + + // Add some dimensions so the element has an `innerText` which some people depend on in tests. + min-width: 1px; + min-height: 1px; + } + // Used when disabling global scrolling. .cdk-global-scrollblock { position: fixed; diff --git a/src/cdk/overlay/overlay-directives.spec.ts b/src/cdk/overlay/overlay-directives.spec.ts index 56dc3b1e3c82..b5c9517a99fe 100644 --- a/src/cdk/overlay/overlay-directives.spec.ts +++ b/src/cdk/overlay/overlay-directives.spec.ts @@ -6,11 +6,11 @@ import {dispatchKeyboardEvent} from '@angular/cdk/testing'; import {ESCAPE} from '@angular/cdk/keycodes'; import {CdkConnectedOverlay, OverlayModule, CdkOverlayOrigin} from './index'; import {OverlayContainer} from './overlay-container'; -import {ConnectedPositionStrategy} from './position/connected-position-strategy'; import { ConnectedOverlayPositionChange, ConnectionPositionPair, } from './position/connected-position'; +import {FlexibleConnectedPositionStrategy} from './position/flexible-connected-position-strategy'; describe('Overlay directives', () => { @@ -79,13 +79,11 @@ describe('Overlay directives', () => { let testComponent: ConnectedOverlayDirectiveTest = fixture.debugElement.componentInstance; let overlayDirective = testComponent.connectedOverlayDirective; - let strategy = - overlayDirective.overlayRef.getConfig().positionStrategy; - expect(strategy instanceof ConnectedPositionStrategy).toBe(true); + overlayDirective.overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy; - let positions = strategy.positions; - expect(positions.length).toBeGreaterThan(0); + expect(strategy instanceof FlexibleConnectedPositionStrategy).toBe(true); + expect(strategy.positions.length).toBeGreaterThan(0); }); it('should set and update the `dir` attribute', () => { @@ -138,7 +136,7 @@ describe('Overlay directives', () => { fixture.componentInstance.isOpen = true; fixture.detectChanges(); - const pane = overlayContainerElement.children[0] as HTMLElement; + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; expect(pane.style.width).toEqual('250px'); fixture.componentInstance.isOpen = false; @@ -156,7 +154,7 @@ describe('Overlay directives', () => { fixture.componentInstance.isOpen = true; fixture.detectChanges(); - const pane = overlayContainerElement.children[0] as HTMLElement; + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; expect(pane.style.height).toEqual('100vh'); fixture.componentInstance.isOpen = false; @@ -174,7 +172,7 @@ describe('Overlay directives', () => { fixture.componentInstance.isOpen = true; fixture.detectChanges(); - const pane = overlayContainerElement.children[0] as HTMLElement; + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; expect(pane.style.minWidth).toEqual('250px'); fixture.componentInstance.isOpen = false; @@ -192,7 +190,7 @@ describe('Overlay directives', () => { fixture.componentInstance.isOpen = true; fixture.detectChanges(); - const pane = overlayContainerElement.children[0] as HTMLElement; + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; expect(pane.style.minHeight).toEqual('500px'); fixture.componentInstance.isOpen = false; @@ -233,18 +231,13 @@ describe('Overlay directives', () => { }); it('should set the offsetX', () => { - const trigger = fixture.debugElement.query(By.css('button')).nativeElement; - const startX = trigger.getBoundingClientRect().left; - fixture.componentInstance.offsetX = 5; fixture.componentInstance.isOpen = true; fixture.detectChanges(); - const pane = overlayContainerElement.children[0] as HTMLElement; + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; - expect(pane.style.left) - .toBe(startX + 5 + 'px', - `Expected overlay translateX to equal the original X + the offsetX.`); + expect(pane.style.transform).toContain('translateX(5px)'); fixture.componentInstance.isOpen = false; fixture.detectChanges(); @@ -253,9 +246,7 @@ describe('Overlay directives', () => { fixture.componentInstance.isOpen = true; fixture.detectChanges(); - expect(pane.style.left) - .toBe(startX + 15 + 'px', - `Expected overlay directive to reflect new offsetX if it changes.`); + expect(pane.style.transform).toContain('translateX(15px)'); }); it('should set the offsetY', () => { @@ -268,12 +259,9 @@ describe('Overlay directives', () => { fixture.componentInstance.isOpen = true; fixture.detectChanges(); - // expected y value is the starting y + trigger height + offset y - // 30 + 20 + 45 = 95px - const pane = overlayContainerElement.children[0] as HTMLElement; + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; - expect(pane.style.top) - .toBe('95px', `Expected overlay translateY to equal the start Y + height + offsetY.`); + expect(pane.style.transform).toContain('translateY(45px)'); fixture.componentInstance.isOpen = false; fixture.detectChanges(); @@ -281,8 +269,7 @@ describe('Overlay directives', () => { fixture.componentInstance.offsetY = 55; fixture.componentInstance.isOpen = true; fixture.detectChanges(); - expect(pane.style.top) - .toBe('105px', `Expected overlay directive to reflect new offsetY if it changes.`); + expect(pane.style.transform).toContain('translateY(55px)'); }); it('should be able to update the origin after init', () => { diff --git a/src/cdk/overlay/overlay-directives.ts b/src/cdk/overlay/overlay-directives.ts index e5e6f1388fca..c67416501477 100644 --- a/src/cdk/overlay/overlay-directives.ts +++ b/src/cdk/overlay/overlay-directives.ts @@ -30,27 +30,39 @@ import {Overlay} from './overlay'; import {OverlayConfig} from './overlay-config'; import {OverlayRef} from './overlay-ref'; import { - ConnectedOverlayPositionChange, - ConnectionPositionPair, -} from './position/connected-position'; -import {ConnectedPositionStrategy} from './position/connected-position-strategy'; + FlexibleConnectedPositionStrategy, + ConnectedPosition, +} from './position/flexible-connected-position-strategy'; +import {ConnectedOverlayPositionChange} from './position/connected-position'; import {RepositionScrollStrategy, ScrollStrategy} from './scroll/index'; /** Default set of positions for the overlay. Follows the behavior of a dropdown. */ -const defaultPositionList = [ - new ConnectionPositionPair( - {originX: 'start', originY: 'bottom'}, - {overlayX: 'start', overlayY: 'top'}), - new ConnectionPositionPair( - {originX: 'start', originY: 'top'}, - {overlayX: 'start', overlayY: 'bottom'}), - new ConnectionPositionPair( - {originX: 'end', originY: 'top'}, - {overlayX: 'end', overlayY: 'bottom'}), - new ConnectionPositionPair( - {originX: 'end', originY: 'bottom'}, - {overlayX: 'end', overlayY: 'top'}), +const defaultPositionList: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom' + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom' + }, + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + } ]; /** Injection token that determines the scroll handling while the connected overlay is open. */ @@ -101,21 +113,22 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { private _backdropSubscription = Subscription.EMPTY; private _offsetX: number = 0; private _offsetY: number = 0; - private _position: ConnectedPositionStrategy; + private _position: FlexibleConnectedPositionStrategy; /** Origin for the connected overlay. */ @Input('cdkConnectedOverlayOrigin') origin: CdkOverlayOrigin; /** Registered connected position pairs. */ - @Input('cdkConnectedOverlayPositions') positions: ConnectionPositionPair[]; + @Input('cdkConnectedOverlayPositions') positions: ConnectedPosition[]; /** The offset in pixels for the overlay connection point on the x-axis */ @Input('cdkConnectedOverlayOffsetX') get offsetX(): number { return this._offsetX; } set offsetX(offsetX: number) { this._offsetX = offsetX; + if (this._position) { - this._position.withOffsetX(offsetX); + this._setPositions(this._position); } } @@ -124,8 +137,9 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { get offsetY() { return this._offsetY; } set offsetY(offsetY: number) { this._offsetY = offsetY; + if (this._position) { - this._position.withOffsetY(offsetY); + this._setPositions(this._position); } } @@ -264,28 +278,42 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { } /** Returns the position strategy of the overlay to be set on the overlay config */ - private _createPositionStrategy(): ConnectedPositionStrategy { - const primaryPosition = this.positions[0]; - const originPoint = {originX: primaryPosition.originX, originY: primaryPosition.originY}; - const overlayPoint = {overlayX: primaryPosition.overlayX, overlayY: primaryPosition.overlayY}; + private _createPositionStrategy(): FlexibleConnectedPositionStrategy { const strategy = this._overlay.position() - .connectedTo(this.origin.elementRef, originPoint, overlayPoint) - .withOffsetX(this.offsetX) - .withOffsetY(this.offsetY) + .flexibleConnectedTo(this.origin.elementRef) + // Turn off all of the flexible positioning features for now to have it behave + // the same way as the old ConnectedPositionStrategy and to avoid breaking changes. + // TODO(crisbeto): make these on by default and add inputs for them + // next time we do breaking changes. + .withFlexibleHeight(false) + .withFlexibleWidth(false) + .withPush(false) + .withGrowAfterOpen(false) .withLockedPosition(this.lockPosition); - for (let i = 1; i < this.positions.length; i++) { - strategy.withFallbackPosition( - {originX: this.positions[i].originX, originY: this.positions[i].originY}, - {overlayX: this.positions[i].overlayX, overlayY: this.positions[i].overlayY} - ); - } - - strategy.onPositionChange.subscribe(pos => this.positionChange.emit(pos)); + this._setPositions(strategy); + strategy.positionChanges.subscribe(p => this.positionChange.emit(p)); return strategy; } + /** + * Sets the primary and fallback positions of a positions strategy, + * based on the current directive inputs. + */ + private _setPositions(positionStrategy: FlexibleConnectedPositionStrategy) { + const positions: ConnectedPosition[] = this.positions.map(pos => ({ + originX: pos.originX, + originY: pos.originY, + overlayX: pos.overlayX, + overlayY: pos.overlayY, + offsetX: this.offsetX, + offsetY: this.offsetY + })); + + positionStrategy.withPositions(positions); + } + /** Attaches the overlay and subscribes to backdrop clicks if backdrop exists */ private _attachOverlay() { if (!this._overlayRef) { @@ -306,7 +334,6 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { }); } - this._position.withDirection(this.dir); this._overlayRef.setDirection(this.dir); if (!this._overlayRef.hasAttached()) { diff --git a/src/cdk/overlay/overlay-ref.ts b/src/cdk/overlay/overlay-ref.ts index 523bdb2028b0..a624ef5fb2c8 100644 --- a/src/cdk/overlay/overlay-ref.ts +++ b/src/cdk/overlay/overlay-ref.ts @@ -36,6 +36,7 @@ export class OverlayRef implements PortalOutlet { constructor( private _portalOutlet: PortalOutlet, + private _host: HTMLElement, private _pane: HTMLElement, private _config: ImmutableObject, private _ngZone: NgZone, @@ -57,6 +58,15 @@ export class OverlayRef implements PortalOutlet { return this._backdropElement; } + /** + * Wrapper around the panel element. Can be used for advanced + * positioning where a wrapper with specific styling is + * required around the overlay pane. + */ + get hostElement(): HTMLElement { + return this._host; + } + attach(portal: ComponentPortal): ComponentRef; attach(portal: TemplatePortal): EmbeddedViewRef; attach(portal: any): any; @@ -87,12 +97,15 @@ export class OverlayRef implements PortalOutlet { // Update the position once the zone is stable so that the overlay will be fully rendered // before attempting to position it, as the position may depend on the size of the rendered // content. - this._ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => { - // The overlay could've been detached before the zone has stabilized. - if (this.hasAttached()) { - this.updatePosition(); - } - }); + this._ngZone.onStable + .asObservable() + .pipe(take(1)) + .subscribe(() => { + // The overlay could've been detached before the zone has stabilized. + if (this.hasAttached()) { + this.updatePosition(); + } + }); // Enable pointer events for the overlay pane element. this._togglePointerEvents(true); @@ -104,7 +117,7 @@ export class OverlayRef implements PortalOutlet { if (this._config.panelClass) { // We can't do a spread here, because IE doesn't support setting multiple classes. if (Array.isArray(this._config.panelClass)) { - this._config.panelClass.forEach(cls => this._pane.classList.add(cls)); + this._config.panelClass.forEach(cssClass => this._pane.classList.add(cssClass)); } else { this._pane.classList.add(this._config.panelClass); } @@ -173,6 +186,11 @@ export class OverlayRef implements PortalOutlet { this._backdropClick.complete(); this._keydownEvents.complete(); + if (this._host && this._host.parentNode) { + this._host.parentNode.removeChild(this._host); + this._host = null!; + } + if (isAttached) { this._detachments.next(); } @@ -279,7 +297,7 @@ export class OverlayRef implements PortalOutlet { // Insert the backdrop before the pane in the DOM order, // in order to handle stacked overlays properly. - this._pane.parentElement!.insertBefore(this._backdropElement, this._pane); + this._host.parentElement!.insertBefore(this._backdropElement, this._host); // Forward backdrop clicks such that the consumer of the overlay can perform whatever // action desired when such a click occurs (usually closing the overlay). @@ -308,8 +326,8 @@ export class OverlayRef implements PortalOutlet { * in its original DOM position. */ private _updateStackingOrder() { - if (this._pane.nextSibling) { - this._pane.parentNode!.appendChild(this._pane); + if (this._host.nextSibling) { + this._host.parentNode!.appendChild(this._host); } } @@ -347,9 +365,7 @@ export class OverlayRef implements PortalOutlet { // Run this outside the Angular zone because there's nothing that Angular cares about. // If it were to run inside the Angular zone, every test that used Overlay would have to be // either async or fakeAsync. - this._ngZone.runOutsideAngular(() => { - setTimeout(finishDetach, 500); - }); + this._ngZone.runOutsideAngular(() => setTimeout(finishDetach, 500)); } } } diff --git a/src/cdk/overlay/overlay.spec.ts b/src/cdk/overlay/overlay.spec.ts index f0b67e806030..a2121b758a21 100644 --- a/src/cdk/overlay/overlay.spec.ts +++ b/src/cdk/overlay/overlay.spec.ts @@ -125,9 +125,9 @@ describe('Overlay', () => { pizzaOverlayRef.attach(componentPortal); cakeOverlayRef.attach(templatePortal); - expect(pizzaOverlayRef.overlayElement.nextSibling) + expect(pizzaOverlayRef.hostElement.nextSibling) .toBeTruthy('Expected pizza to be on the bottom.'); - expect(cakeOverlayRef.overlayElement.nextSibling) + expect(cakeOverlayRef.hostElement.nextSibling) .toBeFalsy('Expected cake to be on top.'); pizzaOverlayRef.dispose(); @@ -137,27 +137,27 @@ describe('Overlay', () => { pizzaOverlayRef.attach(componentPortal); cakeOverlayRef.attach(templatePortal); - expect(pizzaOverlayRef.overlayElement.nextSibling) + expect(pizzaOverlayRef.hostElement.nextSibling) .toBeTruthy('Expected pizza to still be on the bottom.'); - expect(cakeOverlayRef.overlayElement.nextSibling) + expect(cakeOverlayRef.hostElement.nextSibling) .toBeFalsy('Expected cake to still be on top.'); })); it('should take the default direction from the global Directionality', () => { dir = 'rtl'; - overlay.create().attach(componentPortal); + const overlayRef = overlay.create(); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.getAttribute('dir')).toBe('rtl'); + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.getAttribute('dir')).toBe('rtl'); }); it('should set the direction', () => { const config = new OverlayConfig({direction: 'rtl'}); + const overlayRef = overlay.create(config); - overlay.create(config).attach(componentPortal); + overlayRef.attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.getAttribute('dir')).toBe('rtl'); + expect(overlayRef.overlayElement.getAttribute('dir')).toEqual('rtl'); }); it('should emit when an overlay is attached', () => { @@ -312,77 +312,86 @@ describe('Overlay', () => { it('should apply the width set in the config', () => { config.width = 500; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.width).toEqual('500px'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.width).toEqual('500px'); }); it('should support using other units if a string width is provided', () => { config.width = '200%'; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.width).toEqual('200%'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.width).toEqual('200%'); }); it('should apply the height set in the config', () => { config.height = 500; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.height).toEqual('500px'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.height).toEqual('500px'); }); it('should support using other units if a string height is provided', () => { config.height = '100vh'; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.height).toEqual('100vh'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.height).toEqual('100vh'); }); it('should apply the min width set in the config', () => { config.minWidth = 200; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.minWidth).toEqual('200px'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.minWidth).toEqual('200px'); }); it('should apply the min height set in the config', () => { config.minHeight = 500; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.minHeight).toEqual('500px'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.minHeight).toEqual('500px'); }); it('should apply the max width set in the config', () => { config.maxWidth = 200; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.maxWidth).toEqual('200px'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.maxWidth).toEqual('200px'); }); it('should apply the max height set in the config', () => { config.maxHeight = 500; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.maxHeight).toEqual('500px'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.maxHeight).toEqual('500px'); }); it('should support zero widths and heights', () => { config.width = 0; config.height = 0; - overlay.create(config).attach(componentPortal); - const pane = overlayContainerElement.children[0] as HTMLElement; - expect(pane.style.width).toEqual('0px'); - expect(pane.style.height).toEqual('0px'); + const overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(overlayRef.overlayElement.style.width).toEqual('0px'); + expect(overlayRef.overlayElement.style.height).toEqual('0px'); }); }); @@ -458,20 +467,20 @@ describe('Overlay', () => { expect(backdrop.style.pointerEvents).toBe('none'); }); - it('should insert the backdrop before the overlay pane in the DOM order', () => { - let overlayRef = overlay.create(config); - overlayRef.attach(componentPortal); + it('should insert the backdrop before the overlay host in the DOM order', () => { + const overlayRef = overlay.create(config); + overlayRef.attach(componentPortal); viewContainerFixture.detectChanges(); - let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); - let pane = overlayContainerElement.querySelector('.cdk-overlay-pane'); - let children = Array.prototype.slice.call(overlayContainerElement.children); + const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); + const host = overlayContainerElement.querySelector('.cdk-overlay-pane')!.parentElement!; + const children = Array.prototype.slice.call(overlayContainerElement.children); expect(children.indexOf(backdrop)).toBeGreaterThan(-1); - expect(children.indexOf(pane)).toBeGreaterThan(-1); + expect(children.indexOf(host)).toBeGreaterThan(-1); expect(children.indexOf(backdrop)) - .toBeLessThan(children.indexOf(pane), 'Expected backdrop to be before the pane in the DOM'); + .toBeLessThan(children.indexOf(host), 'Expected backdrop to be before the host in the DOM'); }); }); diff --git a/src/cdk/overlay/overlay.ts b/src/cdk/overlay/overlay.ts index 872f599c2ff4..67df2f938a3b 100644 --- a/src/cdk/overlay/overlay.ts +++ b/src/cdk/overlay/overlay.ts @@ -57,20 +57,15 @@ export class Overlay { * @returns Reference to the created overlay. */ create(config?: OverlayConfig): OverlayRef { - const pane = this._createPaneElement(); + const host = this._createHostElement(); + const pane = this._createPaneElement(host); const portalOutlet = this._createPortalOutlet(pane); const overlayConfig = new OverlayConfig(config); overlayConfig.direction = overlayConfig.direction || this._directionality.value; - return new OverlayRef( - portalOutlet, - pane, - overlayConfig, - this._ngZone, - this._keyboardDispatcher, - this._document - ); + return new OverlayRef(portalOutlet, host, pane, overlayConfig, this._ngZone, + this._keyboardDispatcher, this._document); } /** @@ -86,16 +81,27 @@ export class Overlay { * Creates the DOM element for an overlay and appends it to the overlay container. * @returns Newly-created pane element */ - private _createPaneElement(): HTMLElement { + private _createPaneElement(host: HTMLElement): HTMLElement { const pane = this._document.createElement('div'); pane.id = `cdk-overlay-${nextUniqueId++}`; pane.classList.add('cdk-overlay-pane'); - this._overlayContainer.getContainerElement().appendChild(pane); + host.appendChild(pane); return pane; } + /** + * Creates the host element that wraps around an overlay + * and can be used for advanced positioning. + * @returns Newly-create host element. + */ + private _createHostElement(): HTMLElement { + const host = this._document.createElement('div'); + this._overlayContainer.getContainerElement().appendChild(host); + return host; + } + /** * Create a DomPortalOutlet into which the overlay content can be loaded. * @param pane The DOM element to turn into a portal outlet. diff --git a/src/cdk/overlay/position/connected-position-strategy.spec.ts b/src/cdk/overlay/position/connected-position-strategy.spec.ts index 9de618eab78a..746efd9dee1d 100644 --- a/src/cdk/overlay/position/connected-position-strategy.spec.ts +++ b/src/cdk/overlay/position/connected-position-strategy.spec.ts @@ -1,9 +1,10 @@ -import {ElementRef} from '@angular/core'; +import {ElementRef, Component, NgModule, NgZone} from '@angular/core'; import {TestBed, inject} from '@angular/core/testing'; -import {OverlayPositionBuilder} from './overlay-position-builder'; import {CdkScrollable} from '@angular/cdk/scrolling'; import {Subscription} from 'rxjs/Subscription'; import {ScrollDispatchModule} from '@angular/cdk/scrolling'; +import {MockNgZone} from '@angular/cdk/testing'; +import {ComponentPortal, PortalModule} from '@angular/cdk/portal'; import { OverlayModule, Overlay, @@ -24,15 +25,20 @@ const DEFAULT_WIDTH = 60; // for tests on CI (both SauceLabs and Browserstack). describe('ConnectedPositionStrategy', () => { - let positionBuilder: OverlayPositionBuilder; + let overlay: Overlay; let overlayContainer: OverlayContainer; let overlayContainerElement: HTMLElement; + let zone: MockNgZone; + let overlayRef: OverlayRef; beforeEach(() => { - TestBed.configureTestingModule({imports: [ScrollDispatchModule, OverlayModule]}); + TestBed.configureTestingModule({ + imports: [ScrollDispatchModule, OverlayModule, OverlayTestModule], + providers: [{provide: NgZone, useFactory: () => zone = new MockNgZone()}] + }); - inject([Overlay, OverlayContainer], (overlay: Overlay, oc: OverlayContainer) => { - positionBuilder = overlay.position(); + inject([Overlay, OverlayContainer], (o: Overlay, oc: OverlayContainer) => { + overlay = o; overlayContainer = oc; overlayContainerElement = oc.getContainerElement(); })(); @@ -40,8 +46,18 @@ describe('ConnectedPositionStrategy', () => { afterEach(() => { overlayContainer.ngOnDestroy(); + + if (overlayRef) { + overlayRef.dispose(); + } }); + function attachOverlay(positionStrategy: ConnectedPositionStrategy) { + overlayRef = overlay.create({positionStrategy}); + overlayRef.attach(new ComponentPortal(TestOverlay)); + zone.simulateZoneExit(); + } + describe('with origin on document body', () => { const ORIGIN_HEIGHT = DEFAULT_HEIGHT; const ORIGIN_WIDTH = DEFAULT_WIDTH; @@ -49,8 +65,7 @@ describe('ConnectedPositionStrategy', () => { const OVERLAY_WIDTH = DEFAULT_WIDTH; let originElement: HTMLElement; - let overlayElement: HTMLElement; - let strategy: ConnectedPositionStrategy; + let positionStrategy: ConnectedPositionStrategy; let fakeElementRef: ElementRef; let originRect: ClientRect | null; @@ -60,9 +75,7 @@ describe('ConnectedPositionStrategy', () => { beforeEach(() => { // The origin and overlay elements need to be in the document body in order to have geometry. originElement = createPositionedBlockElement(); - overlayElement = createPositionedBlockElement(); document.body.appendChild(originElement); - overlayContainerElement.appendChild(overlayElement); fakeElementRef = new ElementRef(originElement); }); @@ -139,7 +152,7 @@ describe('ConnectedPositionStrategy', () => { originElement.style.left = '200px'; originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'top'}, {overlayX: 'end', overlayY: 'bottom'}) @@ -147,10 +160,9 @@ describe('ConnectedPositionStrategy', () => { {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left)); }); @@ -164,7 +176,7 @@ describe('ConnectedPositionStrategy', () => { originRect = originElement.getBoundingClientRect(); originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'end', overlayY: 'top'}) @@ -172,10 +184,9 @@ describe('ConnectedPositionStrategy', () => { {originX: 'end', originY: 'center'}, {overlayX: 'start', overlayY: 'center'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originCenterY - (OVERLAY_HEIGHT / 2))); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.right)); }); @@ -185,7 +196,7 @@ describe('ConnectedPositionStrategy', () => { originElement.style.left = '200px'; originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}) @@ -193,10 +204,9 @@ describe('ConnectedPositionStrategy', () => { {originX: 'end', originY: 'top'}, {overlayX: 'end', overlayY: 'bottom'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top)); expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.right)); }); @@ -206,7 +216,7 @@ describe('ConnectedPositionStrategy', () => { originElement.style.right = '25px'; originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'center'}, {overlayX: 'start', overlayY: 'center'}) @@ -214,10 +224,9 @@ describe('ConnectedPositionStrategy', () => { {originX: 'start', originY: 'bottom'}, {overlayX: 'end', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.left)); @@ -228,7 +237,7 @@ describe('ConnectedPositionStrategy', () => { originElement.style.bottom = '25px'; originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}) @@ -237,16 +246,15 @@ describe('ConnectedPositionStrategy', () => { {overlayX: 'start', overlayY: 'bottom'}); // This should apply the fallback position, as the original position won't fit. - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); // Now make the overlay small enough to fit in the first preferred position. - overlayElement.style.height = '15px'; + overlayRef.overlayElement.style.height = '15px'; // This should only re-align in the last position, even though the first would fit. - strategy.recalculateLastPosition(); + positionStrategy.recalculateLastPosition(); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top), 'Expected overlay to be re-aligned to the trigger in the previous position.'); }); @@ -257,16 +265,15 @@ describe('ConnectedPositionStrategy', () => { originElement.style.top = '0'; originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'bottom'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); - strategy.recalculateLastPosition(); + attachOverlay(positionStrategy); + positionStrategy.recalculateLastPosition(); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top), 'Expected overlay to be re-aligned to the trigger in the initial position.'); @@ -274,50 +281,47 @@ describe('ConnectedPositionStrategy', () => { it('should position a panel properly when rtl', () => { // must make the overlay longer than the origin to properly test attachment - overlayElement.style.width = `500px`; + overlayRef.overlayElement.style.width = `500px`; originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}) .withDirection('rtl'); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.right)); }); it('should position a panel with the x offset provided', () => { originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'top'}); - strategy.withOffsetX(10); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + positionStrategy.withOffsetX(10); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left + 10)); }); it('should position a panel with the y offset provided', () => { originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'top'}); - strategy.withOffsetY(50); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + positionStrategy.withOffsetY(50); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top + 50)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left)); }); @@ -327,7 +331,7 @@ describe('ConnectedPositionStrategy', () => { originElement.style.left = '50%'; originElement.style.position = 'fixed'; originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder + positionStrategy = overlay.position() .connectedTo( fakeElementRef, {originX: 'start', originY: 'top'}, @@ -337,11 +341,10 @@ describe('ConnectedPositionStrategy', () => { {overlayX: 'start', overlayY: 'bottom'}, -100, -100); - strategy.withOffsetY(50).withOffsetY(50); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + positionStrategy.withOffsetY(50).withOffsetY(50); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top - 100)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left - 100)); }); @@ -352,7 +355,7 @@ describe('ConnectedPositionStrategy', () => { originElement.style.top = '200px'; originElement.style.right = '25px'; - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'center'}, {overlayX: 'start', overlayY: 'center'}) @@ -361,10 +364,9 @@ describe('ConnectedPositionStrategy', () => { {overlayX: 'end', overlayY: 'top'}); const positionChangeHandler = jasmine.createSpy('positionChangeHandler'); - const subscription = strategy.onPositionChange.subscribe(positionChangeHandler); + const subscription = positionStrategy.onPositionChange.subscribe(positionChangeHandler); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); const latestCall = positionChangeHandler.calls.mostRecent(); @@ -376,8 +378,8 @@ describe('ConnectedPositionStrategy', () => { // the position change event should be emitted again. originElement.style.top = '200px'; originElement.style.left = '200px'; - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + + positionStrategy.apply(); expect(positionChangeHandler).toHaveBeenCalledTimes(2); @@ -388,7 +390,7 @@ describe('ConnectedPositionStrategy', () => { originElement.style.bottom = '25px'; originElement.style.right = '25px'; - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}) @@ -397,10 +399,9 @@ describe('ConnectedPositionStrategy', () => { {overlayX: 'end', overlayY: 'top'}); const positionChangeHandler = jasmine.createSpy('positionChangeHandler'); - const subscription = strategy.onPositionChange.subscribe(positionChangeHandler); + const subscription = positionStrategy.onPositionChange.subscribe(positionChangeHandler); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); expect(positionChangeHandler).toHaveBeenCalled(); @@ -408,17 +409,16 @@ describe('ConnectedPositionStrategy', () => { }); it('should complete the onPositionChange stream on dispose', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}); const completeHandler = jasmine.createSpy('complete handler'); - strategy.onPositionChange.subscribe(undefined, undefined, completeHandler); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); - strategy.dispose(); + positionStrategy.onPositionChange.subscribe(undefined, undefined, completeHandler); + attachOverlay(positionStrategy); + positionStrategy.dispose(); expect(completeHandler).toHaveBeenCalled(); }); @@ -428,7 +428,7 @@ describe('ConnectedPositionStrategy', () => { originElement.style.right = '25px'; originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'center'}, {overlayX: 'start', overlayY: 'center'}) @@ -439,17 +439,16 @@ describe('ConnectedPositionStrategy', () => { {originX: 'end', originY: 'top'}, {overlayX: 'end', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left)); }); it('should re-use the preferred position when re-applying while locked in', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'center'}, {overlayX: 'start', overlayY: 'center'}) @@ -458,14 +457,13 @@ describe('ConnectedPositionStrategy', () => { {originX: 'start', originY: 'bottom'}, {overlayX: 'end', overlayY: 'top'}); - const recalcSpy = spyOn(strategy, 'recalculateLastPosition'); + const recalcSpy = spyOn(positionStrategy._positionStrategy, 'reapplyLastPosition'); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); expect(recalcSpy).not.toHaveBeenCalled(); - strategy.apply(); + positionStrategy.apply(); expect(recalcSpy).toHaveBeenCalled(); }); @@ -478,111 +476,104 @@ describe('ConnectedPositionStrategy', () => { */ function runSimplePositionTests() { it('should position a panel below, left-aligned', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect!.bottom)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect!.left)); }); it('should position to the right, center aligned vertically', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'center'}, {overlayX: 'start', overlayY: 'center'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originCenterY! - (OVERLAY_HEIGHT / 2))); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect!.right)); }); it('should position to the left, below', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'end', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect!.bottom)); expect(Math.round(overlayRect.right)).toBe(Math.round(originRect!.left)); }); it('should position above, right aligned', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'top'}, {overlayX: 'end', overlayY: 'bottom'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.round(overlayRect.bottom)).toBe(Math.round(originRect!.top)); expect(Math.round(overlayRect.right)).toBe(Math.round(originRect!.right)); }); it('should position below, centered', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'center', originY: 'bottom'}, {overlayX: 'center', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect!.bottom)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originCenterX! - (OVERLAY_WIDTH / 2))); }); it('should center the overlay on the origin', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'center', originY: 'center'}, {overlayX: 'center', overlayY: 'center'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect!.top)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect!.left)); }); it('should allow for the positions to be updated after init', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); + attachOverlay(positionStrategy); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect!.bottom)); expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect!.left)); - strategy.withPositions([new ConnectionPositionPair( + positionStrategy.withPositions([new ConnectionPositionPair( {originX: 'start', originY: 'bottom'}, {overlayX: 'end', overlayY: 'top'} )]); - strategy.apply(); + positionStrategy.apply(); - overlayRect = overlayElement.getBoundingClientRect(); + overlayRect = overlayRef.overlayElement.getBoundingClientRect(); expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect!.bottom)); expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect!.left)); }); @@ -591,19 +582,14 @@ describe('ConnectedPositionStrategy', () => { }); describe('onPositionChange with scrollable view properties', () => { - let overlayElement: HTMLElement; - let strategy: ConnectedPositionStrategy; - let scrollable: HTMLDivElement; let positionChangeHandler: jasmine.Spy; let onPositionChangeSubscription: Subscription; let positionChange: ConnectedOverlayPositionChange; + let fakeElementRef: ElementRef; + let positionStrategy: ConnectedPositionStrategy; beforeEach(() => { - // Set up the overlay - overlayElement = createPositionedBlockElement(); - overlayContainerElement.appendChild(overlayElement); - // Set up the origin let originElement = createBlockElement(); originElement.style.margin = '0 1000px 1000px 0'; // Added so that the container scrolls @@ -614,17 +600,18 @@ describe('ConnectedPositionStrategy', () => { scrollable.appendChild(originElement); // Create a strategy with knowledge of the scrollable container - let fakeElementRef = new ElementRef(originElement); - strategy = positionBuilder.connectedTo( + fakeElementRef = new ElementRef(originElement); + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}); - strategy.withScrollableContainers([ + positionStrategy.withScrollableContainers([ new CdkScrollable(new ElementRef(scrollable), null!, null!)]); - strategy.attach(fakeOverlayRef(overlayElement)); positionChangeHandler = jasmine.createSpy('positionChangeHandler'); - onPositionChangeSubscription = strategy.onPositionChange.subscribe(positionChangeHandler); + onPositionChangeSubscription = + positionStrategy.onPositionChange.subscribe(positionChangeHandler); + attachOverlay(positionStrategy); }); afterEach(() => { @@ -633,8 +620,6 @@ describe('ConnectedPositionStrategy', () => { }); it('should not have origin or overlay clipped or out of view without scroll', () => { - strategy.apply(); - expect(positionChangeHandler).toHaveBeenCalled(); positionChange = positionChangeHandler.calls.mostRecent().args[0]; expect(positionChange.scrollableViewProperties).toEqual({ @@ -647,7 +632,7 @@ describe('ConnectedPositionStrategy', () => { it('should evaluate if origin is clipped if scrolled slightly down', () => { scrollable.scrollTop = 10; // Clip the origin by 10 pixels - strategy.apply(); + positionStrategy.apply(); expect(positionChangeHandler).toHaveBeenCalled(); positionChange = positionChangeHandler.calls.mostRecent().args[0]; @@ -661,7 +646,7 @@ describe('ConnectedPositionStrategy', () => { it('should evaluate if origin is out of view and overlay is clipped if scrolled enough', () => { scrollable.scrollTop = 31; // Origin is 30 pixels, move out of view and clip the overlay 1px - strategy.apply(); + positionStrategy.apply(); expect(positionChangeHandler).toHaveBeenCalled(); positionChange = positionChangeHandler.calls.mostRecent().args[0]; @@ -675,7 +660,7 @@ describe('ConnectedPositionStrategy', () => { it('should evaluate the overlay and origin are both out of the view', () => { scrollable.scrollTop = 61; // Scroll by overlay height + origin height + 1px buffer - strategy.apply(); + positionStrategy.apply(); expect(positionChangeHandler).toHaveBeenCalled(); positionChange = positionChangeHandler.calls.mostRecent().args[0]; @@ -690,16 +675,13 @@ describe('ConnectedPositionStrategy', () => { describe('positioning properties', () => { let originElement: HTMLElement; - let overlayElement: HTMLElement; - let strategy: ConnectedPositionStrategy; + let positionStrategy: ConnectedPositionStrategy; let fakeElementRef: ElementRef; beforeEach(() => { // The origin and overlay elements need to be in the document body in order to have geometry. originElement = createPositionedBlockElement(); - overlayElement = createPositionedBlockElement(); document.body.appendChild(originElement); - overlayContainerElement.appendChild(overlayElement); fakeElementRef = new ElementRef(originElement); }); @@ -709,85 +691,86 @@ describe('ConnectedPositionStrategy', () => { describe('in ltr', () => { it('should use `left` when positioning an element at the start', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); - expect(overlayElement.style.left).toBeTruthy(); - expect(overlayElement.style.right).toBeFalsy(); + + attachOverlay(positionStrategy); + + expect(overlayRef.overlayElement.style.left).toBeTruthy(); + expect(overlayRef.overlayElement.style.right).toBeFalsy(); }); it('should use `right` when positioning an element at the end', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'top'}, {overlayX: 'end', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); - expect(overlayElement.style.right).toBeTruthy(); - expect(overlayElement.style.left).toBeFalsy(); + attachOverlay(positionStrategy); + + expect(overlayRef.overlayElement.style.right).toBeTruthy(); + expect(overlayRef.overlayElement.style.left).toBeFalsy(); }); }); describe('in rtl', () => { it('should use `right` when positioning an element at the start', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'top'} ) .withDirection('rtl'); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); - expect(overlayElement.style.right).toBeTruthy(); - expect(overlayElement.style.left).toBeFalsy(); + attachOverlay(positionStrategy); + + expect(overlayRef.overlayElement.style.right).toBeTruthy(); + expect(overlayRef.overlayElement.style.left).toBeFalsy(); }); it('should use `left` when positioning an element at the end', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'end', originY: 'top'}, {overlayX: 'end', overlayY: 'top'} ).withDirection('rtl'); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); - expect(overlayElement.style.left).toBeTruthy(); - expect(overlayElement.style.right).toBeFalsy(); + attachOverlay(positionStrategy); + + expect(overlayRef.overlayElement.style.left).toBeTruthy(); + expect(overlayRef.overlayElement.style.right).toBeFalsy(); }); }); describe('vertical', () => { it('should use `top` when positioning at element along the top', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'top'} ); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); - expect(overlayElement.style.top).toBeTruthy(); - expect(overlayElement.style.bottom).toBeFalsy(); + attachOverlay(positionStrategy); + + expect(overlayRef.overlayElement.style.top).toBeTruthy(); + expect(overlayRef.overlayElement.style.bottom).toBeFalsy(); }); it('should use `bottom` when positioning at element along the bottom', () => { - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( fakeElementRef, {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'bottom'} ); - strategy.attach(fakeOverlayRef(overlayElement)); - strategy.apply(); - expect(overlayElement.style.bottom).toBeTruthy(); - expect(overlayElement.style.top).toBeFalsy(); + attachOverlay(positionStrategy); + + expect(overlayRef.overlayElement.style.bottom).toBeTruthy(); + expect(overlayRef.overlayElement.style.top).toBeFalsy(); }); }); @@ -796,54 +779,55 @@ describe('ConnectedPositionStrategy', () => { describe('validations', () => { let overlayElement: HTMLElement; let originElement: HTMLElement; - let strategy: ConnectedPositionStrategy; + let positionStrategy: ConnectedPositionStrategy; beforeEach(() => { overlayElement = createPositionedBlockElement(); overlayContainerElement.appendChild(overlayElement); originElement = createBlockElement(); - strategy = positionBuilder.connectedTo( + positionStrategy = overlay.position().connectedTo( new ElementRef(originElement), {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}); - strategy.attach(fakeOverlayRef(overlayElement)); + + attachOverlay(positionStrategy); }); afterEach(() => { - strategy.dispose(); + positionStrategy.dispose(); }); it('should throw when attaching without any positions', () => { - strategy.withPositions([]); - expect(() => strategy.apply()).toThrow(); + positionStrategy.withPositions([]); + expect(() => positionStrategy.apply()).toThrow(); }); it('should throw when passing in something that is missing a connection point', () => { - strategy.withPositions([{originY: 'top', overlayX: 'start', overlayY: 'top'} as any]); - expect(() => strategy.apply()).toThrow(); + positionStrategy.withPositions([{originY: 'top', overlayX: 'start', overlayY: 'top'} as any]); + expect(() => positionStrategy.apply()).toThrow(); }); it('should throw when passing in something that has an invalid X position', () => { - strategy.withPositions([{ + positionStrategy.withPositions([{ originX: 'left', originY: 'top', overlayX: 'left', overlayY: 'top' } as any]); - expect(() => strategy.apply()).toThrow(); + expect(() => positionStrategy.apply()).toThrow(); }); it('should throw when passing in something that has an invalid Y position', () => { - strategy.withPositions([{ + positionStrategy.withPositions([{ originX: 'start', originY: 'middle', overlayX: 'start', overlayY: 'middle' } as any]); - expect(() => strategy.apply()).toThrow(); + expect(() => positionStrategy.apply()).toThrow(); }); }); @@ -877,6 +861,17 @@ function createOverflowContainerElement() { return element; } -function fakeOverlayRef(overlayElement: HTMLElement) { - return {overlayElement} as OverlayRef; -} + +@Component({ + template: `
` +}) +class TestOverlay { } + + +@NgModule({ + imports: [OverlayModule, PortalModule], + exports: [TestOverlay], + declarations: [TestOverlay], + entryComponents: [TestOverlay], +}) +class OverlayTestModule { } diff --git a/src/cdk/overlay/position/connected-position-strategy.ts b/src/cdk/overlay/position/connected-position-strategy.ts index 2b7a74a10526..6939c8b1d268 100644 --- a/src/cdk/overlay/position/connected-position-strategy.ts +++ b/src/cdk/overlay/position/connected-position-strategy.ts @@ -9,21 +9,19 @@ import {PositionStrategy} from './position-strategy'; import {ElementRef} from '@angular/core'; import {ViewportRuler} from '@angular/cdk/scrolling'; +import {Direction} from '@angular/cdk/bidi'; import { ConnectionPositionPair, OriginConnectionPosition, OverlayConnectionPosition, ConnectedOverlayPositionChange, - ScrollingVisibility, validateHorizontalPosition, validateVerticalPosition, } from './connected-position'; -import {Subject} from 'rxjs/Subject'; -import {Subscription} from 'rxjs/Subscription'; import {Observable} from 'rxjs/Observable'; import {CdkScrollable} from '@angular/cdk/scrolling'; -import {isElementScrolledOutsideView, isElementClippedByScrolling} from './scroll-clip'; import {OverlayRef} from '../overlay-ref'; +import {FlexibleConnectedPositionStrategy} from './flexible-connected-position-strategy'; @@ -33,60 +31,52 @@ import {OverlayRef} from '../overlay-ref'; * a point on the origin element that is connected to a point on the overlay element. For example, * a basic dropdown is connecting the bottom-left corner of the origin to the top-left corner * of the overlay. + * @deprecated + * @deletion-target 7.0.0 */ export class ConnectedPositionStrategy implements PositionStrategy { - /** Layout direction of the position strategy. */ - private _dir = 'ltr'; - - /** The offset in pixels for the overlay connection point on the x-axis */ - private _offsetX: number = 0; - - /** The offset in pixels for the overlay connection point on the y-axis */ - private _offsetY: number = 0; + /** + * Reference to the underlying position strategy to which all the API calls are proxied. + * @docs-private + */ + _positionStrategy: FlexibleConnectedPositionStrategy; - /** The Scrollable containers used to check scrollable view properties on position change. */ - private scrollables: CdkScrollable[] = []; + /** The overlay to which this strategy is attached. */ + private _overlayRef: OverlayRef; - /** Subscription to viewport resize events. */ - private _resizeSubscription = Subscription.EMPTY; + private _direction: Direction | null; /** Whether the we're dealing with an RTL context */ get _isRtl() { - return this._dir === 'rtl'; + return this._overlayRef.getConfig().direction === 'rtl'; } /** Ordered list of preferred positions, from most to least desirable. */ _preferredPositions: ConnectionPositionPair[] = []; - /** The origin element against which the overlay will be positioned. */ - private _origin: HTMLElement; - - /** The overlay pane element. */ - private _pane: HTMLElement; - - /** The last position to have been calculated as the best fit position. */ - private _lastConnectedPosition: ConnectionPositionPair; - - /** Whether the position strategy is applied currently. */ - private _applied = false; - - /** Whether the overlay position is locked. */ - private _positionLocked = false; - - private _onPositionChange = new Subject(); - /** Emits an event when the connection point changes. */ get onPositionChange(): Observable { - return this._onPositionChange.asObservable(); + return this._positionStrategy.positionChanges; } constructor( originPos: OriginConnectionPosition, overlayPos: OverlayConnectionPosition, - private _connectedTo: ElementRef, - private _viewportRuler: ViewportRuler, - private _document: any) { - this._origin = this._connectedTo.nativeElement; + connectedTo: ElementRef, + viewportRuler: ViewportRuler, + document: Document) { + + // Since the `ConnectedPositionStrategy` is deprecated and we don't want to maintain + // the extra logic, we create an instance of the positioning strategy that has some + // defaults that make it behave as the old position strategy and to which we'll + // proxy all of the API calls. + this._positionStrategy = + new FlexibleConnectedPositionStrategy(connectedTo, viewportRuler, document) + .withFlexibleHeight(false) + .withFlexibleWidth(false) + .withPush(false) + .withViewportMargin(0); + this.withFallbackPosition(originPos, overlayPos); } @@ -97,22 +87,23 @@ export class ConnectedPositionStrategy implements PositionStrategy { /** Attach this position strategy to an overlay. */ attach(overlayRef: OverlayRef): void { - this._pane = overlayRef.overlayElement; - this._resizeSubscription.unsubscribe(); - this._resizeSubscription = this._viewportRuler.change().subscribe(() => this.apply()); + this._overlayRef = overlayRef; + this._positionStrategy.attach(overlayRef); + + if (this._direction) { + overlayRef.setDirection(this._direction); + this._direction = null; + } } /** Disposes all resources used by the position strategy. */ dispose() { - this._applied = false; - this._resizeSubscription.unsubscribe(); - this._onPositionChange.complete(); + this._positionStrategy.dispose(); } /** @docs-private */ detach() { - this._applied = false; - this._resizeSubscription.unsubscribe(); + this._positionStrategy.detach(); } /** @@ -121,55 +112,8 @@ export class ConnectedPositionStrategy implements PositionStrategy { * @docs-private */ apply(): void { - // If the position has been applied already (e.g. when the overlay was opened) and the - // consumer opted into locking in the position, re-use the old position, in order to - // prevent the overlay from jumping around. - if (this._applied && this._positionLocked && this._lastConnectedPosition) { - this.recalculateLastPosition(); - return; - } - this._validatePositions(); - this._applied = true; - - // We need the bounding rects for the origin and the overlay to determine how to position - // the overlay relative to the origin. - const element = this._pane; - const originRect = this._origin.getBoundingClientRect(); - const overlayRect = element.getBoundingClientRect(); - - // We use the viewport size to determine whether a position would go off-screen. - const viewportSize = this._viewportRuler.getViewportSize(); - - // Fallback point if none of the fallbacks fit into the viewport. - let fallbackPoint: OverlayPoint | undefined; - let fallbackPosition: ConnectionPositionPair | undefined; - - // We want to place the overlay in the first of the preferred positions such that the - // overlay fits on-screen. - for (let pos of this._preferredPositions) { - // Get the (x, y) point of connection on the origin, and then use that to get the - // (top, left) coordinate for the overlay at `pos`. - let originPoint = this._getOriginConnectionPoint(originRect, pos); - let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportSize, pos); - - // If the overlay in the calculated position fits on-screen, put it there and we're done. - if (overlayPoint.fitsInViewport) { - this._setElementPosition(element, overlayRect, overlayPoint, pos); - - // Save the last connected position in case the position needs to be re-calculated. - this._lastConnectedPosition = pos; - - return; - } else if (!fallbackPoint || fallbackPoint.visibleArea < overlayPoint.visibleArea) { - fallbackPoint = overlayPoint; - fallbackPosition = pos; - } - } - - // If none of the preferred positions were in the viewport, take the one - // with the largest visible area. - this._setElementPosition(element, overlayRect, fallbackPoint!, fallbackPosition!); + this._positionStrategy.apply(); } /** @@ -178,21 +122,8 @@ export class ConnectedPositionStrategy implements PositionStrategy { * allows one to re-align the panel without changing the orientation of the panel. */ recalculateLastPosition(): void { - // If the overlay has never been positioned before, do nothing. - if (!this._lastConnectedPosition) { - return; - } - this._validatePositions(); - - const originRect = this._origin.getBoundingClientRect(); - const overlayRect = this._pane.getBoundingClientRect(); - const viewportSize = this._viewportRuler.getViewportSize(); - const lastPosition = this._lastConnectedPosition || this._preferredPositions[0]; - - let originPoint = this._getOriginConnectionPoint(originRect, lastPosition); - let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportSize, lastPosition); - this._setElementPosition(this._pane, overlayRect, overlayPoint, lastPosition); + this._positionStrategy.reapplyLastPosition(); } /** @@ -200,9 +131,8 @@ export class ConnectedPositionStrategy implements PositionStrategy { * on reposition we can evaluate if it or the overlay has been clipped or outside view. Every * Scrollable must be an ancestor element of the strategy's origin element. */ - withScrollableContainers(scrollables: CdkScrollable[]): this { - this.scrollables = scrollables; - return this; + withScrollableContainers(scrollables: CdkScrollable[]) { + this._positionStrategy.withScrollableContainers(scrollables); } /** @@ -218,6 +148,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { const position = new ConnectionPositionPair(originPos, overlayPos, offsetX, offsetY); this._preferredPositions.push(position); + this._positionStrategy.withPositions(this._preferredPositions); return this; } @@ -226,7 +157,15 @@ export class ConnectedPositionStrategy implements PositionStrategy { * @param dir New layout direction. */ withDirection(dir: 'ltr' | 'rtl'): this { - this._dir = dir; + // Since the direction might be declared before the strategy is attached, + // we save the value in a temporary property and we'll transfer it to the + // overlay ref on attachment. + if (this._overlayRef) { + this._overlayRef.setDirection(dir); + } else { + this._direction = dir; + } + return this; } @@ -235,7 +174,12 @@ export class ConnectedPositionStrategy implements PositionStrategy { * @param offset New offset in the X axis. */ withOffsetX(offset: number): this { - this._offsetX = offset; + this._preferredPositions.forEach(position => { + if (position.offsetX == null) { + position.offsetX = offset; + } + }); + return this; } @@ -244,7 +188,12 @@ export class ConnectedPositionStrategy implements PositionStrategy { * @param offset New offset in the Y axis. */ withOffsetY(offset: number): this { - this._offsetY = offset; + this._preferredPositions.forEach(position => { + if (position.offsetY == null) { + position.offsetY = offset; + } + }); + return this; } @@ -255,7 +204,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { * @param isLocked Whether the overlay should locked in. */ withLockedPosition(isLocked: boolean): this { - this._positionLocked = isLocked; + this._positionStrategy.withLockedPosition(isLocked); return this; } @@ -265,6 +214,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { */ withPositions(positions: ConnectionPositionPair[]): this { this._preferredPositions = positions.slice(); + this._positionStrategy.withPositions(this._preferredPositions); return this; } @@ -273,180 +223,10 @@ export class ConnectedPositionStrategy implements PositionStrategy { * @param origin Reference to the new origin element. */ setOrigin(origin: ElementRef): this { - this._origin = origin.nativeElement; + this._positionStrategy.setOrigin(origin); return this; } - /** - * Gets the horizontal (x) "start" dimension based on whether the overlay is in an RTL context. - * @param rect - */ - private _getStartX(rect: ClientRect): number { - return this._isRtl ? rect.right : rect.left; - } - - /** - * Gets the horizontal (x) "end" dimension based on whether the overlay is in an RTL context. - * @param rect - */ - private _getEndX(rect: ClientRect): number { - return this._isRtl ? rect.left : rect.right; - } - - - /** - * Gets the (x, y) coordinate of a connection point on the origin based on a relative position. - * @param originRect - * @param pos - */ - private _getOriginConnectionPoint(originRect: ClientRect, pos: ConnectionPositionPair): Point { - const originStartX = this._getStartX(originRect); - const originEndX = this._getEndX(originRect); - - let x: number; - if (pos.originX == 'center') { - x = originStartX + (originRect.width / 2); - } else { - x = pos.originX == 'start' ? originStartX : originEndX; - } - - let y: number; - if (pos.originY == 'center') { - y = originRect.top + (originRect.height / 2); - } else { - y = pos.originY == 'top' ? originRect.top : originRect.bottom; - } - - return {x, y}; - } - - - /** - * Gets the (x, y) coordinate of the top-left corner of the overlay given a given position and - * origin point to which the overlay should be connected, as well as how much of the element - * would be inside the viewport at that position. - */ - private _getOverlayPoint( - originPoint: Point, - overlayRect: ClientRect, - viewportSize: {width: number; height: number}, - pos: ConnectionPositionPair): OverlayPoint { - // Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position - // relative to the origin point. - let overlayStartX: number; - if (pos.overlayX == 'center') { - overlayStartX = -overlayRect.width / 2; - } else if (pos.overlayX === 'start') { - overlayStartX = this._isRtl ? -overlayRect.width : 0; - } else { - overlayStartX = this._isRtl ? 0 : -overlayRect.width; - } - - let overlayStartY: number; - if (pos.overlayY == 'center') { - overlayStartY = -overlayRect.height / 2; - } else { - overlayStartY = pos.overlayY == 'top' ? 0 : -overlayRect.height; - } - - // The (x, y) offsets of the overlay based on the current position. - let offsetX = typeof pos.offsetX === 'undefined' ? this._offsetX : pos.offsetX; - let offsetY = typeof pos.offsetY === 'undefined' ? this._offsetY : pos.offsetY; - - // The (x, y) coordinates of the overlay. - let x = originPoint.x + overlayStartX + offsetX; - let y = originPoint.y + overlayStartY + offsetY; - - // How much the overlay would overflow at this position, on each side. - let leftOverflow = 0 - x; - let rightOverflow = (x + overlayRect.width) - viewportSize.width; - let topOverflow = 0 - y; - let bottomOverflow = (y + overlayRect.height) - viewportSize.height; - - // Visible parts of the element on each axis. - let visibleWidth = this._subtractOverflows(overlayRect.width, leftOverflow, rightOverflow); - let visibleHeight = this._subtractOverflows(overlayRect.height, topOverflow, bottomOverflow); - - // The area of the element that's within the viewport. - let visibleArea = visibleWidth * visibleHeight; - let fitsInViewport = (overlayRect.width * overlayRect.height) === visibleArea; - - return {x, y, fitsInViewport, visibleArea}; - } - - /** - * Gets the view properties of the trigger and overlay, including whether they are clipped - * or completely outside the view of any of the strategy's scrollables. - */ - private _getScrollVisibility(overlay: HTMLElement): ScrollingVisibility { - const originBounds = this._origin.getBoundingClientRect(); - const overlayBounds = overlay.getBoundingClientRect(); - const scrollContainerBounds = - this.scrollables.map(s => s.getElementRef().nativeElement.getBoundingClientRect()); - - return { - isOriginClipped: isElementClippedByScrolling(originBounds, scrollContainerBounds), - isOriginOutsideView: isElementScrolledOutsideView(originBounds, scrollContainerBounds), - isOverlayClipped: isElementClippedByScrolling(overlayBounds, scrollContainerBounds), - isOverlayOutsideView: isElementScrolledOutsideView(overlayBounds, scrollContainerBounds), - }; - } - - /** Physically positions the overlay element to the given coordinate. */ - private _setElementPosition( - element: HTMLElement, - overlayRect: ClientRect, - overlayPoint: Point, - pos: ConnectionPositionPair) { - - // We want to set either `top` or `bottom` based on whether the overlay wants to appear above - // or below the origin and the direction in which the element will expand. - let verticalStyleProperty = pos.overlayY === 'bottom' ? 'bottom' : 'top'; - - // When using `bottom`, we adjust the y position such that it is the distance - // from the bottom of the viewport rather than the top. - let y = verticalStyleProperty === 'top' ? - overlayPoint.y : - this._document.documentElement.clientHeight - (overlayPoint.y + overlayRect.height); - - // We want to set either `left` or `right` based on whether the overlay wants to appear "before" - // or "after" the origin, which determines the direction in which the element will expand. - // For the horizontal axis, the meaning of "before" and "after" change based on whether the - // page is in RTL or LTR. - let horizontalStyleProperty: string; - if (this._dir === 'rtl') { - horizontalStyleProperty = pos.overlayX === 'end' ? 'left' : 'right'; - } else { - horizontalStyleProperty = pos.overlayX === 'end' ? 'right' : 'left'; - } - - // When we're setting `right`, we adjust the x position such that it is the distance - // from the right edge of the viewport rather than the left edge. - let x = horizontalStyleProperty === 'left' ? - overlayPoint.x : - this._document.documentElement.clientWidth - (overlayPoint.x + overlayRect.width); - - - // Reset any existing styles. This is necessary in case the preferred position has - // changed since the last `apply`. - ['top', 'bottom', 'left', 'right'].forEach(p => element.style[p] = null); - - element.style[verticalStyleProperty] = `${y}px`; - element.style[horizontalStyleProperty] = `${x}px`; - - // Notify that the position has been changed along with its change properties. - const scrollableViewProperties = this._getScrollVisibility(element); - const positionChange = new ConnectedOverlayPositionChange(pos, scrollableViewProperties); - this._onPositionChange.next(positionChange); - } - - /** Subtracts the amount that an element is overflowing on an axis from it's length. */ - private _subtractOverflows(length: number, ...overflows: number[]): number { - return overflows.reduce((currentValue: number, currentOverflow: number) => { - return currentValue - Math.max(currentOverflow, 0); - }, length); - } - /** Validates that the current position match the expected values. */ private _validatePositions(): void { if (!this._preferredPositions.length) { @@ -455,6 +235,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { // TODO(crisbeto): remove these once Angular's template type // checking is advanced enough to catch these cases. + // TODO(crisbeto): port these checks into the flexible positioning. this._preferredPositions.forEach(pair => { validateHorizontalPosition('originX', pair.originX); validateVerticalPosition('originY', pair.originY); @@ -463,19 +244,3 @@ export class ConnectedPositionStrategy implements PositionStrategy { }); } } - -/** A simple (x, y) coordinate. */ -interface Point { - x: number; - y: number; -} - -/** - * Expands the simple (x, y) coordinate by adding info about whether the - * element would fit inside the viewport at that position, as well as - * how much of the element would be visible. - */ -interface OverlayPoint extends Point { - visibleArea: number; - fitsInViewport: boolean; -} diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts new file mode 100644 index 000000000000..5d1b625276a7 --- /dev/null +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts @@ -0,0 +1,1365 @@ +import {ElementRef, NgModule, Component, NgZone} from '@angular/core'; +import {TestBed, inject} from '@angular/core/testing'; +import {CdkScrollable} from '@angular/cdk/scrolling'; +import {PortalModule, ComponentPortal} from '@angular/cdk/portal'; +import {Subscription} from 'rxjs/Subscription'; +import {map} from 'rxjs/operators/map'; +import {ScrollDispatchModule} from '@angular/cdk/scrolling'; +import {MockNgZone} from '@angular/cdk/testing'; +import { + OverlayModule, + Overlay, + OverlayConfig, + OverlayRef, + OverlayContainer, + FlexibleConnectedPositionStrategy, + ConnectedOverlayPositionChange, + ViewportRuler, +} from '../index'; + +// Default width and height of the overlay and origin panels throughout these tests. +const DEFAULT_HEIGHT = 30; +const DEFAULT_WIDTH = 60; + +describe('FlexibleConnectedPositionStrategy', () => { + let overlay: Overlay; + let overlayContainer: OverlayContainer; + let zone: MockNgZone; + let overlayRef: OverlayRef; + let viewport: ViewportRuler; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ScrollDispatchModule, OverlayModule, OverlayTestModule], + providers: [{provide: NgZone, useFactory: () => zone = new MockNgZone()}] + }); + + inject([Overlay, OverlayContainer, ViewportRuler], + (o: Overlay, oc: OverlayContainer, v: ViewportRuler) => { + overlay = o; + overlayContainer = oc; + viewport = v; + })(); + }); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + + if (overlayRef) { + overlayRef.dispose(); + } + }); + + function attachOverlay(config: OverlayConfig) { + overlayRef = overlay.create(config); + overlayRef.attach(new ComponentPortal(TestOverlay)); + zone.simulateZoneExit(); + } + + it('should throw when attempting to attach to multiple different overlays', () => { + const origin = new ElementRef(document.createElement('div')); + const positionStrategy = overlay.position() + .flexibleConnectedTo(origin) + .withPositions([{ + overlayX: 'start', + overlayY: 'top', + originX: 'start', + originY: 'bottom' + }]); + + attachOverlay({positionStrategy}); + expect(() => attachOverlay({positionStrategy})).toThrow(); + }); + + it('should not throw when trying to apply after being disposed', () => { + const origin = new ElementRef(document.createElement('div')); + const positionStrategy = overlay.position() + .flexibleConnectedTo(origin) + .withPositions([{ + overlayX: 'start', + overlayY: 'top', + originX: 'start', + originY: 'bottom' + }]); + + attachOverlay({positionStrategy}); + overlayRef.dispose(); + + expect(() => positionStrategy.apply()).not.toThrow(); + }); + + it('should not throw when trying to re-apply the last position after being disposed', () => { + const origin = new ElementRef(document.createElement('div')); + const positionStrategy = overlay.position() + .flexibleConnectedTo(origin) + .withPositions([{ + overlayX: 'start', + overlayY: 'top', + originX: 'start', + originY: 'bottom' + }]); + + attachOverlay({positionStrategy}); + overlayRef.dispose(); + + expect(() => positionStrategy.reapplyLastPosition()).not.toThrow(); + }); + + describe('without flexible dimensions and pushing', () => { + const ORIGIN_HEIGHT = DEFAULT_HEIGHT; + const ORIGIN_WIDTH = DEFAULT_WIDTH; + const OVERLAY_HEIGHT = DEFAULT_HEIGHT; + const OVERLAY_WIDTH = DEFAULT_WIDTH; + + let originElement: HTMLElement; + let positionStrategy: FlexibleConnectedPositionStrategy; + + beforeEach(() => { + // The origin and overlay elements need to be in the document body in order to have geometry. + originElement = createPositionedBlockElement(); + document.body.appendChild(originElement); + positionStrategy = overlay.position() + .flexibleConnectedTo(new ElementRef(originElement)) + .withFlexibleHeight(false) + .withFlexibleWidth(false) + .withPush(false); + }); + + afterEach(() => { + document.body.removeChild(originElement); + }); + + describe('when not near viewport edge, not scrolled', () => { + // Place the original element close to the center of the window. + // (1024 / 2, 768 / 2). It's not exact, since outerWidth/Height includes browser + // chrome, but it doesn't really matter for these tests. + const ORIGIN_LEFT = 500; + const ORIGIN_TOP = 350; + + beforeEach(() => { + originElement.style.left = `${ORIGIN_LEFT}px`; + originElement.style.top = `${ORIGIN_TOP}px`; + }); + + // Preconditions are set, now just run the full set of simple position tests. + runSimplePositionTests(); + }); + + describe('when scrolled', () => { + // Place the original element decently far outside the unscrolled document (1024x768). + const ORIGIN_LEFT = 2500; + const ORIGIN_TOP = 2500; + + // Create a very large element that will make the page scrollable. + let veryLargeElement: HTMLElement = document.createElement('div'); + veryLargeElement.style.width = '4000px'; + veryLargeElement.style.height = '4000px'; + + beforeEach(() => { + // Scroll the page such that the origin element is roughly in the + // center of the visible viewport (2500 - 1024/2, 2500 - 768/2). + document.body.appendChild(veryLargeElement); + document.body.scrollTop = 2100; + document.body.scrollLeft = 2100; + + originElement.style.top = `${ORIGIN_TOP}px`; + originElement.style.left = `${ORIGIN_LEFT}px`; + }); + + afterEach(() => { + document.body.removeChild(veryLargeElement); + document.body.scrollTop = 0; + document.body.scrollLeft = 0; + }); + + // Preconditions are set, now just run the full set of simple position tests. + runSimplePositionTests(); + }); + + describe('when near viewport edge', () => { + it('should reposition the overlay if it would go off the top of the screen', () => { + originElement.style.top = '5px'; + originElement.style.left = '200px'; + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([ + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom' + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + } + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left)); + }); + + it('should reposition the overlay if it would go off the left of the screen', () => { + originElement.style.top = '200px'; + originElement.style.left = '5px'; + + const originRect = originElement.getBoundingClientRect(); + const originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + + positionStrategy.withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + }, + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center' + } + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originCenterY - (OVERLAY_HEIGHT / 2))); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.right)); + }); + + it('should reposition the overlay if it would go off the bottom of the screen', () => { + originElement.style.bottom = '25px'; + originElement.style.left = '200px'; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom' + } + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top)); + expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.right)); + }); + + it('should reposition the overlay if it would go off the right of the screen', () => { + originElement.style.top = '200px'; + originElement.style.right = '25px'; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([ + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center' + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + } + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); + expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.left)); + }); + + it('should recalculate and set the last position with recalculateLastPosition()', () => { + // Push the trigger down so the overlay doesn't have room to open on the bottom. + originElement.style.bottom = '25px'; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom' + } + ]); + + // This should apply the fallback position, as the original position won't fit. + attachOverlay({positionStrategy}); + + // Now make the overlay small enough to fit in the first preferred position. + overlayRef.overlayElement.style.height = '15px'; + + // This should only re-align in the last position, even though the first would fit. + positionStrategy.reapplyLastPosition(); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top), + 'Expected overlay to be re-aligned to the trigger in the previous position.'); + }); + + it('should default to the initial position, if no positions fit in the viewport', () => { + // Make the origin element taller than the viewport. + originElement.style.height = '1000px'; + originElement.style.top = '0'; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom' + }]); + + attachOverlay({positionStrategy}); + positionStrategy.reapplyLastPosition(); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top), + 'Expected overlay to be re-aligned to the trigger in the initial position.'); + }); + + it('should position a panel properly when rtl', () => { + // must make the overlay longer than the origin to properly test attachment + overlayRef.overlayElement.style.width = `500px`; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({ + positionStrategy, + direction: 'rtl' + }); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); + expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.right)); + }); + + it('should position a panel with the x offset provided', () => { + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top', + offsetX: 10 + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left + 10)); + }); + + it('should position a panel with the y offset provided', () => { + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top', + offsetY: 50 + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top + 50)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left)); + }); + + it('should allow for the fallback positions to specify their own offsets', () => { + originElement.style.bottom = '0'; + originElement.style.left = '50%'; + originElement.style.position = 'fixed'; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([ + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top', + offsetX: 50, + offsetY: 50 + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetX: -100, + offsetY: -100 + } + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top - 100)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left - 100)); + }); + + }); + + it('should account for the `offsetX` pushing the overlay out of the screen', () => { + // Position the element so it would have enough space to fit. + originElement.style.top = '200px'; + originElement.style.left = '70px'; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([ + { + originX: 'start', + originY: 'top', + overlayX: 'end', + overlayY: 'top', + offsetX: -20 // Add enough of an offset to pull the element out of the viewport. + }, + { + originX: 'end', + originY: 'top', + overlayX: 'start', + overlayY: 'top' + } + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.right)); + }); + + it('should account for the `offsetY` pushing the overlay out of the screen', () => { + // Position the overlay so it would normally have enough space to fit. + originElement.style.bottom = '40px'; + originElement.style.left = '200px'; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 20 // Add enough of an offset for it to go off-screen. + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom' + } + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top)); + expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.right)); + }); + + it('should emit onPositionChange event when the position changes', () => { + originElement.style.top = '200px'; + originElement.style.right = '25px'; + + positionStrategy.withPositions([ + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center' + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + } + ]); + + const positionChangeHandler = jasmine.createSpy('positionChangeHandler'); + const subscription = positionStrategy.positionChanges.subscribe(positionChangeHandler); + + attachOverlay({positionStrategy}); + + const latestCall = positionChangeHandler.calls.mostRecent(); + + expect(positionChangeHandler).toHaveBeenCalled(); + expect(latestCall.args[0] instanceof ConnectedOverlayPositionChange) + .toBe(true, `Expected strategy to emit an instance of ConnectedOverlayPositionChange.`); + + // If the strategy is re-applied and the initial position would now fit, + // the position change event should be emitted again. + originElement.style.top = '200px'; + originElement.style.left = '200px'; + + overlayRef.updatePosition(); + + expect(positionChangeHandler).toHaveBeenCalledTimes(2); + + subscription.unsubscribe(); + }); + + it('should emit the onPositionChange event even if none of the positions fit', () => { + originElement.style.bottom = '25px'; + originElement.style.right = '25px'; + + positionStrategy.withPositions([ + { + originX: 'end', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + } + ]); + + const positionChangeHandler = jasmine.createSpy('positionChangeHandler'); + const subscription = positionStrategy.positionChanges.subscribe(positionChangeHandler); + + attachOverlay({positionStrategy}); + + expect(positionChangeHandler).toHaveBeenCalled(); + + subscription.unsubscribe(); + }); + + it('should pick the fallback position that shows the largest area of the element', () => { + originElement.style.top = '200px'; + originElement.style.right = '25px'; + + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([ + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center' + }, + { + originX: 'end', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom' + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'top' + } + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left)); + }); + + it('should re-use the preferred position when re-applying while locked in', () => { + positionStrategy.withPositions([ + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center' + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + } + ]) + .withLockedPosition(); + + const recalcSpy = spyOn(positionStrategy, 'reapplyLastPosition'); + + attachOverlay({positionStrategy}); + + expect(recalcSpy).not.toHaveBeenCalled(); + + positionStrategy.apply(); + + expect(recalcSpy).toHaveBeenCalled(); + }); + + /** + * Run all tests for connecting the overlay to the origin such that first preferred + * position does not go off-screen. We do this because there are several cases where we + * want to run the exact same tests with different preconditions (e.g., not scroll, scrolled, + * different element sized, etc.). + */ + function runSimplePositionTests() { + it('should position a panel below, left-aligned', () => { + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left)); + }); + + it('should position to the right, center aligned vertically', () => { + const originRect = originElement.getBoundingClientRect(); + const originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + + positionStrategy.withPositions([{ + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originCenterY - (OVERLAY_HEIGHT / 2))); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.right)); + }); + + it('should position to the left, below', () => { + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); + expect(Math.round(overlayRect.right)).toBe(Math.round(originRect.left)); + }); + + it('should position above, right aligned', () => { + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([{ + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.round(overlayRect.bottom)).toBe(Math.round(originRect.top)); + expect(Math.round(overlayRect.right)).toBe(Math.round(originRect.right)); + }); + + it('should position below, centered', () => { + const originRect = originElement.getBoundingClientRect(); + const originCenterX = originRect.left + (ORIGIN_WIDTH / 2); + + positionStrategy.withPositions([{ + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originCenterX - (OVERLAY_WIDTH / 2))); + }); + + it('should center the overlay on the origin', () => { + const originRect = originElement.getBoundingClientRect(); + + positionStrategy.withPositions([{ + originX: 'center', + originY: 'center', + overlayX: 'center', + overlayY: 'center' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left)); + }); + } + }); + + describe('with pushing', () => { + const OVERLAY_HEIGHT = DEFAULT_HEIGHT; + const OVERLAY_WIDTH = DEFAULT_WIDTH; + + let originElement: HTMLElement; + let positionStrategy: FlexibleConnectedPositionStrategy; + + beforeEach(() => { + originElement = createPositionedBlockElement(); + document.body.appendChild(originElement); + positionStrategy = overlay.position() + .flexibleConnectedTo(new ElementRef(originElement)) + .withFlexibleHeight(false) + .withFlexibleWidth(false) + .withPush(); + }); + + afterEach(() => { + document.body.removeChild(originElement); + }); + + it('should be able to push an overlay into the viewport when it goes out on the right', () => { + originElement.style.top = '200px'; + originElement.style.right = `${-OVERLAY_WIDTH / 2}px`; + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.right)).toBe(viewport.getViewportSize().width); + }); + + it('should be able to push an overlay into the viewport when it goes out on the left', () => { + originElement.style.top = '200px'; + originElement.style.left = `${-OVERLAY_WIDTH / 2}px`; + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.left)).toBe(0); + }); + + it('should be able to push an overlay into the viewport when it goes out on the top', () => { + originElement.style.top = `${-OVERLAY_HEIGHT * 2}px`; + originElement.style.left = '200px'; + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(0); + }); + + it('should be able to push an overlay into the viewport when it goes out on the bottom', () => { + originElement.style.bottom = `${-OVERLAY_HEIGHT / 2}px`; + originElement.style.left = '200px'; + + positionStrategy.withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.bottom)).toBe(viewport.getViewportSize().height); + }); + + it('should set a margin when pushing the overlay into the viewport horizontally', () => { + originElement.style.top = '200px'; + originElement.style.left = `${-OVERLAY_WIDTH / 2}px`; + + positionStrategy + .withViewportMargin(15) + .withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.left)).toBe(15); + }); + + it('should set a margin when pushing the overlay into the viewport vertically', () => { + positionStrategy.withViewportMargin(15); + + originElement.style.top = `${-OVERLAY_HEIGHT * 2}px`; + originElement.style.left = '200px'; + + positionStrategy + .withViewportMargin(15) + .withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(15); + }); + + }); + + describe('with flexible dimensions', () => { + const OVERLAY_HEIGHT = DEFAULT_HEIGHT; + const OVERLAY_WIDTH = DEFAULT_WIDTH; + + let originElement: HTMLElement; + let positionStrategy: FlexibleConnectedPositionStrategy; + + beforeEach(() => { + originElement = createPositionedBlockElement(); + document.body.appendChild(originElement); + positionStrategy = overlay.position().flexibleConnectedTo(new ElementRef(originElement)); + }); + + afterEach(() => { + document.body.removeChild(originElement); + }); + + it('should align the overlay to `flex-start` when the content is flowing to the right', () => { + positionStrategy + .withFlexibleWidth() + .withFlexibleHeight() + .withPositions([{ + overlayY: 'top', + overlayX: 'start', + originY: 'bottom', + originX: 'start' + }]); + + attachOverlay({positionStrategy}); + + expect(overlayRef.overlayElement.style.justifyContent).toBe('flex-start'); + }); + + it('should align the overlay to `flex-end` when the content is flowing to the left', () => { + positionStrategy + .withFlexibleWidth() + .withFlexibleHeight() + .withPositions([{ + overlayY: 'top', + overlayX: 'end', + originY: 'bottom', + originX: 'end' + }]); + + attachOverlay({positionStrategy}); + + expect(overlayRef.overlayElement.style.justifyContent).toBe('flex-end'); + }); + + it('should align the overlay to `center` when the content is centered', () => { + positionStrategy + .withFlexibleWidth() + .withFlexibleHeight() + .withPositions([{ + overlayY: 'top', + overlayX: 'center', + originY: 'bottom', + originX: 'center' + }]); + + attachOverlay({positionStrategy}); + + expect(overlayRef.overlayElement.style.justifyContent).toBe('center'); + }); + + // TODO(crisbeto): investigate failure in older Safari + // it('should support offsets when centering', () => { + // originElement.style.top = '200px'; + // originElement.style.left = '200px'; + + // positionStrategy + // .withFlexibleWidth() + // .withFlexibleHeight() + // .withPositions([{ + // overlayY: 'center', + // overlayX: 'center', + // originY: 'center', + // originX: 'center', + // offsetY: 20, + // offsetX: -15 + // }]); + + // attachOverlay({positionStrategy}); + + // const originRect = originElement.getBoundingClientRect(); + // const originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + // const originCenterX = originRect.left + (ORIGIN_WIDTH / 2); + + // const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + // const overlayCenterY = overlayRect.top + (OVERLAY_HEIGHT / 2); + // const overlayCenterX = overlayRect.left + (OVERLAY_WIDTH / 2); + + // expect(overlayRef.overlayElement.style.transform) + // .toBe('translateX(-15px) translateY(20px)'); + // expect(Math.floor(overlayCenterY)).toBe(Math.floor(originCenterY) + 20); + // expect(Math.floor(overlayCenterX)).toBe(Math.floor(originCenterX) - 15); + // }); + + it('should become scrollable when it hits the viewport edge with a flexible height', () => { + originElement.style.left = '200px'; + originElement.style.bottom = `${OVERLAY_HEIGHT - 10}px`; + + positionStrategy + .withFlexibleHeight() + .withPositions([{ + overlayY: 'top', + overlayX: 'start', + originY: 'bottom', + originX: 'start' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.height)).toBe(OVERLAY_HEIGHT - 10); + expect(Math.floor(overlayRect.bottom)).toBe(viewport.getViewportSize().height); + }); + + it('should become scrollable when it hits the viewport edge with a flexible width', () => { + originElement.style.top = '200px'; + originElement.style.right = '-20px'; + + positionStrategy + .withFlexibleWidth() + .withPositions([{ + overlayY: 'top', + overlayX: 'start', + originY: 'bottom', + originX: 'start' + }]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.width)).toBe(OVERLAY_WIDTH - 20); + expect(Math.floor(overlayRect.right)).toBe(viewport.getViewportSize().width); + }); + + it('should not collapse the height if the size is less than the minHeight', () => { + originElement.style.left = '200px'; + originElement.style.bottom = `${OVERLAY_HEIGHT - 10}px`; + + positionStrategy + .withFlexibleHeight() + .withPositions([{ + overlayY: 'top', + overlayX: 'start', + originY: 'bottom', + originX: 'start' + }]); + + attachOverlay({ + positionStrategy, + minHeight: OVERLAY_HEIGHT - 5 + }); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.height)).toBe(OVERLAY_HEIGHT); + }); + + it('should not collapse the width if the size is less than the minWidth', () => { + originElement.style.top = '200px'; + originElement.style.right = '-20px'; + + positionStrategy + .withFlexibleWidth() + .withPositions([{ + overlayY: 'top', + overlayX: 'start', + originY: 'bottom', + originX: 'start' + }]); + + attachOverlay({ + minWidth: OVERLAY_WIDTH - 10, + positionStrategy + }); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.width)).toBe(OVERLAY_WIDTH); + }); + + it('should take `weight` into account when determining which position to pick', () => { + originElement.style.top = '200px'; + originElement.style.right = '25px'; + + positionStrategy + .withFlexibleWidth() + .withPositions([ + { + originX: 'end', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + weight: 3 + }, + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center' + } + ]); + + attachOverlay({positionStrategy}); + + const originRect = originElement.getBoundingClientRect(); + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + + expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top)); + expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.right)); + }); + + it('should be able to opt-in to having the overlay grow after it was opened', () => { + originElement.style.left = '200px'; + originElement.style.bottom = `${OVERLAY_HEIGHT - 10}px`; + + positionStrategy + .withFlexibleHeight() + .withGrowAfterOpen() + .withPositions([{ + overlayY: 'top', + overlayX: 'start', + originY: 'bottom', + originX: 'start' + }]); + + attachOverlay({positionStrategy}); + + let overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + + // The overlay should be scrollable, because it hit the viewport edge. + expect(Math.floor(overlayRect.height)).toBe(OVERLAY_HEIGHT - 10); + + originElement.style.bottom = '200px'; + overlayRef.updatePosition(); + overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + + // The overlay should be back to full height. + expect(Math.floor(overlayRect.height)).toBe(OVERLAY_HEIGHT); + }); + + }); + + describe('onPositionChange with scrollable view properties', () => { + let scrollable: HTMLDivElement; + let positionChangeHandler: jasmine.Spy; + let onPositionChangeSubscription: Subscription; + + beforeEach(() => { + // Set up the origin + const originElement = createBlockElement(); + originElement.style.margin = '0 1000px 1000px 0'; // Added so that the container scrolls + + // Create a scrollable container and put the origin inside + scrollable = createOverflowContainerElement(); + document.body.appendChild(scrollable); + scrollable.appendChild(originElement); + + // Create a strategy with knowledge of the scrollable container + const strategy = overlay.position() + .flexibleConnectedTo(new ElementRef(originElement)) + .withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }]); + + strategy.withScrollableContainers([ + new CdkScrollable(new ElementRef(scrollable), null!, null!) + ]); + + positionChangeHandler = jasmine.createSpy('positionChange handler'); + onPositionChangeSubscription = strategy.positionChanges + .pipe(map(event => event.scrollableViewProperties)) + .subscribe(positionChangeHandler); + + attachOverlay({positionStrategy: strategy}); + }); + + afterEach(() => { + onPositionChangeSubscription.unsubscribe(); + document.body.removeChild(scrollable); + }); + + it('should not have origin or overlay clipped or out of view without scroll', () => { + expect(positionChangeHandler).toHaveBeenCalledWith(jasmine.objectContaining({ + isOriginClipped: false, + isOriginOutsideView: false, + isOverlayClipped: false, + isOverlayOutsideView: false + })); + }); + + it('should evaluate if origin is clipped if scrolled slightly down', () => { + scrollable.scrollTop = 10; // Clip the origin by 10 pixels + overlayRef.updatePosition(); + + expect(positionChangeHandler).toHaveBeenCalledWith(jasmine.objectContaining({ + isOriginClipped: true, + isOriginOutsideView: false, + isOverlayClipped: false, + isOverlayOutsideView: false + })); + }); + + it('should evaluate if origin is out of view and overlay is clipped if scrolled enough', () => { + scrollable.scrollTop = 31; // Origin is 30 pixels, move out of view and clip the overlay 1px + overlayRef.updatePosition(); + + expect(positionChangeHandler).toHaveBeenCalledWith(jasmine.objectContaining({ + isOriginClipped: true, + isOriginOutsideView: true, + isOverlayClipped: true, + isOverlayOutsideView: false + })); + }); + + it('should evaluate the overlay and origin are both out of the view', () => { + scrollable.scrollTop = 61; // Scroll by overlay height + origin height + 1px + overlayRef.updatePosition(); + + expect(positionChangeHandler).toHaveBeenCalledWith(jasmine.objectContaining({ + isOriginClipped: true, + isOriginOutsideView: true, + isOverlayClipped: true, + isOverlayOutsideView: true + })); + }); + }); + + describe('positioning properties', () => { + let originElement: HTMLElement; + let positionStrategy: FlexibleConnectedPositionStrategy; + + beforeEach(() => { + originElement = createPositionedBlockElement(); + document.body.appendChild(originElement); + positionStrategy = overlay.position().flexibleConnectedTo(new ElementRef(originElement)); + }); + + afterEach(() => { + document.body.removeChild(originElement); + }); + + describe('in ltr', () => { + it('should use `left` when positioning an element at the start', () => { + positionStrategy.withPositions([{ + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + expect(overlayRef.overlayElement.style.left).toBeTruthy(); + expect(overlayRef.overlayElement.style.right).toBeFalsy(); + }); + + it('should use `right` when positioning an element at the end', () => { + positionStrategy.withPositions([{ + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + expect(overlayRef.overlayElement.style.right).toBeTruthy(); + expect(overlayRef.overlayElement.style.left).toBeFalsy(); + }); + + }); + + describe('in rtl', () => { + it('should use `right` when positioning an element at the start', () => { + positionStrategy.withPositions([{ + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({ + positionStrategy, + direction: 'rtl' + }); + + expect(overlayRef.overlayElement.style.right).toBeTruthy(); + expect(overlayRef.overlayElement.style.left).toBeFalsy(); + }); + + it('should use `left` when positioning an element at the end', () => { + positionStrategy.withPositions([{ + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy, direction: 'rtl'}); + + expect(overlayRef.overlayElement.style.left).toBeTruthy(); + expect(overlayRef.overlayElement.style.right).toBeFalsy(); + }); + }); + + describe('vertical', () => { + it('should use `top` when positioning at element along the top', () => { + positionStrategy.withPositions([{ + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top' + }]); + + attachOverlay({positionStrategy}); + + expect(overlayRef.overlayElement.style.top).toBeTruthy(); + expect(overlayRef.overlayElement.style.bottom).toBeFalsy(); + }); + + it('should use `bottom` when positioning at element along the bottom', () => { + positionStrategy.withPositions([{ + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'bottom' + }]); + + attachOverlay({positionStrategy}); + + expect(overlayRef.overlayElement.style.bottom).toBeTruthy(); + expect(overlayRef.overlayElement.style.top).toBeFalsy(); + }); + }); + + }); + +}); + +/** Creates an absolutely positioned, display: block element with a default size. */ +function createPositionedBlockElement() { + const element = createBlockElement(); + element.style.position = 'absolute'; + return element; +} + +/** Creates a block element with a default size. */ +function createBlockElement() { + const element = document.createElement('div'); + element.style.width = `${DEFAULT_WIDTH}px`; + element.style.height = `${DEFAULT_HEIGHT}px`; + element.style.backgroundColor = 'rebeccapurple'; + element.style.zIndex = '100'; + return element; +} + +/** Creates an overflow container with a set height and width with margin. */ +function createOverflowContainerElement() { + const element = document.createElement('div'); + element.style.position = 'relative'; + element.style.overflow = 'auto'; + element.style.height = '300px'; + element.style.width = '300px'; + element.style.margin = '100px'; + return element; +} + + +@Component({ + template: `
` +}) +class TestOverlay { } + + +@NgModule({ + imports: [OverlayModule, PortalModule], + exports: [TestOverlay], + declarations: [TestOverlay], + entryComponents: [TestOverlay], +}) +class OverlayTestModule { } diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.ts new file mode 100644 index 000000000000..696cf6ce313e --- /dev/null +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.ts @@ -0,0 +1,944 @@ +/** + * @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 {PositionStrategy} from './position-strategy'; +import {ElementRef} from '@angular/core'; +import {ViewportRuler, CdkScrollable} from '@angular/cdk/scrolling'; +import { + ConnectedOverlayPositionChange, + ConnectionPositionPair, + ScrollingVisibility, +} from './connected-position'; +import {Subject} from 'rxjs/Subject'; +import {Subscription} from 'rxjs/Subscription'; +import {Observable} from 'rxjs/Observable'; +import {OverlayRef} from '../overlay-ref'; +import {isElementScrolledOutsideView, isElementClippedByScrolling} from './scroll-clip'; + + +// TODO: refactor clipping detection into a separate thing (part of scrolling module) +// TODO: attribute selector to specify the transform-origin inside the overlay content +// TODO: flexible position + centering doesn't work on IE11 (works on Edge). +// TODO: doesn't handle both flexible width and height when it has to scroll along both axis. + +/** + * 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 + * a point on the origin element that is connected to a point on the overlay element. For example, + * a basic dropdown is connecting the bottom-left corner of the origin to the top-left corner + * of the overlay. + */ +export class FlexibleConnectedPositionStrategy implements PositionStrategy { + /** The overlay to which this strategy is attached. */ + private _overlayRef: OverlayRef; + + /** Whether we're performing the very first positioning of the overlay. */ + private _isInitialRender = true; + + /** Last size used for the bounding box. Used to avoid resizing the overlay after open. */ + private _lastBoundingBoxSize = {width: 0, height: 0}; + + /** Whether the overlay was pushed in a previous positioning. */ + private _isPushed = false; + + /** Whether the overlay can be pushed on-screen on the initial open. */ + private _canPush = true; + + /** Whether the overlay can grow via flexible width/height after the initial open. */ + private _growAfterOpen = false; + + /** Whether the overlay's height can be constrained to fit within the viewport. */ + private _hasFlexibleHeight = true; + + /** Whether the overlay's width can be constrained to fit within the viewport. */ + private _hasFlexibleWidth = true; + + /** Whether the overlay position is locked. */ + private _positionLocked = false; + + /** Cached origin dimensions */ + private _originRect: ClientRect; + + /** Cached overlay dimensions */ + private _overlayRect: ClientRect; + + /** Cached viewport dimensions */ + private _viewportRect: ClientRect; + + /** Amount of space that must be maintained between the overlay and the edge of the viewport. */ + private _viewportMargin: number = 0; + + /** The Scrollable containers used to check scrollable view properties on position change. */ + private scrollables: CdkScrollable[] = []; + + /** Ordered list of preferred positions, from most to least desirable. */ + _preferredPositions: ConnectionPositionPair[] = []; + + /** The origin element against which the overlay will be positioned. */ + private _origin: HTMLElement; + + /** The overlay pane element. */ + private _pane: HTMLElement; + + /** Whether the strategy has been disposed of already. */ + private _isDisposed: boolean; + + /** + * Parent element for the overlay panel used to constrain the overlay panel's size to fit + * within the viewport. + */ + private _boundingBox: HTMLElement | null; + + /** The last position to have been calculated as the best fit position. */ + private _lastPosition: ConnectedPosition; + + /** Subject that emits whenever the position changes. */ + private _positionChanges = new Subject(); + + /** Subscription to viewport size changes. */ + private _resizeSubscription = Subscription.EMPTY; + + /** Observable sequence of position changes. */ + positionChanges: Observable = + this._positionChanges.asObservable(); + + /** Ordered list of preferred positions, from most to least desirable. */ + get positions() { + return this._preferredPositions; + } + + constructor( + private _connectedTo: ElementRef, + private _viewportRuler: ViewportRuler, + private _document: Document) { + this._origin = this._connectedTo.nativeElement; + } + + /** Attaches this position strategy to an overlay. */ + attach(overlayRef: OverlayRef): void { + if (this._overlayRef && overlayRef !== this._overlayRef) { + throw Error('This position strategy is already attached to an overlay'); + } + + overlayRef.hostElement.classList.add('cdk-overlay-connected-position-bounding-box'); + + this._overlayRef = overlayRef; + this._boundingBox = overlayRef.hostElement!; + this._pane = overlayRef.overlayElement; + this._resizeSubscription.unsubscribe(); + this._resizeSubscription = this._viewportRuler.change().subscribe(() => this.apply()); + } + + /** + * Updates the position of the overlay element, using whichever preferred position relative + * to the origin best fits on-screen. + * + * The selection of a position goes as follows: + * - If any positions fit completely within the viewport as-is, + * choose the first position that does so. + * - If flexible dimensions are enabled and at least one satifies the given minimum width/height, + * choose the position with the greatest available size modified by the positions' weight. + * - If pushing is enabled, take the position that went off-screen the least and push it + * on-screen. + * - If none of the previous criteria were met, use the position that goes off-screen the least. + * @docs-private + */ + apply(): void { + // We shouldn't do anything if the strategy was disposed. + if (this._isDisposed) { + return; + } + + // If the position has been applied already (e.g. when the overlay was opened) and the + // consumer opted into locking in the position, re-use the old position, in order to + // prevent the overlay from jumping around. + if (!this._isInitialRender && this._positionLocked && this._lastPosition) { + this.reapplyLastPosition(); + return; + } + + this._resetBoundingBoxStyles(); + + // We need the bounding rects for the origin and the overlay to determine how to position + // the overlay relative to the origin. + // We use the viewport rect to determine whether a position would go off-screen. + this._viewportRect = this._getNarrowedViewportRect(); + this._originRect = this._origin.getBoundingClientRect(); + this._overlayRect = this._pane.getBoundingClientRect(); + + const originRect = this._originRect; + const overlayRect = this._overlayRect; + const viewportRect = this._viewportRect; + + // Positions where the overlay will fit with flexible dimensions. + const flexibleFits: FlexibleFit[] = []; + + // Fallback if none of the preferred positions fit within the viewport. + let fallback: FallbackPosition | undefined; + + // Go through each of the preferred positions looking for a good fit. + // If a good fit is found, it will be applied immediately. + for (let pos of this._preferredPositions) { + // Get the exact (x, y) coordinate for the point-of-origin on the origin element. + let originPoint = this._getOriginPoint(originRect, pos); + + // From that point-of-origin, get the exact (x, y) coordinate for the top-left corner of the + // overlay in this position. We use the top-left corner for calculations and later translate + // this into an appropriate (top, left, bottom, right) style. + let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, pos); + + // Calculate how well the overlay would fit into the viewport with this point. + let overlayFit = this._getOverlayFit(overlayPoint, overlayRect, viewportRect, pos); + + // If the overlay, without any further work, fits into the viewport, use this position. + if (overlayFit.isCompletelyWithinViewport) { + this._isPushed = false; + this._applyPosition(pos, originPoint); + return; + } + + // If the overlay has flexible dimensions, we can use this position + // so long as there's enough space for the minimum dimensions. + if (this._canFitWithFlexibleDimensions(overlayFit, overlayPoint, viewportRect)) { + // Save positions where the overlay will fit with flexible dimensions. We will use these + // if none of the positions fit *without* flexible dimensions. + flexibleFits.push({ + position: pos, + origin: originPoint, + overlayRect, + boundingBoxRect: this._calculateBoundingBoxRect(originPoint, pos) + }); + + continue; + } + + // If the current preferred position does not fit on the screen, remember the position + // if it has more visible area on-screen than we've seen and move onto the next preferred + // position. + if (!fallback || fallback.overlayFit.visibleArea < overlayFit.visibleArea) { + fallback = {overlayFit, overlayPoint, originPoint, position: pos, overlayRect}; + } + } + + // If there are any positions where the overlay would fit with flexible dimensions, choose the + // one that has the greatest area available modified by the position's weight + if (flexibleFits.length) { + let bestFit: FlexibleFit | null = null; + let bestScore = -1; + for (const fit of flexibleFits) { + const score = + fit.boundingBoxRect.width * fit.boundingBoxRect.height * (fit.position.weight || 1); + if (score > bestScore) { + bestScore = score; + bestFit = fit; + } + } + + this._isPushed = false; + this._applyPosition(bestFit!.position, bestFit!.origin); + return; + } + + // When none of the preferred positions fit within the viewport, take the position + // that went off-screen the least and attempt to push it on-screen. + if (this._canPush) { + // TODO(jelbourn): after pushing, the opening "direction" of the overlay might not make sense. + this._isPushed = true; + this._applyPosition(fallback!.position, fallback!.originPoint); + return; + } + + // All options for getting the overlay within the viewport have been exhausted, so go with the + // position that went off-screen the least. + this._applyPosition(fallback!.position, fallback!.originPoint); + } + + detach() { + this._resizeSubscription.unsubscribe(); + } + + /** Cleanup after the element gets destroyed. */ + dispose() { + if (!this._isDisposed) { + this.detach(); + this._boundingBox = null; + this._positionChanges.complete(); + this._isDisposed = true; + } + } + + /** + * This re-aligns the overlay element with the trigger in its last calculated position, + * even if a position higher in the "preferred positions" list would now fit. This + * allows one to re-align the panel without changing the orientation of the panel. + */ + reapplyLastPosition(): void { + if (!this._isDisposed) { + this._originRect = this._origin.getBoundingClientRect(); + this._overlayRect = this._pane.getBoundingClientRect(); + this._viewportRect = this._getNarrowedViewportRect(); + + const lastPosition = this._lastPosition || this._preferredPositions[0]; + const originPoint = this._getOriginPoint(this._originRect, lastPosition); + + this._applyPosition(lastPosition, originPoint); + } + } + + /** + * Sets the list of Scrollable containers that host the origin element so that + * on reposition we can evaluate if it or the overlay has been clipped or outside view. Every + * Scrollable must be an ancestor element of the strategy's origin element. + */ + withScrollableContainers(scrollables: CdkScrollable[]) { + this.scrollables = scrollables; + } + + /** + * Adds a new preferred fallback position. + * @param positions List of positions options for this overlay. + */ + withPositions(positions: ConnectedPosition[]): this { + this._preferredPositions = positions; + return this; + } + + /** + * Sets a minimum distance the ovelray may be positioned to the edge of the viewport. + * @param margin Required margin between the overlay and the viewport edge in pixels. + */ + withViewportMargin(margin: number): this { + this._viewportMargin = margin; + return this; + } + + /** Sets whether the overlay's height can be constrained to fit within the viewport. */ + withFlexibleHeight(flexibleHeight = true): this { + this._hasFlexibleHeight = flexibleHeight; + return this; + } + + /** Sets whether the overlay's width can be constrained to fit within the viewport. */ + withFlexibleWidth(flexibleWidth = true): this { + this._hasFlexibleWidth = flexibleWidth; + return this; + } + + /** Sets whether the overlay can grow after the initial open via flexible width/height. */ + withGrowAfterOpen(growAfterOpen = true): this { + this._growAfterOpen = growAfterOpen; + return this; + } + + /** Sets whether the overlay can be pushed on-screen if none of the provided positions fit. */ + withPush(canPush = true): this { + this._canPush = canPush; + return this; + } + + /** + * Sets whether the overlay's position should be locked in after it is positioned + * initially. When an overlay is locked in, it won't attempt to reposition itself + * when the position is re-applied (e.g. when the user scrolls away). + * @param isLocked Whether the overlay should locked in. + */ + withLockedPosition(isLocked = true): this { + this._positionLocked = isLocked; + return this; + } + + /** + * Sets the origin element, relative to which to position the overlay. + * @param origin Reference to the new origin element. + */ + setOrigin(origin: ElementRef): this { + this._origin = origin.nativeElement; + return this; + } + + /** + * Gets the (x, y) coordinate of a connection point on the origin based on a relative position. + */ + private _getOriginPoint(originRect: ClientRect, pos: ConnectedPosition): Point { + let x: number; + if (pos.originX == 'center') { + // Note: when centering we should always use the `left` + // offset, otherwise the position will be wrong in RTL. + x = originRect.left + (originRect.width / 2); + } else { + const startX = this._isRtl() ? originRect.right : originRect.left; + const endX = this._isRtl() ? originRect.left : originRect.right; + x = pos.originX == 'start' ? startX : endX; + } + + let y: number; + if (pos.originY == 'center') { + y = originRect.top + (originRect.height / 2); + } else { + y = pos.originY == 'top' ? originRect.top : originRect.bottom; + } + + return {x, y}; + } + + + /** + * Gets the (x, y) coordinate of the top-left corner of the overlay given a given position and + * origin point to which the overlay should be connected. + */ + private _getOverlayPoint( + originPoint: Point, + overlayRect: ClientRect, + pos: ConnectedPosition): Point { + + // Calculate the (overlayStartX, overlayStartY), the start of the + // potential overlay position relative to the origin point. + let overlayStartX: number; + if (pos.overlayX == 'center') { + overlayStartX = -overlayRect.width / 2; + } else if (pos.overlayX === 'start') { + overlayStartX = this._isRtl() ? -overlayRect.width : 0; + } else { + overlayStartX = this._isRtl() ? 0 : -overlayRect.width; + } + + let overlayStartY: number; + if (pos.overlayY == 'center') { + overlayStartY = -overlayRect.height / 2; + } else { + overlayStartY = pos.overlayY == 'top' ? 0 : -overlayRect.height; + } + + // The (x, y) coordinates of the overlay. + return { + x: originPoint.x + overlayStartX, + y: originPoint.y + overlayStartY, + }; + } + + /** Gets how well an overlay at the given point will fit within the viewport. */ + private _getOverlayFit(point: Point, overlay: ClientRect, viewport: ClientRect, + position: ConnectedPosition): OverlayFit { + + let {x, y} = point; + + // Account for the offsets since they could push the overlay out of the viewport. + if (position.offsetX) { + x += position.offsetX; + } + + if (position.offsetY) { + y += position.offsetY; + } + + // How much the overlay would overflow at this position, on each side. + let leftOverflow = 0 - x; + let rightOverflow = (x + overlay.width) - viewport.width; + let topOverflow = 0 - y; + let bottomOverflow = (y + overlay.height) - viewport.height; + + // Visible parts of the element on each axis. + let visibleWidth = this._subtractOverflows(overlay.width, leftOverflow, rightOverflow); + let visibleHeight = this._subtractOverflows(overlay.height, topOverflow, bottomOverflow); + let visibleArea = visibleWidth * visibleHeight; + + return { + visibleArea, + isCompletelyWithinViewport: (overlay.width * overlay.height) === visibleArea, + fitsInViewportVertically: visibleHeight === overlay.height, + fitsInViewportHorizontally: visibleWidth == overlay.width, + }; + } + + /** + * Whether the overlay can fit within the viewport when it may resize either its width or height. + * @param fit How well the overlay fits in the viewport at some position. + * @param point The (x, y) coordinates of the overlat at some position. + * @param viewport The geometry of the viewport. + */ + private _canFitWithFlexibleDimensions(fit: OverlayFit, point: Point, viewport: ClientRect) { + if (this._hasFlexibleWidth || this._hasFlexibleWidth) { + const availableHeight = viewport.bottom - point.y; + const availableWidth = viewport.right - point.x; + const minHeight = this._overlayRef.getConfig().minHeight || 0; + const minWidth = this._overlayRef.getConfig().minWidth || 0; + + const verticalFit = fit.fitsInViewportVertically || + (this._hasFlexibleHeight && minHeight <= availableHeight); + const horizontalFit = fit.fitsInViewportHorizontally || + (this._hasFlexibleWidth && minWidth <= availableWidth); + + return verticalFit && horizontalFit; + } + } + + /** + * Gets the point at which the overlay can be "pushed" on-screen. If the overlay is larger than + * the viewport, the top-left corner will be pushed on-screen (with overflow occuring on the + * right and bottom). + * + * @param start The starting point from which the overlay is pushed. + * @param overlay The overlay dimensions. + * @returns The point at which to position the overlay after pushing. This is effectively a new + * originPoint. + */ + private _pushOverlayOnScreen(start: Point, overlay: ClientRect): Point { + const viewport = this._viewportRect; + + // Determine how much the overlay goes outside the viewport on each side, which we'll use to + // decide which direction to push it. + const overflowRight = Math.max(start.x + overlay.width - viewport.right, 0); + const overflowBottom = Math.max(start.y + overlay.height - viewport.bottom, 0); + const overflowTop = Math.max(viewport.top - start.y, 0); + const overflowLeft = Math.max(viewport.left - start.x, 0); + + // Amount by which to push the overlay in each direction such that it remains on-screen. + let pushX, pushY = 0; + + // If the overlay fits completely within the bounds of the viewport, push it from whichever + // direction is goes off-screen. Otherwise, push the top-left corner such that its in the + // viewport and allow for the trailing end of the overlay to go out of bounds. + if (overlay.width <= viewport.width) { + pushX = overflowLeft || -overflowRight; + } else { + pushX = viewport.left - start.x; + } + + if (overlay.height <= viewport.height) { + pushY = overflowTop || -overflowBottom; + } else { + pushY = viewport.top - start.y; + } + + return { + x: start.x + pushX, + y: start.y + pushY, + }; + } + + /** + * Applies a computed position to the overlay and emits a position change. + * + * @param position The position preference + * @param originPoint The point on the origin element where the overlay is connected. + */ + private _applyPosition(position: ConnectedPosition, originPoint: Point) { + this._setOverlayElementStyles(originPoint, position); + this._setBoundingBoxStyles(originPoint, position); + + // Save the last connected position in case the position needs to be re-calculated. + this._lastPosition = position; + + // Notify that the position has been changed along with its change properties. + const scrollableViewProperties = this._getScrollVisibility(); + const changeEvent = new ConnectedOverlayPositionChange(position, scrollableViewProperties); + this._positionChanges.next(changeEvent); + this._isInitialRender = false; + } + + /** + * Gets the position and size of the overlay's sizing container. + * + * This method does no measuring and applies no styles so that we can cheaply compute the + * bounds for all positions and choose the best fit based on these results. + */ + private _calculateBoundingBoxRect(origin: Point, position: ConnectedPosition): BoundingBoxRect { + const viewport = this._viewportRect; + let height, top, bottom; + + if (position.overlayY === 'top') { + // Overlay is opening "downward" and thus is bound by the bottom viewport edge. + top = origin.y; + height = viewport.bottom - origin.y; + } else if (position.overlayY === 'bottom') { + // Overlay is opening "upward" and thus is bound by the top viewport edge. + bottom = viewport.bottom - origin.y + this._viewportMargin; + height = origin.y - viewport.top; + } else { + // If neither top nor bottom, it means that the overlay + // is vertically centered on the origin point. + const smallestDistanceToViewportEdge = + Math.min(viewport.bottom - origin.y, origin.y - viewport.left); + const previousHeight = this._lastBoundingBoxSize.height; + + height = smallestDistanceToViewportEdge * 2; + top = origin.y - smallestDistanceToViewportEdge; + + if (height > previousHeight && !this._isInitialRender && !this._growAfterOpen) { + top = origin.y - (previousHeight / 2); + } + } + + // The overlay is opening 'right-ward' (the content flows to the right). + const isBoundedByRightViewportEdge = + (position.overlayX === 'start' && !this._isRtl()) || + (position.overlayX === 'end' && this._isRtl()); + + // The overlay is opening 'left-ward' (the content flows to the left). + const isBoundedByLeftViewportEdge = + (position.overlayX === 'end' && !this._isRtl()) || + (position.overlayX === 'start' && this._isRtl()); + + let width, left, right; + + if (isBoundedByLeftViewportEdge) { + right = viewport.right - origin.x + this._viewportMargin; + width = origin.x - viewport.left; + } else if (isBoundedByRightViewportEdge) { + left = origin.x; + width = viewport.right - origin.x; + } else { + // If neither start nor end, it means that the overlay + // is horizontally centered on the origin point. + const smallestDistanceToViewportEdge = + Math.min(viewport.right - origin.x, origin.x - viewport.top); + const previousWidth = this._lastBoundingBoxSize.width; + + width = smallestDistanceToViewportEdge * 2; + left = origin.x - smallestDistanceToViewportEdge; + + if (width > previousWidth && !this._isInitialRender && !this._growAfterOpen) { + left = origin.x - (previousWidth / 2); + } + } + + return {top, left, bottom, right, width, height}; + } + + /** + * Sets the position and size of the overlay's sizing wrapper. The wrapper is positioned on the + * origin's connection point and stetches to the bounds of the viewport. + * + * @param origin The point on the origin element where the overlay is connected. + * @param position The position preference + */ + private _setBoundingBoxStyles(origin: Point, position: ConnectedPosition): void { + const boundingBoxRect = this._calculateBoundingBoxRect(origin, position); + + // It's weird if the overlay *grows* while scrolling, so we take the last size into account + // when applying a new size. + if (!this._isInitialRender && !this._growAfterOpen) { + boundingBoxRect.height = Math.min(boundingBoxRect.height, this._lastBoundingBoxSize.height); + boundingBoxRect.width = Math.min(boundingBoxRect.width, this._lastBoundingBoxSize.width); + } + + const styles = {} as CSSStyleDeclaration; + + if (!this._hasFlexibleHeight || this._isPushed) { + styles.top = '0'; + styles.bottom = ''; + styles.height = '100%'; + } else { + styles.height = `${boundingBoxRect.height}px`; + styles.top = boundingBoxRect.top ? `${boundingBoxRect.top}px` : ''; + styles.bottom = boundingBoxRect.bottom ? `${boundingBoxRect.bottom}px` : ''; + } + + if (!this._hasFlexibleWidth || this._isPushed) { + styles.left = '0'; + styles.right = ''; + styles.width = '100%'; + } else { + styles.width = `${boundingBoxRect.width}px`; + styles.left = boundingBoxRect.left ? `${boundingBoxRect.left}px` : ''; + styles.right = boundingBoxRect.right ? `${boundingBoxRect.right}px` : ''; + } + + const maxHeight = this._overlayRef.getConfig().maxHeight; + if (maxHeight && this._hasFlexibleHeight) { + styles.maxHeight = formatCssUnit(maxHeight); + } + + const maxWidth = this._overlayRef.getConfig().maxWidth; + if (maxWidth && this._hasFlexibleWidth) { + styles.maxWidth = formatCssUnit(maxWidth); + } + + this._lastBoundingBoxSize = boundingBoxRect; + + extendStyles(this._boundingBox!.style, styles); + } + + /** Resets the styles for the bounding box so that a new positioning can be computed. */ + private _resetBoundingBoxStyles() { + extendStyles(this._boundingBox!.style, { + top: '0', + left: '0', + right: '0', + bottom: '0', + height: '', + width: '', + alignItems: '', + justifyContent: '', + } as CSSStyleDeclaration); + } + + /** Sets positioning styles to the overlay element. */ + private _setOverlayElementStyles(originPoint: Point, position: ConnectedPosition): void { + // Reset styles from any previous positioning. + const styles = { + top: '', + left: '', + bottom: '', + right: '', + } as CSSStyleDeclaration; + + // Align the overlay panel to the appropriate edge of the + // size-constraining container unless using a 'center' position. + if (this._hasFlexibleWidth && position.overlayX !== 'center' && !this._isPushed) { + if (this._isRtl()) { + styles[position.overlayX === 'end' ? 'left' : 'right'] = '0'; + } else { + styles[position.overlayX === 'end' ? 'right' : 'left'] = '0'; + } + } + + if (this._hasFlexibleHeight && position.overlayY !== 'center' && !this._isPushed) { + styles[position.overlayY === 'bottom' ? 'bottom' : 'top'] = '0'; + } + + if (!this._hasFlexibleHeight || this._isPushed) { + extendStyles(styles, this._getExactOverlayY(position, originPoint)); + } + + if (!this._hasFlexibleWidth || this._isPushed) { + extendStyles(styles, this._getExactOverlayX(position, originPoint)); + } + + // Use a transform to apply the offsets. We do this because the `center` positions rely on + // being in the normal flex flow and setting a `top` / `left` at all will completely throw + // off the position. We also can't use margins, because they won't have an effect in some + // cases where the element doesn't have anything to "push off of". Finally, this works + // better both with flexible and non-flexible positioning. + let transformString = ' '; + + if (position.offsetX) { + transformString += `translateX(${position.offsetX}px)`; + } + + if (position.offsetY) { + transformString += `translateY(${position.offsetY}px)`; + } + + styles.transform = transformString.trim(); + + // If a maxWidth or maxHeight is specified on the overlay, we remove them. We do this because + // we need these values to both be set to "100%" for the automatic flexible sizing to work. + // The maxHeight and maxWidth are set on the boundingBox in order to enforce the constraint. + if (this._hasFlexibleHeight && this._overlayRef.getConfig().maxHeight) { + styles.maxHeight = ''; + } + + if (this._hasFlexibleWidth && this._overlayRef.getConfig().maxWidth) { + styles.maxWidth = ''; + } + + // Push the pane content towards the proper direction. + if (position.overlayX === 'center') { + styles.justifyContent = 'center'; + } else { + styles.justifyContent = position.overlayX === 'end' ? 'flex-end' : 'flex-start'; + } + + extendStyles(this._pane.style, styles); + } + + /** Gets the exact top/bottom for the overlay when not using flexible sizing or when pushing. */ + private _getExactOverlayY(position: ConnectedPosition, originPoint: Point) { + // Reset any existing styles. This is necessary in case the + // preferred position has changed since the last `apply`. + let styles = {top: null, bottom: null} as CSSStyleDeclaration; + let overlayPoint = this._getOverlayPoint(originPoint, this._overlayRect, position); + + if (this._isPushed) { + overlayPoint = this._pushOverlayOnScreen(overlayPoint, this._overlayRect); + } + + // We want to set either `top` or `bottom` based on whether the overlay wants to appear + // above or below the origin and the direction in which the element will expand. + if (position.overlayY === 'bottom') { + // When using `bottom`, we adjust the y position such that it is the distance + // from the bottom of the viewport rather than the top. + const documentHeight = this._document.documentElement.clientHeight; + styles.bottom = `${documentHeight - (overlayPoint.y + this._overlayRect.height)}px`; + } else { + styles.top = `${overlayPoint.y}px`; + } + + return styles; + } + + /** Gets the exact left/right for the overlay when not using flexible sizing or when pushing. */ + private _getExactOverlayX(position: ConnectedPosition, originPoint: Point) { + // Reset any existing styles. This is necessary in case the preferred position has + // changed since the last `apply`. + let styles = {left: null, right: null} as CSSStyleDeclaration; + let overlayPoint = this._getOverlayPoint(originPoint, this._overlayRect, position); + + if (this._isPushed) { + overlayPoint = this._pushOverlayOnScreen(overlayPoint, this._overlayRect); + } + + // We want to set either `left` or `right` based on whether the overlay wants to appear "before" + // or "after" the origin, which determines the direction in which the element will expand. + // For the horizontal axis, the meaning of "before" and "after" change based on whether the + // page is in RTL or LTR. + let horizontalStyleProperty: 'left' | 'right'; + + if (this._isRtl()) { + horizontalStyleProperty = position.overlayX === 'end' ? 'left' : 'right'; + } else { + horizontalStyleProperty = position.overlayX === 'end' ? 'right' : 'left'; + } + + // When we're setting `right`, we adjust the x position such that it is the distance + // from the right edge of the viewport rather than the left edge. + if (horizontalStyleProperty === 'right') { + const documentWidth = this._document.documentElement.clientWidth; + styles.right = `${documentWidth - (overlayPoint.x + this._overlayRect.width)}px`; + } else { + styles.left = `${overlayPoint.x}px`; + } + + return styles; + } + + /** + * Gets the view properties of the trigger and overlay, including whether they are clipped + * or completely outside the view of any of the strategy's scrollables. + */ + private _getScrollVisibility(): ScrollingVisibility { + // Note: needs fresh rects since the position could've changed. + const originBounds = this._origin.getBoundingClientRect(); + const overlayBounds = this._pane.getBoundingClientRect(); + + // TODO(jelbourn): instead of needing all of the client rects for these scrolling containers + // every time, we should be able to use the scrollTop of the containers if the size of those + // containers hasn't changed. + const scrollContainerBounds = this.scrollables.map(scrollable => { + return scrollable.getElementRef().nativeElement.getBoundingClientRect(); + }); + + return { + isOriginClipped: isElementClippedByScrolling(originBounds, scrollContainerBounds), + isOriginOutsideView: isElementScrolledOutsideView(originBounds, scrollContainerBounds), + isOverlayClipped: isElementClippedByScrolling(overlayBounds, scrollContainerBounds), + isOverlayOutsideView: isElementScrolledOutsideView(overlayBounds, scrollContainerBounds), + }; + } + + /** Subtracts the amount that an element is overflowing on an axis from it's length. */ + private _subtractOverflows(length: number, ...overflows: number[]): number { + return overflows.reduce((currentValue: number, currentOverflow: number) => { + return currentValue - Math.max(currentOverflow, 0); + }, length); + } + + /** Narrows the given viewport rect by the current _viewportMargin. */ + private _getNarrowedViewportRect(): ClientRect { + // We recalculate the viewport rect here ourselves, rather than using the ViewportRuler, + // because we want to use the `clientWidth` and `clientHeight` as the base. The difference + // being that the client properties don't include the scrollbar, as opposed to `innerWidth` + // and `innerHeight` that do. This is necessary, because the overlay container uses + // 100% `width` and `height` which don't include the scrollbar either. + const width = this._document.documentElement.clientWidth; + const height = this._document.documentElement.clientHeight; + const scrollPosition = this._viewportRuler.getViewportScrollPosition(); + + return { + top: scrollPosition.top + this._viewportMargin, + left: scrollPosition.left + this._viewportMargin, + right: scrollPosition.left + width - this._viewportMargin, + bottom: scrollPosition.top + height - this._viewportMargin, + width: width - (2 * this._viewportMargin), + height: height - (2 * this._viewportMargin), + }; + } + + /** Whether the we're dealing with an RTL context */ + private _isRtl() { + return this._overlayRef.getConfig().direction === 'rtl'; + } +} + +/** A simple (x, y) coordinate. */ +interface Point { + x: number; + y: number; +} + +/** Record of measurements for how an overlay (at a given position) fits into the viewport. */ +interface OverlayFit { + /** Whether the overlay fits completely in the viewport. */ + isCompletelyWithinViewport: boolean; + + /** Whether the overlay fits in the viewport on the y-axis. */ + fitsInViewportVertically: boolean; + + /** Whether the overlay fits in the viewport on the x-axis. */ + fitsInViewportHorizontally: boolean; + + /** The total visible area (in px^2) of the overlay inside the viewport. */ + visibleArea: number; +} + +/** Record of the measurments determining whether an overlay will fit in a specific position. */ +interface FallbackPosition { + position: ConnectedPosition; + originPoint: Point; + overlayPoint: Point; + overlayFit: OverlayFit; + overlayRect: ClientRect; +} + +/** Position and size of the overlay sizing wrapper for a specific position. */ +interface BoundingBoxRect { + top: number; + left: number; + bottom: number; + right: number; + height: number; + width: number; +} + +/** Record of measures determining how well a given position will fit with flexible dimensions. */ +interface FlexibleFit { + position: ConnectedPosition; + origin: Point; + overlayRect: ClientRect; + boundingBoxRect: BoundingBoxRect; +} + +/** A connected position as specified by the user. */ +export interface ConnectedPosition { + originX: 'start' | 'center' | 'end'; + originY: 'top' | 'center' | 'bottom'; + + overlayX: 'start' | 'center' | 'end'; + overlayY: 'top' | 'center' | 'bottom'; + + weight?: number; + offsetX?: number; + offsetY?: number; +} + +// TODO: move to common place +function formatCssUnit(value: number | string) { + return typeof value === 'string' ? value as string : `${value}px`; +} + +/** Shallow-extends a stylesheet object with another stylesheet object. */ +function extendStyles(dest: CSSStyleDeclaration, source: CSSStyleDeclaration): CSSStyleDeclaration { + for (let key in source) { + if (source.hasOwnProperty(key)) { + dest[key] = source[key]; + } + } + + return dest; +} diff --git a/src/cdk/overlay/position/global-position-strategy.ts b/src/cdk/overlay/position/global-position-strategy.ts index d4f14faf7527..04a39649960c 100644 --- a/src/cdk/overlay/position/global-position-strategy.ts +++ b/src/cdk/overlay/position/global-position-strategy.ts @@ -19,21 +19,15 @@ import {OverlayRef} from '../overlay-ref'; export class GlobalPositionStrategy implements PositionStrategy { /** The overlay to which this strategy is attached. */ private _overlayRef: OverlayRef; - - private _cssPosition = 'static'; - private _topOffset = ''; - private _bottomOffset = ''; - private _leftOffset = ''; - private _rightOffset = ''; - private _alignItems = ''; - private _justifyContent = ''; - private _width = ''; - private _height = ''; - - /** A lazily-created wrapper for the overlay element that is used as a flex container. */ - private _wrapper: HTMLElement | null = null; - - constructor(private _document: any) {} + private _cssPosition: string = 'static'; + private _topOffset: string = ''; + private _bottomOffset: string = ''; + private _leftOffset: string = ''; + private _rightOffset: string = ''; + private _alignItems: string = ''; + private _justifyContent: string = ''; + private _width: string = ''; + private _height: string = ''; attach(overlayRef: OverlayRef): void { const config = overlayRef.getConfig(); @@ -47,6 +41,8 @@ export class GlobalPositionStrategy implements PositionStrategy { if (this._height && !config.height) { overlayRef.updateSize({height: this._height}); } + + overlayRef.hostElement.classList.add('cdk-global-overlay-wrapper'); } /** @@ -152,8 +148,6 @@ export class GlobalPositionStrategy implements PositionStrategy { /** * Apply the position to the element. * @docs-private - * - * @returns Resolved when the styles have been applied. */ apply(): void { // Since the overlay ref applies the strategy asynchronously, it could @@ -163,17 +157,8 @@ export class GlobalPositionStrategy implements PositionStrategy { return; } - const element = this._overlayRef.overlayElement; - - if (!this._wrapper && element.parentNode) { - this._wrapper = this._document.createElement('div'); - this._wrapper!.classList.add('cdk-global-overlay-wrapper'); - element.parentNode.insertBefore(this._wrapper!, element); - this._wrapper!.appendChild(element); - } - - const styles = element.style; - const parentStyles = (element.parentNode as HTMLElement).style; + const styles = this._overlayRef.overlayElement.style; + const parentStyles = this._overlayRef.hostElement.style; const config = this._overlayRef.getConfig(); styles.position = this._cssPosition; @@ -186,11 +171,9 @@ export class GlobalPositionStrategy implements PositionStrategy { parentStyles.alignItems = config.height === '100%' ? 'flex-start' : this._alignItems; } - /** Removes the wrapper element from the DOM. */ - dispose(): void { - if (this._wrapper && this._wrapper.parentNode) { - this._wrapper.parentNode.removeChild(this._wrapper); - this._wrapper = null; - } - } + /** + * Noop implemented as a part of the PositionStrategy interface. + * @docs-private + */ + dispose(): void { } } diff --git a/src/cdk/overlay/position/overlay-position-builder.ts b/src/cdk/overlay/position/overlay-position-builder.ts index 19059e4ac6ca..78fa4ad61e79 100644 --- a/src/cdk/overlay/position/overlay-position-builder.ts +++ b/src/cdk/overlay/position/overlay-position-builder.ts @@ -11,20 +11,22 @@ import {ViewportRuler} from '@angular/cdk/scrolling'; import {ConnectedPositionStrategy} from './connected-position-strategy'; import {GlobalPositionStrategy} from './global-position-strategy'; import {OverlayConnectionPosition, OriginConnectionPosition} from './connected-position'; +import {FlexibleConnectedPositionStrategy} from './flexible-connected-position-strategy'; import {DOCUMENT} from '@angular/common'; /** Builder for overlay position strategy. */ @Injectable() export class OverlayPositionBuilder { - constructor(private _viewportRuler: ViewportRuler, - @Inject(DOCUMENT) private _document: any) { } + constructor( + private _viewportRuler: ViewportRuler, + @Inject(DOCUMENT) private _document: any) { } /** * Creates a global position strategy. */ global(): GlobalPositionStrategy { - return new GlobalPositionStrategy(this._document); + return new GlobalPositionStrategy(); } /** @@ -38,7 +40,16 @@ export class OverlayPositionBuilder { originPos: OriginConnectionPosition, overlayPos: OverlayConnectionPosition): ConnectedPositionStrategy { - return new ConnectedPositionStrategy(originPos, overlayPos, elementRef, - this._viewportRuler, this._document); + return new ConnectedPositionStrategy(originPos, overlayPos, elementRef, this._viewportRuler, + this._document); } + + /** + * Creates a flexible position strategy. + * @param elementRef + */ + flexibleConnectedTo(elementRef: ElementRef): FlexibleConnectedPositionStrategy { + return new FlexibleConnectedPositionStrategy(elementRef, this._viewportRuler, this._document); + } + } diff --git a/src/cdk/overlay/position/position-strategy.ts b/src/cdk/overlay/position/position-strategy.ts index 1b60b9ec4f97..4237f8b8cc6c 100644 --- a/src/cdk/overlay/position/position-strategy.ts +++ b/src/cdk/overlay/position/position-strategy.ts @@ -11,9 +11,8 @@ import {OverlayRef} from '../overlay-ref'; /** Strategy for setting the position on an overlay. */ export interface PositionStrategy { - /** Attaches this position strategy to an overlay. */ - attach(overlay: OverlayRef): void; + attach(overlayRef: OverlayRef): void; /** Updates the position of the overlay element. */ apply(): void; diff --git a/src/cdk/overlay/public-api.ts b/src/cdk/overlay/public-api.ts index 1e1f786a5319..9b95f7ef073b 100644 --- a/src/cdk/overlay/public-api.ts +++ b/src/cdk/overlay/public-api.ts @@ -24,4 +24,5 @@ export {OverlayPositionBuilder} from './position/overlay-position-builder'; export {PositionStrategy} from './position/position-strategy'; export {GlobalPositionStrategy} from './position/global-position-strategy'; export {ConnectedPositionStrategy} from './position/connected-position-strategy'; +export {FlexibleConnectedPositionStrategy} from './position/flexible-connected-position-strategy'; export {VIEWPORT_RULER_PROVIDER} from '@angular/cdk/scrolling'; diff --git a/src/demo-app/connected-overlay/connected-overlay-demo.html b/src/demo-app/connected-overlay/connected-overlay-demo.html new file mode 100644 index 000000000000..ef949173384c --- /dev/null +++ b/src/demo-app/connected-overlay/connected-overlay-demo.html @@ -0,0 +1,95 @@ +
+ +
+
+

Origin X

+ + start + center + end + +
+ +
+

Origin Y

+ + top + center + bottom + +
+ +
+

Overlay X

+ + start + center + end + +
+ +
+

Overlay Y

+ + top + center + bottom + +
+ +
+

Offsets

+ +
+ +
+ +
+ +
+
+ +
+ + +
+ Allow flexible dimensions +
+ +
+ Allow push +
+ +
+ + Show bounding box + +
+
+ +
+ +
+
+ + +
+
+ +
+ +
diff --git a/src/demo-app/connected-overlay/connected-overlay-demo.scss b/src/demo-app/connected-overlay/connected-overlay-demo.scss new file mode 100644 index 000000000000..5ab6a88ce664 --- /dev/null +++ b/src/demo-app/connected-overlay/connected-overlay-demo.scss @@ -0,0 +1,35 @@ +.demo-options { + display: flex; + margin: 20px; + + & > div { + margin: 16px; + } +} + +demo-overlay { + display: block; + + background: lightblue; + min-width: 50px; + min-height: 50px; + + max-height: 100%; + overflow: auto; +} + +.demo-trigger { + display: flex; + justify-content: center; + align-items: center; + padding: 10px; +} + +.cdk-overlay-connected-position-bounding-box.demo-show-box { + background: orangered; + opacity: 0.2; + + .cdk-overlay-pane { + opacity: 1; + } +} diff --git a/src/demo-app/connected-overlay/connected-overlay-demo.ts b/src/demo-app/connected-overlay/connected-overlay-demo.ts new file mode 100644 index 000000000000..e28be97d42fc --- /dev/null +++ b/src/demo-app/connected-overlay/connected-overlay-demo.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ViewChild, ViewContainerRef, ViewEncapsulation} from '@angular/core'; +import {ComponentPortal} from '@angular/cdk/portal'; +import {Directionality} from '@angular/cdk/bidi'; +import { + HorizontalConnectionPos, + Overlay, + OverlayOrigin, + OverlayRef, + VerticalConnectionPos +} from '@angular/cdk/overlay'; + + +let itemCount = 25; + +@Component({ + moduleId: module.id, + selector: 'overlay-demo', + templateUrl: 'connected-overlay-demo.html', + styleUrls: ['connected-overlay-demo.css'], + encapsulation: ViewEncapsulation.None, +}) +export class ConnectedOverlayDemo { + @ViewChild(OverlayOrigin) _overlayOrigin: OverlayOrigin; + + originX: HorizontalConnectionPos = 'start'; + originY: VerticalConnectionPos = 'bottom'; + overlayX: HorizontalConnectionPos = 'start'; + overlayY: VerticalConnectionPos = 'top'; + isFlexible = true; + canPush = true; + showBoundingBox = false; + offsetX = 0; + offsetY = 0; + overlayRef: OverlayRef | null; + + constructor( + public overlay: Overlay, + public viewContainerRef: ViewContainerRef, + public dir: Directionality) { } + + openWithConfig() { + const positionStrategy = this.overlay.position() + .flexibleConnectedTo(this._overlayOrigin.elementRef) + .withFlexibleHeight(this.isFlexible) + .withFlexibleWidth(this.isFlexible) + .withPush(this.canPush) + .withViewportMargin(10) + .withGrowAfterOpen(true) + .withPositions([{ + originX: this.originX, + originY: this.originY, + overlayX: this.overlayX, + overlayY: this.overlayY, + offsetX: this.offsetX, + offsetY: this.offsetY + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + } + ]); + + this.overlayRef = this.overlay.create({ + positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + direction: this.dir.value, + minWidth: 200, + minHeight: 50 + }); + + this.overlayRef.attach(new ComponentPortal(DemoOverlay, this.viewContainerRef)); + } + + close() { + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = null; + this.showBoundingBox = false; + } + } + + updateCount(value: number) { + itemCount = +value; + } + + toggleShowBoundingBox() { + const box = document.querySelector('.cdk-overlay-connected-position-bounding-box'); + + if (box) { + this.showBoundingBox = !this.showBoundingBox; + box.classList.toggle('demo-show-box'); + } + } +} + + +@Component({ + template: ` +
+ {{items.length}} +
  • Item with a long name {{i}}
+
`, + encapsulation: ViewEncapsulation.None, +}) +export class DemoOverlay { + items = Array(itemCount); +} + diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 674c42c26f95..310b4f30b294 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -54,6 +54,8 @@ export class DemoApp { {name: 'Button Toggle', route: '/button-toggle'}, {name: 'Button', route: '/button'}, {name: 'Card', route: '/card'}, + {name: 'Chips', route: '/chips'}, + {name: 'Connected Overlay', route: '/connected-overlay'}, {name: 'Checkbox', route: '/checkbox'}, {name: 'Chips', route: '/chips'}, {name: 'Datepicker', route: '/datepicker'}, diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index 449ef2778b4c..9d116439a02d 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -63,6 +63,7 @@ import {DEMO_APP_ROUTES} from './routes'; import {TableDemoModule} from '../table/table-demo-module'; import {BadgeDemo} from '../badge/badge-demo'; import {TreeDemoModule} from '../tree/tree-demo-module'; +import {ConnectedOverlayDemo, DemoOverlay} from '../connected-overlay/connected-overlay-demo'; @NgModule({ imports: [ @@ -130,6 +131,9 @@ import {TreeDemoModule} from '../tree/tree-demo-module'; TooltipDemo, TypographyDemo, ExampleBottomSheet, + ExpansionDemo, + ConnectedOverlayDemo, + DemoOverlay, ], providers: [ {provide: OverlayContainer, useClass: FullscreenOverlayContainer}, @@ -144,6 +148,7 @@ import {TreeDemoModule} from '../tree/tree-demo-module'; ScienceJoke, SpagettiPanel, ExampleBottomSheet, + DemoOverlay, ], }) export class DemoModule {} diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 281ae53ce8b8..34c4d65f05d5 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -53,6 +53,7 @@ import {DemoApp, Home} from './demo-app'; import {TableDemoPage} from '../table/table-demo-page'; import {TABLE_DEMO_ROUTES} from '../table/routes'; import {BadgeDemo} from '../badge/badge-demo'; +import {ConnectedOverlayDemo} from '../connected-overlay/connected-overlay-demo'; export const DEMO_APP_ROUTES: Routes = [ {path: '', component: DemoApp, children: [ @@ -100,6 +101,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'expansion', component: ExpansionDemo}, {path: 'stepper', component: StepperDemo}, {path: 'screen-type', component: ScreenTypeDemo}, + {path: 'connected-overlay', component: ConnectedOverlayDemo}, ]} ]; diff --git a/src/demo-app/overlay/overlay-demo.html b/src/demo-app/overlay/overlay-demo.html index 512ab0927ff2..7d3e84682ced 100644 --- a/src/demo-app/overlay/overlay-demo.html +++ b/src/demo-app/overlay/overlay-demo.html @@ -7,7 +7,7 @@