diff --git a/libs/barista-components/autocomplete/src/autocomplete-trigger.ts b/libs/barista-components/autocomplete/src/autocomplete-trigger.ts index 97e68d48d1..84fb1f91a9 100644 --- a/libs/barista-components/autocomplete/src/autocomplete-trigger.ts +++ b/libs/barista-components/autocomplete/src/autocomplete-trigger.ts @@ -80,6 +80,9 @@ import { DtViewportResizer, isDefined, stringify, + ViewportBoundaries, + mixinViewportBoundaries, + Constructor, } from '@dynatrace/barista-components/core'; import { DtFormField } from '@dynatrace/barista-components/form-field'; @@ -136,6 +139,14 @@ export function calculateOptionHeight( }; } +// Boilerplate for applying mixins to DtAutocompleteTrigger. +export class DtAutocompleteTriggerBase { + constructor(public _viewportResizer: DtViewportResizer) {} +} +export const _DtAutocompleteTriggerMixinBase = mixinViewportBoundaries< + Constructor +>(DtAutocompleteTriggerBase); + @Directive({ selector: `input[dtAutocomplete], textarea[dtAutocomplete]`, exportAs: 'dtAutocompleteTrigger', @@ -157,6 +168,7 @@ export function calculateOptionHeight( providers: [DT_AUTOCOMPLETE_VALUE_ACCESSOR], }) export class DtAutocompleteTrigger + extends _DtAutocompleteTriggerMixinBase implements ControlValueAccessor, OnDestroy { private _optionHeight: number; private _maxPanelHeight: number; @@ -291,13 +303,14 @@ export class DtAutocompleteTrigger /** Old value of the native input. Used to work around issues with the `input` event on IE. */ private _previousValue: string | number | null; - private _destroy$ = new Subject(); + /** The stream that holds the viewport boundaries in order to be able to limit where an overlay can be positioned */ + private _viewportBoundaries: ViewportBoundaries = { left: 0, top: 0 }; constructor( private _element: ElementRef, private _overlay: Overlay, private _changeDetectorRef: ChangeDetectorRef, - private _viewportResizer: DtViewportResizer, + public _viewportResizer: DtViewportResizer, private _zone: NgZone, private _viewportRuler: ViewportRuler, private _platform: Platform, @@ -312,6 +325,7 @@ export class DtAutocompleteTrigger @Inject(DT_OPTION_CONFIG) optionConfig?: DtOptionConfiguration, ) { + super(_viewportResizer); // tslint:disable-next-line:strict-type-predicates if (typeof window !== 'undefined') { _zone.runOutsideAngular(() => { @@ -328,16 +342,23 @@ export class DtAutocompleteTrigger }); } - if (this._viewportResizer) { - this._viewportResizer - .change() - .pipe(takeUntil(this._destroy$)) - .subscribe(() => { - if (this.panelOpen && this._overlayRef) { - this._overlayRef.updateSize({ maxWidth: this._getPanelWidth() }); - } - }); - } + this._viewportResizer + .change() + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + if (this.panelOpen && this._overlayRef) { + this._overlayRef.updateSize({ maxWidth: this._getPanelWidth() }); + } + }); + + this._viewportBoundaries$.subscribe((boundaries) => { + this._viewportBoundaries = boundaries; + if (this.panelOpen) { + // attachOverlay updates the position strategy of an already existing + // overlay + this._attachOverlay(); + } + }); const heightConfig = calculateOptionHeight(optionConfig?.height ?? 0); @@ -495,7 +516,9 @@ export class DtAutocompleteTrigger } else { // Update the panel width and position in case anything has changed. this._overlayRef.updateSize({ maxWidth: this._getPanelWidth() }); - this._overlayRef.updatePosition(); + this._overlayRef.updatePositionStrategy( + this._getOverlayPositionStrategy(), + ); } if (this._overlayRef && !this._overlayRef.hasAttached()) { @@ -555,13 +578,13 @@ export class DtAutocompleteTrigger // TODO: reconsider if this config should be providable private _getOverlayConfig(): OverlayConfig { return new OverlayConfig({ - positionStrategy: this._getOverlayPosition(), + positionStrategy: this._getOverlayPositionStrategy(), scrollStrategy: this._overlay.scrollStrategies.reposition(), maxWidth: this._getPanelWidth(), }); } - private _getOverlayPosition(): PositionStrategy { + private _getOverlayPositionStrategy(): PositionStrategy { const originalPositionStrategy = new DtFlexibleConnectedPositionStrategy( this._getConnectedElement(), this._viewportRuler, @@ -573,6 +596,7 @@ export class DtAutocompleteTrigger this._positionStrategy = originalPositionStrategy .withFlexibleDimensions(false) .withPush(false) + .withViewportBoundaries(this._viewportBoundaries) .withPositions([ { originX: 'start', diff --git a/libs/barista-components/chart/src/selection-area/selection-area.ts b/libs/barista-components/chart/src/selection-area/selection-area.ts index 236429f5b2..84e6da6127 100644 --- a/libs/barista-components/chart/src/selection-area/selection-area.ts +++ b/libs/barista-components/chart/src/selection-area/selection-area.ts @@ -50,6 +50,8 @@ import { _readKeyCode, _removeCssClass, ViewportBoundaries, + mixinViewportBoundaries, + Constructor, } from '@dynatrace/barista-components/core'; import { animationFrameScheduler, @@ -58,8 +60,6 @@ import { fromEvent, merge, Observable, - of, - Subject, } from 'rxjs'; import { concatMapTo, @@ -68,7 +68,6 @@ import { map, mapTo, share, - shareReplay, startWith, switchMap, take, @@ -115,6 +114,14 @@ import { getTouchStream, } from './streams'; +// Boilerplate for applying mixins to DtChartSelectionArea. +export class DtChartSelectionAreaBase { + constructor(public _viewportResizer: DtViewportResizer) {} +} +export const _DtChartSelectionAreaMixinBase = mixinViewportBoundaries< + Constructor +>(DtChartSelectionAreaBase); + @Component({ selector: 'dt-chart-selection-area', templateUrl: 'selection-area.html', @@ -128,7 +135,9 @@ import { '[attr.tabindex]': '0', }, }) -export class DtChartSelectionArea implements AfterContentInit, OnDestroy { +export class DtChartSelectionArea + extends _DtChartSelectionAreaMixinBase + implements AfterContentInit, OnDestroy { /** @internal The timestamp that follows the mouse */ @ViewChild('hairline', { static: true }) _hairline: ElementRef; @@ -172,11 +181,6 @@ export class DtChartSelectionArea implements AfterContentInit, OnDestroy { /** Stream that holds the Bounding Client Rect of the selection area. set after Highcharts render */ private _selectionAreaBcr$: Observable = EMPTY; - /** Subject to unsubscribe from every subscription */ - private _destroy$ = new Subject(); - - private _viewportBoundaries$: Observable = EMPTY; - constructor( @SkipSelf() private _chart: DtChart, private _elementRef: ElementRef, @@ -189,18 +193,13 @@ export class DtChartSelectionArea implements AfterContentInit, OnDestroy { private _changeDetectorRef: ChangeDetectorRef, // tslint:disable-next-line: no-any @Inject(DOCUMENT) private _document: any, - @Optional() private _viewportResizer: DtViewportResizer, - ) {} + // @breaking-change Will be made mandatory with 9.0.0 + @Optional() public _viewportResizer: DtViewportResizer, + ) { + super(_viewportResizer); + } ngAfterContentInit(): void { - this._viewportBoundaries$ = this._viewportResizer - ? this._viewportResizer.offset$.pipe( - startWith({ top: 0, left: 0 }), - distinctUntilChanged(), - shareReplay(), - ) - : of({ left: 0, top: 0 }); - this._plotBackground$ = this._chart._afterRender.asObservable().pipe( concatMapTo(this._chart._plotBackground$), // plot background can be null as well diff --git a/libs/barista-components/core/src/common-behaviours/index.ts b/libs/barista-components/core/src/common-behaviours/index.ts index be03067719..965398cbc5 100644 --- a/libs/barista-components/core/src/common-behaviours/index.ts +++ b/libs/barista-components/core/src/common-behaviours/index.ts @@ -22,3 +22,4 @@ export * from './progress'; export * from './tabindex'; export * from './dom-exit'; export * from './id'; +export * from './viewport-boundaries'; diff --git a/libs/barista-components/core/src/common-behaviours/viewport-boundaries.spec.ts b/libs/barista-components/core/src/common-behaviours/viewport-boundaries.spec.ts new file mode 100644 index 0000000000..a6e67fb32a --- /dev/null +++ b/libs/barista-components/core/src/common-behaviours/viewport-boundaries.spec.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ViewportRuler } from '@angular/cdk/overlay'; +import { Platform } from '@angular/cdk/platform'; +import { NgZone } from '@angular/core'; +import { MockNgZone } from '@dynatrace/testing/browser'; +import { DtDefaultViewportResizer, DtViewportResizer } from '../viewport'; +import { mixinViewportBoundaries } from './viewport-boundaries'; + +describe('MixinViewportBoundaries', () => { + it('should augment an existing class with a _viewportBoundaries$ property', () => { + const classWithDisabled = mixinViewportBoundaries(TestClass); + const instance = new classWithDisabled(); + + // Expected the mixed-into class to have a _viewportBoundaries property + expect(instance).toHaveProperty('_viewportBoundaries$'); + }); + + it('should augment an existing class with a _destroy$ property', () => { + const classWithDisabled = mixinViewportBoundaries(TestClass); + const instance = new classWithDisabled(); + + // Expected the mixed-into class to have a _viewportBoundaries property + expect(instance).toHaveProperty('_destroy$'); + }); +}); + +class TestClass { + _platform = new Platform('testid'); + _zone: NgZone = new MockNgZone(); + _viewportRuler = new ViewportRuler(this._platform, this._zone); + /** Fake instance of an DtViewportResizer. */ + _viewportResizer: DtViewportResizer = new DtDefaultViewportResizer( + this._viewportRuler, + ); +} diff --git a/libs/barista-components/core/src/common-behaviours/viewport-boundaries.ts b/libs/barista-components/core/src/common-behaviours/viewport-boundaries.ts new file mode 100644 index 0000000000..b0d418975a --- /dev/null +++ b/libs/barista-components/core/src/common-behaviours/viewport-boundaries.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EMPTY, Observable, of, Subject } from 'rxjs'; +import { distinctUntilChanged, startWith, takeUntil } from 'rxjs/operators'; +import { ViewportBoundaries } from '../overlay/flexible-connected-position-strategy'; +import { DtViewportResizer } from '../viewport'; +import { Constructor } from './constructor'; + +export interface HasViewportBoundaries { + /** The current viewport boundaries */ + _viewportBoundaries$: Observable; +} + +export interface HasDestroySubject { + _destroy$: Subject; +} + +export interface HasDtViewportResizer { + _viewportResizer: DtViewportResizer; +} + +/** Mixin to augment a directive with a `viewportBoundaries$` property. */ +export function mixinViewportBoundaries< + T extends Constructor +>( + base: T, +): Constructor & Constructor & T { + return class extends base { + _viewportBoundaries$: Observable = EMPTY; + + _destroy$ = new Subject(); + + // tslint:disable-next-line:no-any + constructor(...args: any[]) { + super(...args); + + this._viewportBoundaries$ = this._viewportResizer + ? this._viewportResizer.offset$.pipe( + startWith({ top: 0, left: 0 }), + distinctUntilChanged(), + takeUntil(this._destroy$), + ) + : of({ left: 0, top: 0 }); + } + }; +} diff --git a/libs/barista-components/filter-field/src/filter-field-range/filter-field-range-trigger.ts b/libs/barista-components/filter-field/src/filter-field-range/filter-field-range-trigger.ts index 609cb37c5d..5957c0cee1 100644 --- a/libs/barista-components/filter-field/src/filter-field-range/filter-field-range-trigger.ts +++ b/libs/barista-components/filter-field/src/filter-field-range/filter-field-range-trigger.ts @@ -32,6 +32,7 @@ import { NgZone, OnDestroy, Inject, + Optional, } from '@angular/core'; import { EMPTY, @@ -42,17 +43,28 @@ import { merge, of as observableOf, } from 'rxjs'; -import { filter, map, take } from 'rxjs/operators'; +import { filter, map, take, takeUntil } from 'rxjs/operators'; import { _readKeyCode, DtFlexibleConnectedPositionStrategy, + DtViewportResizer, + mixinViewportBoundaries, + Constructor, } from '@dynatrace/barista-components/core'; import { DtFilterFieldRange } from './filter-field-range'; import { Platform } from '@angular/cdk/platform'; import { DOCUMENT } from '@angular/common'; +// Boilerplate for applying mixins to DtFilterFieldRangeTrigger. +export class DtFilterFieldRangeTriggerBase { + constructor(public _viewportResizer: DtViewportResizer) {} +} +export const _DtFilterFieldRangeTriggerMixinBase = mixinViewportBoundaries< + Constructor +>(DtFilterFieldRangeTriggerBase); + @Directive({ selector: `input[dtFilterFieldRange]`, exportAs: 'dtFilterFieldRangeTrigger', @@ -62,7 +74,9 @@ import { DOCUMENT } from '@angular/common'; '[attr.aria-owns]': '(rangeDisabled || !panelOpen) ? null : range?.id', }, }) -export class DtFilterFieldRangeTrigger implements OnDestroy { +export class DtFilterFieldRangeTrigger + extends _DtFilterFieldRangeTriggerMixinBase + implements OnDestroy { /** The filter-field range panel to be attached to this trigger. */ @Input('dtFilterFieldRange') get range(): DtFilterFieldRange { @@ -132,9 +146,6 @@ export class DtFilterFieldRangeTrigger implements OnDestroy { /** The subscription for closing actions (some are bound to document). */ private _closingActionsSubscription = EMPTY.subscribe(); - /** The subscription for the window blur event */ - private _windowBlurSubscription = EMPTY.subscribe(); - /** * Whether the autocomplete can open the next time it is focused. Used to prevent a focused, * closed autocomplete from being reopened if the user switches to another browser tab and then @@ -142,6 +153,9 @@ export class DtFilterFieldRangeTrigger implements OnDestroy { */ private _canOpenOnNextFocus = true; + /** The current viewport boundaries */ + private _viewportBoundaries = { left: 0, top: 0 }; + constructor( private _elementRef: ElementRef, private _overlay: Overlay, @@ -152,27 +166,34 @@ export class DtFilterFieldRangeTrigger implements OnDestroy { zone: NgZone, // tslint:disable-next-line:no-any @Inject(DOCUMENT) private _document: any, + // @breaking-change Will be made mandatory with 9.0.0 + @Optional() public _viewportResizer: DtViewportResizer, ) { + super(_viewportResizer); // tslint:disable-next-line:strict-type-predicates if (typeof window !== 'undefined') { zone.runOutsideAngular(() => { - this._windowBlurSubscription = fromEvent(window, 'blur').subscribe( - () => { + fromEvent(window, 'blur') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { // If the user blurred the window while the autocomplete is focused, it means that it'll be // refocused when they come back. In this case we want to skip the first focus event, if the // pane was closed, in order to avoid reopening it unintentionally. this._canOpenOnNextFocus = document.activeElement !== this._elementRef.nativeElement || this.panelOpen; - }, - ); + }); }); } + this._viewportBoundaries$.subscribe((boundaries) => { + this._viewportBoundaries = boundaries; + }); } ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); this._closingActionsSubscription.unsubscribe(); - this._windowBlurSubscription.unsubscribe(); this._componentDestroyed = true; this._destroyPanel(); this._closeKeyEventStream.complete(); @@ -239,6 +260,10 @@ export class DtFilterFieldRangeTrigger implements OnDestroy { } if (this._overlayRef && !this._overlayRef.hasAttached()) { this._overlayRef.attach(this._range._portal); + // Always unsubscribe here, it might happen that no + // closing action was triggered in between 2 calls + // to this method + this._closingActionsSubscription.unsubscribe(); this._closingActionsSubscription = this._subscribeToClosingActions(); } @@ -246,7 +271,7 @@ export class DtFilterFieldRangeTrigger implements OnDestroy { this.range.opened.emit(); if (this.panelOpen) { - this._overlayRef.updatePosition(); + this._overlayRef.updatePositionStrategy(this._getOverlayPosition()); } this.range._markForCheck(); @@ -288,6 +313,7 @@ export class DtFilterFieldRangeTrigger implements OnDestroy { ) .withFlexibleDimensions(false) .withPush(false) + .withViewportBoundaries(this._viewportBoundaries) .withPositions([ { originX: 'start',