@angular/cdk
Version:
Angular Material Component Development Kit
985 lines • 154 kB
JavaScript
/**
* @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