UNPKG

@angular/cdk

Version:

Angular Material Component Development Kit

985 lines 154 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 { ElementRef } from '@angular/core'; import { ConnectedOverlayPositionChange, validateHorizontalPosition, validateVerticalPosition, } from './connected-position'; import { Subscription, Subject } from 'rxjs'; import { isElementScrolledOutsideView, isElementClippedByScrolling } from './scroll-clip'; import { coerceCssPixelValue, coerceArray } from '@angular/cdk/coercion'; // TODO: refactor clipping detection into a separate thing (part of scrolling module) // TODO: doesn't handle both flexible width and height when it has to scroll along both axis. /** Class to be added to the overlay bounding box. */ const boundingBoxClass = 'cdk-overlay-connected-position-bounding-box'; /** Regex used to split a string on its CSS units. */ const cssUnitPattern = /([A-Za-z%]+)$/; /** * 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 { /** Ordered list of preferred positions, from most to least desirable. */ get positions() { return this._preferredPositions; } constructor(connectedTo, _viewportRuler, _document, _platform, _overlayContainer) { this._viewportRuler = _viewportRuler; this._document = _document; this._platform = _platform; this._overlayContainer = _overlayContainer; /** Last size used for the bounding box. Used to avoid resizing the overlay after open. */ this._lastBoundingBoxSize = { width: 0, height: 0 }; /** Whether the overlay was pushed in a previous positioning. */ this._isPushed = false; /** Whether the overlay can be pushed on-screen on the initial open. */ this._canPush = true; /** Whether the overlay can grow via flexible width/height after the initial open. */ this._growAfterOpen = false; /** Whether the overlay's width and height can be constrained to fit within the viewport. */ this._hasFlexibleDimensions = true; /** Whether the overlay position is locked. */ this._positionLocked = false; /** Amount of space that must be maintained between the overlay and the edge of the viewport. */ this._viewportMargin = 0; /** The Scrollable containers used to check scrollable view properties on position change. */ this._scrollables = []; /** Ordered list of preferred positions, from most to least desirable. */ this._preferredPositions = []; /** Subject that emits whenever the position changes. */ this._positionChanges = new Subject(); /** Subscription to viewport size changes. */ this._resizeSubscription = Subscription.EMPTY; /** Default offset for the overlay along the x axis. */ this._offsetX = 0; /** Default offset for the overlay along the y axis. */ this._offsetY = 0; /** Keeps track of the CSS classes that the position strategy has applied on the overlay panel. */ this._appliedPanelClasses = []; /** Observable sequence of position changes. */ this.positionChanges = this._positionChanges; this.setOrigin(connectedTo); } /** Attaches this position strategy to an overlay. */ attach(overlayRef) { if (this._overlayRef && overlayRef !== this._overlayRef && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw Error('This position strategy is already attached to an overlay'); } this._validatePositions(); overlayRef.hostElement.classList.add(boundingBoxClass); this._overlayRef = overlayRef; this._boundingBox = overlayRef.hostElement; this._pane = overlayRef.overlayElement; this._isDisposed = false; this._isInitialRender = true; this._lastPosition = null; this._resizeSubscription.unsubscribe(); this._resizeSubscription = this._viewportRuler.change().subscribe(() => { // When the window is resized, we want to trigger the next reposition as if it // was an initial render, in order for the strategy to pick a new optimal position, // otherwise position locking will cause it to stay at the old one. this._isInitialRender = true; 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 satisfies 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() { // We shouldn't do anything if the strategy was disposed or we're on the server. if (this._isDisposed || !this._platform.isBrowser) { 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._clearPanelClasses(); this._resetOverlayElementStyles(); this._resetBoundingBoxStyles(); // We need the bounding rects for the origin, the overlay and the container 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._getOriginRect(); this._overlayRect = this._pane.getBoundingClientRect(); this._containerRect = this._overlayContainer.getContainerElement().getBoundingClientRect(); const originRect = this._originRect; const overlayRect = this._overlayRect; const viewportRect = this._viewportRect; const containerRect = this._containerRect; // Positions where the overlay will fit with flexible dimensions. const flexibleFits = []; // Fallback if none of the preferred positions fit within the viewport. let fallback; // 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, containerRect, 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 = 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._clearPanelClasses(); this._lastPosition = null; this._previousPushAmount = null; this._resizeSubscription.unsubscribe(); } /** Cleanup after the element gets destroyed. */ dispose() { if (this._isDisposed) { return; } // We can't use `_resetBoundingBoxStyles` here, because it resets // some properties to zero, rather than removing them. if (this._boundingBox) { extendStyles(this._boundingBox.style, { top: '', left: '', right: '', bottom: '', height: '', width: '', alignItems: '', justifyContent: '', }); } if (this._pane) { this._resetOverlayElementStyles(); } if (this._overlayRef) { this._overlayRef.hostElement.classList.remove(boundingBoxClass); } this.detach(); this._positionChanges.complete(); this._overlayRef = this._boundingBox = null; this._isDisposed = true; } /** * 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() { if (this._isDisposed || !this._platform.isBrowser) { return; } const lastPosition = this._lastPosition; if (lastPosition) { this._originRect = this._getOriginRect(); this._overlayRect = this._pane.getBoundingClientRect(); this._viewportRect = this._getNarrowedViewportRect(); this._containerRect = this._overlayContainer.getContainerElement().getBoundingClientRect(); const originPoint = this._getOriginPoint(this._originRect, this._containerRect, lastPosition); this._applyPosition(lastPosition, originPoint); } else { this.apply(); } } /** * 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) { this._scrollables = scrollables; return this; } /** * Adds new preferred positions. * @param positions List of positions options for this overlay. */ withPositions(positions) { this._preferredPositions = positions; // If the last calculated position object isn't part of the positions anymore, clear // it in order to avoid it being picked up if the consumer tries to re-apply. if (positions.indexOf(this._lastPosition) === -1) { this._lastPosition = null; } this._validatePositions(); return this; } /** * Sets a minimum distance the overlay may be positioned to the edge of the viewport. * @param margin Required margin between the overlay and the viewport edge in pixels. */ withViewportMargin(margin) { this._viewportMargin = margin; return this; } /** Sets whether the overlay's width and height can be constrained to fit within the viewport. */ withFlexibleDimensions(flexibleDimensions = true) { this._hasFlexibleDimensions = flexibleDimensions; return this; } /** Sets whether the overlay can grow after the initial open via flexible width/height. */ withGrowAfterOpen(growAfterOpen = true) { 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._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._positionLocked = isLocked; return this; } /** * Sets the origin, relative to which to position the overlay. * Using an element origin is useful for building components that need to be positioned * relatively to a trigger (e.g. dropdown menus or tooltips), whereas using a point can be * used for cases like contextual menus which open relative to the user's pointer. * @param origin Reference to the new origin. */ setOrigin(origin) { this._origin = origin; return this; } /** * Sets the default offset for the overlay's connection point on the x-axis. * @param offset New offset in the X axis. */ withDefaultOffsetX(offset) { this._offsetX = offset; return this; } /** * Sets the default offset for the overlay's connection point on the y-axis. * @param offset New offset in the Y axis. */ withDefaultOffsetY(offset) { this._offsetY = offset; return this; } /** * Configures that the position strategy should set a `transform-origin` on some elements * inside the overlay, depending on the current position that is being applied. This is * useful for the cases where the origin of an animation can change depending on the * alignment of the overlay. * @param selector CSS selector that will be used to find the target * elements onto which to set the transform origin. */ withTransformOriginOn(selector) { this._transformOriginSelector = selector; return this; } /** * Gets the (x, y) coordinate of a connection point on the origin based on a relative position. */ _getOriginPoint(originRect, containerRect, pos) { let x; 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; } // When zooming in Safari the container rectangle contains negative values for the position // and we need to re-add them to the calculated coordinates. if (containerRect.left < 0) { x -= containerRect.left; } let y; if (pos.originY == 'center') { y = originRect.top + originRect.height / 2; } else { y = pos.originY == 'top' ? originRect.top : originRect.bottom; } // Normally the containerRect's top value would be zero, however when the overlay is attached to an input // (e.g. in an autocomplete), mobile browsers will shift everything in order to put the input in the middle // of the screen and to make space for the virtual keyboard. We need to account for this offset, // otherwise our positioning will be thrown off. // Additionally, when zooming in Safari this fixes the vertical position. if (containerRect.top < 0) { y -= containerRect.top; } 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. */ _getOverlayPoint(originPoint, overlayRect, pos) { // Calculate the (overlayStartX, overlayStartY), the start of the // potential overlay position relative to the origin point. let overlayStartX; 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; 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. */ _getOverlayFit(point, rawOverlayRect, viewport, position) { // Round the overlay rect when comparing against the // viewport, because the viewport is always rounded. const overlay = getRoundedBoundingClientRect(rawOverlayRect); let { x, y } = point; let offsetX = this._getOffset(position, 'x'); let offsetY = this._getOffset(position, 'y'); // Account for the offsets since they could push the overlay out of the viewport. if (offsetX) { x += offsetX; } if (offsetY) { y += 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 overlay at some position. * @param viewport The geometry of the viewport. */ _canFitWithFlexibleDimensions(fit, point, viewport) { if (this._hasFlexibleDimensions) { const availableHeight = viewport.bottom - point.y; const availableWidth = viewport.right - point.x; const minHeight = getPixelValue(this._overlayRef.getConfig().minHeight); const minWidth = getPixelValue(this._overlayRef.getConfig().minWidth); const verticalFit = fit.fitsInViewportVertically || (minHeight != null && minHeight <= availableHeight); const horizontalFit = fit.fitsInViewportHorizontally || (minWidth != null && minWidth <= availableWidth); return verticalFit && horizontalFit; } return false; } /** * 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 occurring on the * right and bottom). * * @param start Starting point from which the overlay is pushed. * @param rawOverlayRect Dimensions of the overlay. * @param scrollPosition Current viewport scroll position. * @returns The point at which to position the overlay after pushing. This is effectively a new * originPoint. */ _pushOverlayOnScreen(start, rawOverlayRect, scrollPosition) { // If the position is locked and we've pushed the overlay already, reuse the previous push // amount, rather than pushing it again. If we were to continue pushing, the element would // remain in the viewport, which goes against the expectations when position locking is enabled. if (this._previousPushAmount && this._positionLocked) { return { x: start.x + this._previousPushAmount.x, y: start.y + this._previousPushAmount.y, }; } // Round the overlay rect when comparing against the // viewport, because the viewport is always rounded. const overlay = getRoundedBoundingClientRect(rawOverlayRect); 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.width, 0); const overflowBottom = Math.max(start.y + overlay.height - viewport.height, 0); const overflowTop = Math.max(viewport.top - scrollPosition.top - start.y, 0); const overflowLeft = Math.max(viewport.left - scrollPosition.left - start.x, 0); // Amount by which to push the overlay in each axis such that it remains on-screen. let pushX = 0; let 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 = start.x < this._viewportMargin ? viewport.left - scrollPosition.left - start.x : 0; } if (overlay.height <= viewport.height) { pushY = overflowTop || -overflowBottom; } else { pushY = start.y < this._viewportMargin ? viewport.top - scrollPosition.top - start.y : 0; } this._previousPushAmount = { x: pushX, y: pushY }; 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. */ _applyPosition(position, originPoint) { this._setTransformOrigin(position); this._setOverlayElementStyles(originPoint, position); this._setBoundingBoxStyles(originPoint, position); if (position.panelClass) { this._addPanelClasses(position.panelClass); } // 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. // We only emit if we've got any subscriptions, because the scroll visibility // calculations can be somewhat expensive. if (this._positionChanges.observers.length) { const scrollableViewProperties = this._getScrollVisibility(); const changeEvent = new ConnectedOverlayPositionChange(position, scrollableViewProperties); this._positionChanges.next(changeEvent); } this._isInitialRender = false; } /** Sets the transform origin based on the configured selector and the passed-in position. */ _setTransformOrigin(position) { if (!this._transformOriginSelector) { return; } const elements = this._boundingBox.querySelectorAll(this._transformOriginSelector); let xOrigin; let yOrigin = position.overlayY; if (position.overlayX === 'center') { xOrigin = 'center'; } else if (this._isRtl()) { xOrigin = position.overlayX === 'start' ? 'right' : 'left'; } else { xOrigin = position.overlayX === 'start' ? 'left' : 'right'; } for (let i = 0; i < elements.length; i++) { elements[i].style.transformOrigin = `${xOrigin} ${yOrigin}`; } } /** * 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. */ _calculateBoundingBoxRect(origin, position) { const viewport = this._viewportRect; const isRtl = this._isRtl(); 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.height - top + this._viewportMargin; } else if (position.overlayY === 'bottom') { // Overlay is opening "upward" and thus is bound by the top viewport edge. We need to add // the viewport margin back in, because the viewport rect is narrowed down to remove the // margin, whereas the `origin` position is calculated based on its `ClientRect`. bottom = viewport.height - origin.y + this._viewportMargin * 2; height = viewport.height - bottom + this._viewportMargin; } else { // If neither top nor bottom, it means that the overlay is vertically centered on the // origin point. Note that we want the position relative to the viewport, rather than // the page, which is why we don't use something like `viewport.bottom - origin.y` and // `origin.y - viewport.top`. const smallestDistanceToViewportEdge = Math.min(viewport.bottom - origin.y + viewport.top, origin.y); 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' && !isRtl) || (position.overlayX === 'end' && isRtl); // The overlay is opening 'left-ward' (the content flows to the left). const isBoundedByLeftViewportEdge = (position.overlayX === 'end' && !isRtl) || (position.overlayX === 'start' && isRtl); let width, left, right; if (isBoundedByLeftViewportEdge) { right = viewport.width - origin.x + this._viewportMargin; width = origin.x - this._viewportMargin; } 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. Note that we want the position relative to the viewport, rather than // the page, which is why we don't use something like `viewport.right - origin.x` and // `origin.x - viewport.left`. const smallestDistanceToViewportEdge = Math.min(viewport.right - origin.x + viewport.left, origin.x); 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: top, left: left, bottom: bottom, right: 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 stretches to the bounds of the viewport. * * @param origin The point on the origin element where the overlay is connected. * @param position The position preference */ _setBoundingBoxStyles(origin, position) { 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 = {}; if (this._hasExactPosition()) { styles.top = styles.left = '0'; styles.bottom = styles.right = styles.maxHeight = styles.maxWidth = ''; styles.width = styles.height = '100%'; } else { const maxHeight = this._overlayRef.getConfig().maxHeight; const maxWidth = this._overlayRef.getConfig().maxWidth; styles.height = coerceCssPixelValue(boundingBoxRect.height); styles.top = coerceCssPixelValue(boundingBoxRect.top); styles.bottom = coerceCssPixelValue(boundingBoxRect.bottom); styles.width = coerceCssPixelValue(boundingBoxRect.width); styles.left = coerceCssPixelValue(boundingBoxRect.left); styles.right = coerceCssPixelValue(boundingBoxRect.right); // Push the pane content towards the proper direction. if (position.overlayX === 'center') { styles.alignItems = 'center'; } else { styles.alignItems = position.overlayX === 'end' ? 'flex-end' : 'flex-start'; } if (position.overlayY === 'center') { styles.justifyContent = 'center'; } else { styles.justifyContent = position.overlayY === 'bottom' ? 'flex-end' : 'flex-start'; } if (maxHeight) { styles.maxHeight = coerceCssPixelValue(maxHeight); } if (maxWidth) { styles.maxWidth = coerceCssPixelValue(maxWidth); } } this._lastBoundingBoxSize = boundingBoxRect; extendStyles(this._boundingBox.style, styles); } /** Resets the styles for the bounding box so that a new positioning can be computed. */ _resetBoundingBoxStyles() { extendStyles(this._boundingBox.style, { top: '0', left: '0', right: '0', bottom: '0', height: '', width: '', alignItems: '', justifyContent: '', }); } /** Resets the styles for the overlay pane so that a new positioning can be computed. */ _resetOverlayElementStyles() { extendStyles(this._pane.style, { top: '', left: '', bottom: '', right: '', position: '', transform: '', }); } /** Sets positioning styles to the overlay element. */ _setOverlayElementStyles(originPoint, position) { const styles = {}; const hasExactPosition = this._hasExactPosition(); const hasFlexibleDimensions = this._hasFlexibleDimensions; const config = this._overlayRef.getConfig(); if (hasExactPosition) { const scrollPosition = this._viewportRuler.getViewportScrollPosition(); extendStyles(styles, this._getExactOverlayY(position, originPoint, scrollPosition)); extendStyles(styles, this._getExactOverlayX(position, originPoint, scrollPosition)); } else { styles.position = 'static'; } // 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 = ''; let offsetX = this._getOffset(position, 'x'); let offsetY = this._getOffset(position, 'y'); if (offsetX) { transformString += `translateX(${offsetX}px) `; } if (offsetY) { transformString += `translateY(${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. // Note that this doesn't apply when we have an exact position, in which case we do want to // apply them because they'll be cleared from the bounding box. if (config.maxHeight) { if (hasExactPosition) { styles.maxHeight = coerceCssPixelValue(config.maxHeight); } else if (hasFlexibleDimensions) { styles.maxHeight = ''; } } if (config.maxWidth) { if (hasExactPosition) { styles.maxWidth = coerceCssPixelValue(config.maxWidth); } else if (hasFlexibleDimensions) { styles.maxWidth = ''; } } extendStyles(this._pane.style, styles); } /** Gets the exact top/bottom for the overlay when not using flexible sizing or when pushing. */ _getExactOverlayY(position, originPoint, scrollPosition) { // Reset any existing styles. This is necessary in case the // preferred position has changed since the last `apply`. let styles = { top: '', bottom: '' }; let overlayPoint = this._getOverlayPoint(originPoint, this._overlayRect, position); if (this._isPushed) { overlayPoint = this._pushOverlayOnScreen(overlayPoint, this._overlayRect, scrollPosition); } // 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 = coerceCssPixelValue(overlayPoint.y); } return styles; } /** Gets the exact left/right for the overlay when not using flexible sizing or when pushing. */ _getExactOverlayX(position, originPoint, scrollPosition) { // Reset any existing styles. This is necessary in case the preferred position has // changed since the last `apply`. let styles = { left: '', right: '' }; let overlayPoint = this._getOverlayPoint(originPoint, this._overlayRect, position); if (this._isPushed) { overlayPoint = this._pushOverlayOnScreen(overlayPoint, this._overlayRect, scrollPosition); } // 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; 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 = coerceCssPixelValue(overlayPoint.x); } 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. */ _getScrollVisibility() { // Note: needs fresh rects since the position could've changed. const originBounds = this._getOriginRect(); 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 its length. */ _subtractOverflows(length, ...overflows) { return overflows.reduce((currentValue, currentOverflow) => { return currentValue - Math.max(currentOverflow, 0); }, length); } /** Narrows the given viewport rect by the current _viewportMargin. */ _getNarrowedViewportRect() { // 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 */ _isRtl() { return this._overlayRef.getDirection() === 'rtl'; } /** Determines whether the overlay uses exact or flexible positioning. */ _hasExactPosition() { return !this._hasFlexibleDimensions || this._isPushed; } /** Retrieves the offset of a position along the x or y axis. */ _getOffset(position, axis) { if (axis === 'x') { // We don't do something like `position['offset' + axis]` in // order to avoid breaking minifiers that rename properties. return position.offsetX == null ? this._offsetX : position.offsetX; } return position.offsetY == null ? this._offsetY : position.offsetY; } /** Validates that the current position match the expected values. */ _validatePositions() { if (typeof ngDevMode === 'undefined' || ngDevMode) { if (!this._preferredPositions.length) { throw Error('FlexibleConnectedPositionStrategy: At least one position is required.'); } // TODO(crisbeto): remove these once Angular's template type // checking is advanced enough to catch these cases. this._preferredPositions.forEach(pair => { validateHorizontalPosition('originX', pair.originX); validateVerticalPosition('originY', pair.originY); validateHorizontalPosition('overlayX', pair.overlayX); validateVerticalPosition('overlayY', pair.overlayY); }); } } /** Adds a single CSS class or an array of classes on the overlay panel. */ _addPanelClasses(cssClasses) { if (this._pane) { coerceArray(cssClasses).forEach(cssClass => { if (cssClass !== '' && this._appliedPanelClasses.indexOf(cssClass) === -1) { this._appliedPanelClasses.push(cssClass); this._pane.classList.add(cssClass); } }); } } /** Clears the classes that the position strategy has applied from the overlay panel. */ _clearPanelClasses() { if (this._pane) { this._appliedPanelClasses.forEach(cssClass => { this._pane.classList.remove(cssClass); }); this._appliedPanelClasses = []; } } /** Returns the ClientRect of the current origin. */ _getOriginRect() { const origin = this._origin; if (origin instanceof ElementRef) { return origin.nativeElement.getBoundingClientRect(); } // Check for Element so SVG elements are also supported. if (origin instanceof Element) { return origin.getBoundingClientRect(); } const width = origin.width || 0; const height = origin.height || 0; // If the origin is a point, return a client rect as if it was a 0x0 element at the point. return { top: origin.y, bottom: origin.y + height, left: origin.x, right: origin.x + width, height, width, }; } } /** Shallow-extends a stylesheet object with another stylesheet object. */ function extendStyles(destination, source) { for (let key in source) { if (source.hasOwnProperty(key)) { destination[key] = source[key]; } } return destination; } /** * Extracts the pixel value as a number from a value, if it's a number * or a CSS pixel string (e.g. `1337px`). Otherwise returns null. */ function getPixelValue(input) { if (typeof input !== 'number' && input != null) { const [value, units] = input.split(cssUnitPattern); return !units || units === 'px' ? parseFloat(value) : null; } return input || null; } /** * Gets a version of an element's bounding `ClientRect` where all the values are rounded down to * the nearest pixel. This allows us to account for the cases where there may be sub-pixel * deviations in the `ClientRect` returned by the browser (e.g. when zoomed in with a percentage * size, see #21350). */ function getRoundedBoundingClientRect(clientRect) { return { top: Math.floor(clientRect.top), right: Math.floor(clientRect.right), bottom: Math.floor(clientRect.bottom), left: Math.floor(clientRect.left), width: Math.floor(clientRect.width), height: Math.floor(clientRect.height), }; } export const STANDARD_DROPDOWN_BELOW_POSITIONS = [ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' }, { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom' }, ]; export const STANDARD_DROPDOWN_ADJACENT_POSITIONS = [ { originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top' }, { originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom' }, { originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top' }, { originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom' }, ]; //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZmxleGlibGUtY29ubmVjdGVkLXBvc2l0aW9uLXN0cmF0ZWd5LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vLi4vLi4vLi4vc3JjL2Nkay9vdmVybGF5L3Bvc2l0aW9uL2ZsZXhpYmxlLWNvbm5lY3RlZC1wb3NpdGlvbi1zdHJhdGVneS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7O0dBTUc7QUFHSCxPQUFPLEVBQUMsVUFBVSxFQUFDLE1BQU0sZUFBZSxDQUFDO0FBRXpDLE9BQU8sRUFDTCw4QkFBOEIsRUFHOUIsMEJBQTBCLEVBQzFCLHdCQUF3QixHQUN6QixNQUFNLHNCQUFzQixDQUFDO0FBQzlCLE9BQU8sRUFBYSxZQUFZLEVBQUUsT0FBTyxFQUFDLE1BQU0sTUFBTSxDQUFDO0FBQ3ZELE9BQU8sRUFBQyw0QkFBNEIsRUFBRSwyQkFBMkIsRUFBQyxNQUFNLGVBQWUsQ0FBQztBQUN4RixPQUFPLEVBQUMsbUJBQW1CLEVBQUUsV0FBVyxFQUFDLE1BQU0sdUJBQXVCLENBQUM7QUFLdkUscUZBQXFGO0FBQ3JGLDZGQUE2RjtBQUU3RixxREFBcUQ7QUFDckQsTUFBTSxnQkFBZ0IsR0FBRyw2Q0FBNkMsQ0FBQztBQUV2RSxxREFBcUQ7QUFDckQsTUFBTSxjQUFjLEdBQUcsZUFBZSxDQUFDO0FBY3ZDOzs7Ozs7R0FNRztBQUNILE1BQU0sT0FBTyxpQ0FBaUM7SUF3RjVDLHlFQUF5RTtJQUN6RSxJQUFJLFNBQVM7UUFDWCxPQUFPLElBQUksQ0FBQyxtQkFBbUIsQ0FBQztJQUNsQyxDQUFDO0lBRUQsWUFDRSxXQUFvRCxFQUM1QyxjQUE2QixFQUM3QixTQUFtQixFQUNuQixTQUFtQixFQUNuQixpQkFBbUM7UUFIbkMsbUJBQWMsR0FBZCxjQUFjLENBQWU7UUFDN0IsY0FBUyxHQUFULFNBQVMsQ0FBVTtRQUNuQixjQUFTLEdBQVQsU0FBUyxDQUFVO1FBQ25CLHNCQUFpQixHQUFqQixpQkFBaUIsQ0FBa0I7UUEzRjdDLDBGQUEwRjtRQUNsRix5QkFBb0IsR0FBRyxFQUFDLEtBQUssRUFBRSxDQUFDLEVBQUUsTUFBTSxFQUFFLENBQUMsRUFBQyxDQUFDO1FBRXJELGdFQUFnRTtRQUN4RCxjQUFTLEdBQUcsS0FBSyxDQUFDO1FBRTFCLHVFQUF1RTtRQUMvRCxhQUFRLEdBQUcsSUFBSSxDQUFDO1FBRXhCLHFGQUFxRjtRQUM3RSxtQkFBYyxHQUFHLEtBQUssQ0FBQztRQUUvQiw0RkFBNEY7UUFDcEYsMkJBQXNCLEdBQUcsSUFBSSxDQUFDO1FBRXRDLDhDQUE4QztRQUN0QyxvQkFBZSxHQUFHLEtBQUssQ0FBQztRQWNoQyxnR0FBZ0c7UUFDeEYsb0JBQWUsR0FBRyxDQUFDLENBQUM7UUFFNUIsNkZBQTZGO1FBQ3JGLGlCQUFZLEdBQW9CLEVBQUUsQ0FBQztRQUUzQyx5RUFBeUU7UUFDekUsd0JBQW1CLEdBQTZCLEVBQUUsQ0FBQztRQW9CbkQsd0RBQXdEO1FBQ3ZDLHFCQUFnQixHQUFHLElBQUksT0FBTyxFQUFrQyxDQUFDO1FBRWxGLDZDQUE2QztRQUNyQyx3QkFBbUIsR0FBRyxZQUFZLENBQUMsS0FBSyxDQUFDO1FBRWpELHVEQUF1RDtRQUMvQyxhQUFRLEdBQUcsQ0FBQyxDQUFDO1FBRXJCLHVEQUF1RDtRQUMvQy