UNPKG

ipsos-components

Version:

Material Design components for Angular

447 lines (373 loc) 17.1 kB
/** * @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} from '@angular/cdk/scrolling'; import { ConnectionPositionPair, OriginConnectionPosition, OverlayConnectionPosition, ConnectedOverlayPositionChange, ScrollingVisibility, } 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'; /** * 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 ConnectedPositionStrategy implements PositionStrategy { /** The overlay to which this strategy is attached. */ private _overlayRef: OverlayRef; /** 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; /** The Scrollable containers used to check scrollable view properties on position change. */ private scrollables: CdkScrollable[] = []; /** Subscription to viewport resize events. */ private _resizeSubscription = Subscription.EMPTY; /** Whether the we're dealing with an RTL context */ get _isRtl() { return this._dir === '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<ConnectedOverlayPositionChange>(); /** Emits an event when the connection point changes. */ get onPositionChange(): Observable<ConnectedOverlayPositionChange> { return this._onPositionChange.asObservable(); } constructor( originPos: OriginConnectionPosition, overlayPos: OverlayConnectionPosition, private _connectedTo: ElementRef, private _viewportRuler: ViewportRuler, private _document: any) { this._origin = this._connectedTo.nativeElement; this.withFallbackPosition(originPos, overlayPos); } /** Ordered list of preferred positions, from most to least desirable. */ get positions(): ConnectionPositionPair[] { return this._preferredPositions; } /** Attach this position strategy to an overlay. */ attach(overlayRef: OverlayRef): void { this._overlayRef = overlayRef; this._pane = overlayRef.overlayElement; this._resizeSubscription.unsubscribe(); this._resizeSubscription = this._viewportRuler.change().subscribe(() => this.apply()); } /** Disposes all resources used by the position strategy. */ dispose() { this._applied = false; this._resizeSubscription.unsubscribe(); } /** @docs-private */ detach() { this._applied = false; this._resizeSubscription.unsubscribe(); } /** * Updates the position of the overlay element, using whichever preferred position relative * to the origin fits on-screen. * @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._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!); } /** * Re-positions 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. */ recalculateLastPosition(): void { // If the overlay has never been positioned before, do nothing. if (!this._lastConnectedPosition) { return; } 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); } /** * 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 originPos * @param overlayPos */ withFallbackPosition( originPos: OriginConnectionPosition, overlayPos: OverlayConnectionPosition, offsetX?: number, offsetY?: number): this { const position = new ConnectionPositionPair(originPos, overlayPos, offsetX, offsetY); this._preferredPositions.push(position); return this; } /** * Sets the layout direction so the overlay's position can be adjusted to match. * @param dir New layout direction. */ withDirection(dir: 'ltr' | 'rtl'): this { this._dir = dir; return this; } /** * Sets an offset for the overlay's connection point on the x-axis * @param offset New offset in the X axis. */ withOffsetX(offset: number): this { this._offsetX = offset; return this; } /** * Sets an offset for the overlay's connection point on the y-axis * @param offset New offset in the Y axis. */ withOffsetY(offset: number): this { this._offsetY = offset; 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: boolean): this { this._positionLocked = isLocked; 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); } } /** 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; }