From 70450b218353eb83da608626d1f54eab859a5708 Mon Sep 17 00:00:00 2001 From: Luke Beach Date: Wed, 9 Oct 2024 09:25:37 +0200 Subject: [PATCH] feat(cdk/overlay): Allow passing separate X and Y values for the viewportMargin The overlay directive now accepts two additional (optional parameters) [viewportMarginX] and [viewportMarginY]. You can use these to pass separate margin values for the viewport. --- src/cdk/overlay/overlay-directives.ts | 4 +- .../overlay/position/connected-position.ts | 3 + ...exible-connected-position-strategy.spec.ts | 44 ++++++++++ .../flexible-connected-position-strategy.ts | 82 ++++++++++++++----- 4 files changed, 112 insertions(+), 21 deletions(-) diff --git a/src/cdk/overlay/overlay-directives.ts b/src/cdk/overlay/overlay-directives.ts index 87315cbf6c03..62b2d4ffed3c 100644 --- a/src/cdk/overlay/overlay-directives.ts +++ b/src/cdk/overlay/overlay-directives.ts @@ -33,7 +33,7 @@ import {takeWhile} from 'rxjs/operators'; import {Overlay} from './overlay'; import {OverlayConfig} from './overlay-config'; import {OverlayRef} from './overlay-ref'; -import {ConnectedOverlayPositionChange} from './position/connected-position'; +import {ConnectedOverlayPositionChange, ViewportMargin} from './position/connected-position'; import { ConnectedPosition, FlexibleConnectedPositionStrategy, @@ -178,7 +178,7 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { @Input('cdkConnectedOverlayPanelClass') panelClass: string | string[]; /** Margin between the overlay and the viewport edges. */ - @Input('cdkConnectedOverlayViewportMargin') viewportMargin: number = 0; + @Input('cdkConnectedOverlayViewportMargin') viewportMargin: ViewportMargin = 0; /** Strategy to be used when handling scroll events while the overlay is open. */ @Input('cdkConnectedOverlayScrollStrategy') scrollStrategy: ScrollStrategy; diff --git a/src/cdk/overlay/position/connected-position.ts b/src/cdk/overlay/position/connected-position.ts index 42954a37a636..21419ca06903 100644 --- a/src/cdk/overlay/position/connected-position.ts +++ b/src/cdk/overlay/position/connected-position.ts @@ -12,6 +12,9 @@ export type HorizontalConnectionPos = 'start' | 'center' | 'end'; /** Vertical dimension of a connection point on the perimeter of the origin or overlay element. */ export type VerticalConnectionPos = 'top' | 'center' | 'bottom'; +/** The distance between the overlay element and the viewport. */ +export type ViewportMargin = number | {top?: number; bottom?: number; start?: number; end?: number}; + /** A connection point on the origin element. */ export interface OriginConnectionPosition { originX: HorizontalConnectionPos; diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts index 32fdf92ccc44..f7bb697e931e 100644 --- a/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts @@ -1367,6 +1367,50 @@ describe('FlexibleConnectedPositionStrategy', () => { expect(Math.floor(overlayRect.top)).toBe(15); }); + it('should set separate margins when pushing the overlay into the viewport', () => { + originElement.style.top = `${-OVERLAY_HEIGHT}px`; + originElement.style.left = `${-OVERLAY_WIDTH / 2}px`; + + positionStrategy.withViewportMargin({top: 15, start: 10}).withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + }, + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.top)).toBe(15); + expect(Math.floor(overlayRect.left)).toBe(10); + }); + + it('should only set the margins that were provided when pushing the overlay into the viewport from both axes', () => { + originElement.style.top = `${-OVERLAY_HEIGHT / 2}px`; + originElement.style.left = `${-OVERLAY_WIDTH / 2}px`; + + positionStrategy.withViewportMargin({start: 30}).withPositions([ + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + }, + ]); + + attachOverlay({positionStrategy}); + + const overlayRect = overlayRef.overlayElement.getBoundingClientRect(); + expect(Math.floor(overlayRect.left)).toBe(OVERLAY_WIDTH / 2); + expect(Math.floor(overlayRect.right)).toBe( + originElement.offsetWidth - OVERLAY_WIDTH / 2 + OVERLAY_WIDTH, + ); + expect(Math.floor(overlayRect.top)).toBe(0); + expect(Math.floor(overlayRect.bottom)).toBe(OVERLAY_HEIGHT); + }); + it('should not mess with the left offset when pushing from the top', () => { originElement.style.top = `${-OVERLAY_HEIGHT * 2}px`; originElement.style.left = '200px'; diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.ts index 27e6e1b4944a..0ac29c4c90a8 100644 --- a/src/cdk/overlay/position/flexible-connected-position-strategy.ts +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.ts @@ -15,6 +15,7 @@ import { ScrollingVisibility, validateHorizontalPosition, validateVerticalPosition, + ViewportMargin, } from './connected-position'; import {Observable, Subscription, Subject} from 'rxjs'; import {isElementScrolledOutsideView, isElementClippedByScrolling} from './scroll-clip'; @@ -88,8 +89,8 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { /** Cached container dimensions */ private _containerRect: Dimensions; - /** Amount of space that must be maintained between the overlay and the edge of the viewport. */ - private _viewportMargin = 0; + /** Amount of space that must be maintained between the overlay and the right edge of the viewport. */ + private _viewportMargin: ViewportMargin = 0; /** The Scrollable containers used to check scrollable view properties on position change. */ private _scrollables: CdkScrollable[] = []; @@ -411,10 +412,11 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { } /** - * 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. + * Sets a minimum distance the overlay may be positioned from the bottom edge of the viewport. + * @param margin Required margin between the overlay and the viewport. + * It can be a number to be applied to all directions, or an object to supply different values for each direction. */ - withViewportMargin(margin: number): this { + withViewportMargin(margin: ViewportMargin): this { this._viewportMargin = margin; return this; } @@ -682,13 +684,17 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { if (overlay.width <= viewport.width) { pushX = overflowLeft || -overflowRight; } else { - pushX = start.x < this._viewportMargin ? viewport.left - scrollPosition.left - start.x : 0; + pushX = + start.x < this._getViewportMarginStart() + ? 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; + pushY = + start.y < this._getViewportMarginTop() ? viewport.top - scrollPosition.top - start.y : 0; } this._previousPushAmount = {x: pushX, y: pushY}; @@ -777,13 +783,14 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { 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; + height = viewport.height - top + this._getViewportMarginBottom(); } 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 `DOMRect`. - bottom = viewport.height - origin.y + this._viewportMargin * 2; - height = viewport.height - bottom + this._viewportMargin; + bottom = + viewport.height - origin.y + this._getViewportMarginTop() + this._getViewportMarginBottom(); + height = viewport.height - bottom + this._getViewportMarginTop(); } 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 @@ -815,11 +822,12 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { let width: number, left: number, right: number; if (isBoundedByLeftViewportEdge) { - right = viewport.width - origin.x + this._viewportMargin * 2; - width = origin.x - this._viewportMargin; + right = + viewport.width - origin.x + this._getViewportMarginStart() + this._getViewportMarginEnd(); + width = origin.x - this._getViewportMarginStart(); } else if (isBoundedByRightViewportEdge) { left = origin.x; - width = viewport.right - origin.x; + width = viewport.right - origin.x - this._getViewportMarginEnd(); } 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 @@ -1098,12 +1106,12 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { 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, + top: scrollPosition.top + this._getViewportMarginTop(), + left: scrollPosition.left + this._getViewportMarginStart(), + right: scrollPosition.left + width - this._getViewportMarginEnd(), + bottom: scrollPosition.top + height - this._getViewportMarginBottom(), + width: width - this._getViewportMarginStart() - this._getViewportMarginEnd(), + height: height - this._getViewportMarginTop() - this._getViewportMarginBottom(), }; } @@ -1168,6 +1176,42 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { } } + /** + * Returns either the _viewportMargin directly (if it is a number) or its 'start' value. + * @private + */ + private _getViewportMarginStart(): number { + if (typeof this._viewportMargin === 'number') return this._viewportMargin; + return this._viewportMargin?.start ?? 0; + } + + /** + * Returns either the _viewportMargin directly (if it is a number) or its 'end' value. + * @private + */ + private _getViewportMarginEnd(): number { + if (typeof this._viewportMargin === 'number') return this._viewportMargin; + return this._viewportMargin?.end ?? 0; + } + + /** + * Returns either the _viewportMargin directly (if it is a number) or its 'top' value. + * @private + */ + private _getViewportMarginTop(): number { + if (typeof this._viewportMargin === 'number') return this._viewportMargin; + return this._viewportMargin?.top ?? 0; + } + + /** + * Returns either the _viewportMargin directly (if it is a number) or its 'bottom' value. + * @private + */ + private _getViewportMarginBottom(): number { + if (typeof this._viewportMargin === 'number') return this._viewportMargin; + return this._viewportMargin?.bottom ?? 0; + } + /** Returns the DOMRect of the current origin. */ private _getOriginRect(): Dimensions { const origin = this._origin;