3 * Copyright Google Inc. All Rights Reserved.
5 * Use of this source code is governed by an MIT-style license that can be
6 * found in the LICENSE file at https://angular.io/license
8 /* tslint:disable:array-type member-access variable-name typedef
9 only-arrow-functions directive-class-suffix component-class-suffix
10 component-selector no-unnecessary-type-assertion arrow-parens
12 import {ElementRef} from '@angular/core';
13 import {Observable} from 'rxjs/Observable';
14 import {Subject} from 'rxjs/Subject';
16 import {Scrollable} from '../scroll/scrollable';
18 import {ConnectedOverlayPositionChange, ConnectionPositionPair, OriginConnectionPosition, OverlayConnectionPosition, ScrollableViewProperties} from './connected-position';
19 import {PositionStrategy} from './position-strategy';
20 import {ViewportRuler} from './viewport-ruler';
23 * Container to hold the bounding positions of a particular element with respect
24 * to the viewport, where top and bottom are the y-axis coordinates of the
25 * bounding rectangle and left and right are the x-axis coordinates.
27 interface ElementBoundingPositions {
35 * A strategy for positioning overlays. Using this strategy, an overlay is given
36 * an implicit position relative some origin element. The relative position is
37 * defined in terms of a point on the origin element that is connected to a
38 * point on the overlay element. For example, a basic dropdown is connecting the
39 * bottom-left corner of the origin to the top-left corner of the overlay.
41 export class ConnectedPositionStrategy implements PositionStrategy {
44 /** The offset in pixels for the overlay connection point on the x-axis */
47 /** The offset in pixels for the overlay connection point on the y-axis */
50 /** The Scrollable containers used to check scrollable view properties on
52 private scrollables: Scrollable[] = [];
54 /** Whether the we're dealing with an RTL context */
56 return this._dir === 'rtl';
59 /** Ordered list of preferred positions, from most to least desirable. */
60 _preferredPositions: ConnectionPositionPair[] = [];
62 /** The origin element against which the overlay will be positioned. */
63 private _origin: HTMLElement;
65 /** The overlay pane element. */
66 private _pane: HTMLElement;
68 /** The last position to have been calculated as the best fit position. */
69 private _lastConnectedPosition: ConnectionPositionPair;
71 _onPositionChange: Subject<ConnectedOverlayPositionChange> =
72 new Subject<ConnectedOverlayPositionChange>();
74 /** Emits an event when the connection point changes. */
75 get onPositionChange(): Observable<ConnectedOverlayPositionChange> {
76 return this._onPositionChange.asObservable();
80 private _connectedTo: ElementRef,
81 private _originPos: OriginConnectionPosition,
82 private _overlayPos: OverlayConnectionPosition,
83 private _viewportRuler: ViewportRuler) {
84 this._origin = this._connectedTo.nativeElement;
85 this.withFallbackPosition(this._originPos, this._overlayPos);
88 /** Ordered list of preferred positions, from most to least desirable. */
90 return this._preferredPositions;
94 * To be used to for any cleanup after the element gets destroyed.
101 * Updates the position of the overlay element, using whichever preferred
102 * position relative to the origin fits on-screen.
105 * @param element Element to which to apply the CSS styles.
106 * @returns Resolves when the styles have been applied.
108 apply(element: HTMLElement): void {
109 // Cache the overlay pane element in case re-calculating position is
111 this._pane = element;
113 // We need the bounding rects for the origin and the overlay to determine
114 // how to position the overlay relative to the origin.
115 const originRect = this._origin.getBoundingClientRect();
116 const overlayRect = element.getBoundingClientRect();
118 // We use the viewport rect to determine whether a position would go
120 const viewportRect = this._viewportRuler.getViewportRect();
122 // Fallback point if none of the fallbacks fit into the viewport.
123 let fallbackPoint: OverlayPoint|undefined;
124 let fallbackPosition: ConnectionPositionPair|undefined;
126 // We want to place the overlay in the first of the preferred positions such
127 // that the overlay fits on-screen.
128 for (const pos of this._preferredPositions) {
129 // Get the (x, y) point of connection on the origin, and then use that to
130 // get the (top, left) coordinate for the overlay at `pos`.
131 const originPoint = this._getOriginConnectionPoint(originRect, pos);
133 this._getOverlayPoint(originPoint, overlayRect, viewportRect, pos);
135 // If the overlay in the calculated position fits on-screen, put it there
137 if (overlayPoint.fitsInViewport) {
138 this._setElementPosition(element, overlayRect, overlayPoint, pos);
140 // Save the last connected position in case the position needs to be
142 this._lastConnectedPosition = pos;
144 // Notify that the position has been changed along with its change
146 const scrollableViewProperties =
147 this.getScrollableViewProperties(element);
148 const positionChange =
149 new ConnectedOverlayPositionChange(pos, scrollableViewProperties);
150 this._onPositionChange.next(positionChange);
155 fallbackPoint.visibleArea < overlayPoint.visibleArea) {
156 fallbackPoint = overlayPoint;
157 fallbackPosition = pos;
161 // If none of the preferred positions were in the viewport, take the one
162 // with the largest visible area.
163 this._setElementPosition(
164 element, overlayRect, fallbackPoint, fallbackPosition);
168 * This re-aligns the overlay element with the trigger in its last calculated
169 * position, even if a position higher in the "preferred positions" list would
170 * now fit. This allows one to re-align the panel without changing the
171 * orientation of the panel.
173 recalculateLastPosition(): void {
174 const originRect = this._origin.getBoundingClientRect();
175 const overlayRect = this._pane.getBoundingClientRect();
176 const viewportRect = this._viewportRuler.getViewportRect();
178 this._lastConnectedPosition || this._preferredPositions[0];
181 this._getOriginConnectionPoint(originRect, lastPosition);
182 const overlayPoint = this._getOverlayPoint(
183 originPoint, overlayRect, viewportRect, lastPosition);
184 this._setElementPosition(
185 this._pane, overlayRect, overlayPoint, lastPosition);
189 * Sets the list of Scrollable containers that host the origin element so that
190 * on reposition we can evaluate if it or the overlay has been clipped or
191 * outside view. Every Scrollable must be an ancestor element of the
192 * strategy's origin element.
194 withScrollableContainers(scrollables: Scrollable[]) {
195 this.scrollables = scrollables;
199 * Adds a new preferred fallback position.
203 withFallbackPosition(
204 originPos: OriginConnectionPosition,
205 overlayPos: OverlayConnectionPosition): this {
206 this._preferredPositions.push(
207 new ConnectionPositionPair(originPos, overlayPos));
212 * Sets the layout direction so the overlay's position can be adjusted to
214 * @param dir New layout direction.
216 withDirection(dir: 'ltr'|'rtl'): this {
222 * Sets an offset for the overlay's connection point on the x-axis
223 * @param offset New offset in the X axis.
225 withOffsetX(offset: number): this {
226 this._offsetX = offset;
231 * Sets an offset for the overlay's connection point on the y-axis
232 * @param offset New offset in the Y axis.
234 withOffsetY(offset: number): this {
235 this._offsetY = offset;
240 * Gets the horizontal (x) "start" dimension based on whether the overlay is
244 private _getStartX(rect: ClientRect): number {
245 return this._isRtl ? rect.right : rect.left;
249 * Gets the horizontal (x) "end" dimension based on whether the overlay is in
253 private _getEndX(rect: ClientRect): number {
254 return this._isRtl ? rect.left : rect.right;
259 * Gets the (x, y) coordinate of a connection point on the origin based on a
264 private _getOriginConnectionPoint(
265 originRect: ClientRect, pos: ConnectionPositionPair): Point {
266 const originStartX = this._getStartX(originRect);
267 const originEndX = this._getEndX(originRect);
270 if (pos.originX === 'center') {
271 x = originStartX + (originRect.width / 2);
273 x = pos.originX === 'start' ? originStartX : originEndX;
277 if (pos.originY === 'center') {
278 y = originRect.top + (originRect.height / 2);
280 y = pos.originY === 'top' ? originRect.top : originRect.bottom;
288 * Gets the (x, y) coordinate of the top-left corner of the overlay given a
289 * given position and origin point to which the overlay should be connected,
290 * as well as how much of the element would be inside the viewport at that
293 private _getOverlayPoint(
294 originPoint: Point, overlayRect: ClientRect, viewportRect: ClientRect,
295 pos: ConnectionPositionPair): OverlayPoint {
296 // Calculate the (overlayStartX, overlayStartY), the start of the potential
297 // overlay position relative to the origin point.
298 let overlayStartX: number;
299 if (pos.overlayX === 'center') {
300 overlayStartX = -overlayRect.width / 2;
301 } else if (pos.overlayX === 'start') {
302 overlayStartX = this._isRtl ? -overlayRect.width : 0;
304 overlayStartX = this._isRtl ? 0 : -overlayRect.width;
307 let overlayStartY: number;
308 if (pos.overlayY === 'center') {
309 overlayStartY = -overlayRect.height / 2;
311 overlayStartY = pos.overlayY === 'top' ? 0 : -overlayRect.height;
314 // The (x, y) coordinates of the overlay.
315 const x = originPoint.x + overlayStartX + this._offsetX;
316 const y = originPoint.y + overlayStartY + this._offsetY;
318 // How much the overlay would overflow at this position, on each side.
319 const leftOverflow = 0 - x;
320 const rightOverflow = (x + overlayRect.width) - viewportRect.width;
321 const topOverflow = 0 - y;
322 const bottomOverflow = (y + overlayRect.height) - viewportRect.height;
324 // Visible parts of the element on each axis.
326 this._subtractOverflows(overlayRect.width, leftOverflow, rightOverflow);
327 const visibleHeight = this._subtractOverflows(
328 overlayRect.height, topOverflow, bottomOverflow);
330 // The area of the element that's within the viewport.
331 const visibleArea = visibleWidth * visibleHeight;
332 const fitsInViewport =
333 (overlayRect.width * overlayRect.height) === visibleArea;
335 return {x, y, fitsInViewport, visibleArea};
339 * Gets the view properties of the trigger and overlay, including whether they
340 * are clipped or completely outside the view of any of the strategy's
343 private getScrollableViewProperties(overlay: HTMLElement):
344 ScrollableViewProperties {
345 const originBounds = this._getElementBounds(this._origin);
346 const overlayBounds = this._getElementBounds(overlay);
347 const scrollContainerBounds =
348 this.scrollables.map((scrollable: Scrollable) => {
349 return this._getElementBounds(
350 scrollable.getElementRef().nativeElement);
355 this.isElementClipped(originBounds, scrollContainerBounds),
357 this.isElementOutsideView(originBounds, scrollContainerBounds),
359 this.isElementClipped(overlayBounds, scrollContainerBounds),
360 isOverlayOutsideView:
361 this.isElementOutsideView(overlayBounds, scrollContainerBounds),
365 /** Whether the element is completely out of the view of any of the
367 private isElementOutsideView(
368 elementBounds: ElementBoundingPositions,
369 containersBounds: ElementBoundingPositions[]): boolean {
370 return containersBounds.some(
371 (containerBounds: ElementBoundingPositions) => {
372 const outsideAbove = elementBounds.bottom < containerBounds.top;
373 const outsideBelow = elementBounds.top > containerBounds.bottom;
374 const outsideLeft = elementBounds.right < containerBounds.left;
375 const outsideRight = elementBounds.left > containerBounds.right;
377 return outsideAbove || outsideBelow || outsideLeft || outsideRight;
381 /** Whether the element is clipped by any of the containers. */
382 private isElementClipped(
383 elementBounds: ElementBoundingPositions,
384 containersBounds: ElementBoundingPositions[]): boolean {
385 return containersBounds.some(
386 (containerBounds: ElementBoundingPositions) => {
387 const clippedAbove = elementBounds.top < containerBounds.top;
388 const clippedBelow = elementBounds.bottom > containerBounds.bottom;
389 const clippedLeft = elementBounds.left < containerBounds.left;
390 const clippedRight = elementBounds.right > containerBounds.right;
392 return clippedAbove || clippedBelow || clippedLeft || clippedRight;
396 /** Physically positions the overlay element to the given coordinate. */
397 private _setElementPosition(
398 element: HTMLElement, overlayRect: ClientRect, overlayPoint: Point,
399 pos: ConnectionPositionPair) {
400 // We want to set either `top` or `bottom` based on whether the overlay
401 // wants to appear above or below the origin and the direction in which the
402 // element will expand.
403 const verticalStyleProperty = pos.overlayY === 'bottom' ? 'bottom' : 'top';
405 // When using `bottom`, we adjust the y position such that it is the
406 // distance from the bottom of the viewport rather than the top.
407 const y = verticalStyleProperty === 'top' ?
409 document.documentElement.clientHeight -
410 (overlayPoint.y + overlayRect.height);
412 // We want to set either `left` or `right` based on whether the overlay
413 // wants to appear "before" or "after" the origin, which determines the
414 // direction in which the element will expand. For the horizontal axis, the
415 // meaning of "before" and "after" change based on whether the page is in
417 let horizontalStyleProperty: any;
418 if (this._dir === 'rtl') {
419 horizontalStyleProperty = pos.overlayX === 'end' ? 'left' : 'right';
421 horizontalStyleProperty = pos.overlayX === 'end' ? 'right' : 'left';
424 // When we're setting `right`, we adjust the x position such that it is the
425 // distance from the right edge of the viewport rather than the left edge.
426 const x = horizontalStyleProperty === 'left' ?
428 document.documentElement.clientWidth -
429 (overlayPoint.x + overlayRect.width);
432 // Reset any existing styles. This is necessary in case the preferred
433 // position has changed since the last `apply`.
434 ['top', 'bottom', 'left', 'right'].forEach(
435 (p: any) => element.style[p] = null);
437 element.style[verticalStyleProperty] = `${y}px`;
438 element.style[horizontalStyleProperty] = `${x}px`;
441 /** Returns the bounding positions of the provided element with respect to the
443 private _getElementBounds(element: HTMLElement): ElementBoundingPositions {
444 const boundingClientRect = element.getBoundingClientRect();
446 top: boundingClientRect.top,
447 right: boundingClientRect.left + boundingClientRect.width,
448 bottom: boundingClientRect.top + boundingClientRect.height,
449 left: boundingClientRect.left
454 * Subtracts the amount that an element is overflowing on an axis from it's
457 private _subtractOverflows(length: number, ...overflows: number[]): number {
458 return overflows.reduce((currentValue: number, currentOverflow: number) => {
459 return currentValue - Math.max(currentOverflow, 0);
464 /** A simple (x, y) coordinate. */
471 * Expands the simple (x, y) coordinate by adding info about whether the
472 * element would fit inside the viewport at that position, as well as
473 * how much of the element would be visible.
475 interface OverlayPoint extends Point {
477 fitsInViewport: boolean;