diff --git a/api-goldens/element-ng/common/index.api.md b/api-goldens/element-ng/common/index.api.md index 7f97178eba..3256b7c451 100644 --- a/api-goldens/element-ng/common/index.api.md +++ b/api-goldens/element-ng/common/index.api.md @@ -16,6 +16,7 @@ import { OverlayRef } from '@angular/cdk/overlay'; import { PositionStrategy } from '@angular/cdk/overlay'; import { Provider } from '@angular/core'; import * as rxjs from 'rxjs'; +import { ScrollStrategy } from '@angular/cdk/overlay'; import { TranslatableString } from '@siemens/element-translate-ng/translate'; import { Type } from '@angular/core'; @@ -111,7 +112,7 @@ export const getContentPositionString: (params: { }) => string; // @public (undocumented) -export function getOverlay(elementRef: ElementRef, overlay: Overlay, hasBackdrop: boolean, placement: keyof typeof positions | ConnectionPositionPair[], constrain?: boolean, center?: boolean): OverlayRef; +export function getOverlay(elementRef: ElementRef, overlay: Overlay, hasBackdrop: boolean, placement: keyof typeof positions | ConnectionPositionPair[], constrain?: boolean, center?: boolean, scrollStrategy?: ScrollStrategy): OverlayRef; // @public (undocumented) export function getOverlayPositions(elementRef: ElementRef, placement: keyof typeof positions | ConnectionPositionPair[], center?: boolean): ConnectionPositionPair[]; @@ -129,7 +130,7 @@ export const isRTL: (elem?: HTMLElement) => boolean; export const listenGlobal: (eventName: string, handler: (e: any) => void, active?: boolean) => (() => void); // @public (undocumented) -export function makeOverlay(positionStrategy: PositionStrategy, overlay: Overlay, hasBackdrop: boolean): OverlayRef; +export function makeOverlay(positionStrategy: PositionStrategy, overlay: Overlay, hasBackdrop: boolean, scrollStrategy?: ScrollStrategy): OverlayRef; // @public export function makePositionStrategy(elementRef: ElementRef | undefined, overlay: Overlay, placement: keyof typeof positions | ConnectionPositionPair[], constrain?: boolean, center?: boolean): PositionStrategy; diff --git a/api-goldens/element-ng/popover/index.api.md b/api-goldens/element-ng/popover/index.api.md index d367cfcce7..62490af45f 100644 --- a/api-goldens/element-ng/popover/index.api.md +++ b/api-goldens/element-ng/popover/index.api.md @@ -11,6 +11,7 @@ import { Injector } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { OverlayArrowPosition } from '@siemens/element-ng/common'; +import { ScrollStrategy } from '@angular/cdk/overlay'; import * as _siemens_element_translate_ng_translate from '@siemens/element-translate-ng/translate'; import { TemplateRef } from '@angular/core'; import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; @@ -30,6 +31,7 @@ export class SiPopoverDirective implements OnDestroy { readonly placement: _angular_core.InputSignal<"auto" | "start" | "end" | "top" | "bottom">; // (undocumented) readonly placementInternal: _angular_core.Signal<"auto" | "start" | "end" | "top" | "bottom">; + readonly scrollStrategy: _angular_core.InputSignal; show(): void; readonly siPopover: _angular_core.InputSignal | undefined>; readonly title: _angular_core.InputSignal; diff --git a/api-goldens/element-ng/tooltip/index.api.md b/api-goldens/element-ng/tooltip/index.api.md index 45bb40bafd..a5441f5b3a 100644 --- a/api-goldens/element-ng/tooltip/index.api.md +++ b/api-goldens/element-ng/tooltip/index.api.md @@ -10,6 +10,7 @@ import { Injector } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { OverlayRef } from '@angular/cdk/overlay'; import { positions } from '@siemens/element-ng/common'; +import { ScrollStrategy } from '@angular/cdk/overlay'; import { TemplateRef } from '@angular/core'; import { TranslatableString } from '@siemens/element-translate-ng/translate'; import { Type } from '@angular/core'; @@ -17,9 +18,10 @@ import { Type } from '@angular/core'; // @public (undocumented) export class SiTooltipDirective implements OnDestroy { readonly isDisabled: i0.InputSignalWithTransform; - readonly placement: i0.InputSignal<"auto" | "top" | "start" | "end" | "bottom">; + readonly placement: i0.InputSignal<"auto" | "start" | "end" | "top" | "bottom">; readonly siTooltip: i0.InputSignal | TranslatableString>; readonly tooltipContext: i0.InputSignal; + readonly tooltipScrollStrategy: i0.InputSignal; } // @public (undocumented) diff --git a/projects/element-ng/common/helpers/overlay-helper.ts b/projects/element-ng/common/helpers/overlay-helper.ts index e1787a09a4..40ce6fb4ae 100644 --- a/projects/element-ng/common/helpers/overlay-helper.ts +++ b/projects/element-ng/common/helpers/overlay-helper.ts @@ -10,7 +10,8 @@ import { Overlay, OverlayConfig, OverlayRef, - PositionStrategy + PositionStrategy, + ScrollStrategy } from '@angular/cdk/overlay'; import { ElementRef } from '@angular/core'; @@ -51,11 +52,12 @@ export function makePositionStrategy( export function makeOverlay( positionStrategy: PositionStrategy, overlay: Overlay, - hasBackdrop: boolean + hasBackdrop: boolean, + scrollStrategy?: ScrollStrategy ): OverlayRef { const config = new OverlayConfig(); config.positionStrategy = positionStrategy; - config.scrollStrategy = overlay.scrollStrategies.reposition(); + config.scrollStrategy = scrollStrategy ?? overlay.scrollStrategies.reposition(); config.direction = isRTL() ? 'rtl' : 'ltr'; if (hasBackdrop) { config.hasBackdrop = true; @@ -72,10 +74,11 @@ export function getOverlay( hasBackdrop: boolean, placement: keyof typeof positions | ConnectionPositionPair[], constrain = false, - center = true + center = true, + scrollStrategy?: ScrollStrategy ): OverlayRef { const positionStrategy = makePositionStrategy(elementRef, overlay, placement, constrain, center); - return makeOverlay(positionStrategy, overlay, hasBackdrop); + return makeOverlay(positionStrategy, overlay, hasBackdrop, scrollStrategy); } export function getPositionStrategy( diff --git a/projects/element-ng/popover/si-popover.directive.spec.ts b/projects/element-ng/popover/si-popover.directive.spec.ts index f799268175..6bd48b759e 100644 --- a/projects/element-ng/popover/si-popover.directive.spec.ts +++ b/projects/element-ng/popover/si-popover.directive.spec.ts @@ -2,7 +2,8 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { Component, viewChild } from '@angular/core'; +import { Overlay, ScrollStrategy } from '@angular/cdk/overlay'; +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SiPopoverDirective } from './si-popover.directive'; @@ -193,3 +194,41 @@ describe('with custom template', () => { vi.useRealTimers(); }); }); + +describe('with scrollStrategy', () => { + let fixture: ComponentFixture; + let button: HTMLButtonElement; + + @Component({ + imports: [SiPopoverDirective], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush + }) + class ScrollStrategyHostComponent { + readonly scrollStrategy = signal(undefined); + } + + beforeEach(() => { + fixture = TestBed.createComponent(ScrollStrategyHostComponent); + button = fixture.nativeElement.querySelector('button') as HTMLButtonElement; + fixture.detectChanges(); + }); + + it('should close popover on scroll when custom close scroll strategy is provided', async () => { + const overlay = TestBed.inject(Overlay); + fixture.componentInstance.scrollStrategy.set(overlay.scrollStrategies.close()); + fixture.detectChanges(); + + button.click(); + await fixture.whenStable(); + + expect(document.querySelector('.popover')).toBeInTheDocument(); + + document.dispatchEvent(new Event('scroll', { bubbles: true })); + await fixture.whenStable(); + + expect(document.querySelector('.popover')).not.toBeInTheDocument(); + }); +}); diff --git a/projects/element-ng/popover/si-popover.directive.ts b/projects/element-ng/popover/si-popover.directive.ts index fa5f12d0cc..c18142cd09 100644 --- a/projects/element-ng/popover/si-popover.directive.ts +++ b/projects/element-ng/popover/si-popover.directive.ts @@ -2,7 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { Overlay, OverlayRef, ScrollStrategy } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentRef, @@ -87,6 +87,14 @@ export class SiPopoverDirective implements OnDestroy { */ readonly context = input(undefined, { alias: 'siPopoverContext' }); + /** + * Optional CDK scroll strategy used for the popover overlay. + * If not provided, the default reposition strategy is used. + * + * @defaultValue undefined + */ + readonly scrollStrategy = input(undefined, { alias: 'siPopoverScrollStrategy' }); + /** * Emits an event when the popover is shown/hidden */ @@ -118,7 +126,15 @@ export class SiPopoverDirective implements OnDestroy { if (this.overlayref?.hasAttached()) { return; } - this.overlayref = getOverlay(this.elementRef, this.overlay, false, this.placementInternal()); + this.overlayref = getOverlay( + this.elementRef, + this.overlay, + false, + this.placementInternal(), + false, + true, + this.scrollStrategy() + ); this.overlayref .outsidePointerEvents() .pipe(takeUntil(this.destroyer)) diff --git a/projects/element-ng/tooltip/si-tooltip.directive.spec.ts b/projects/element-ng/tooltip/si-tooltip.directive.spec.ts index 064bdb967b..7b6b2bcb50 100644 --- a/projects/element-ng/tooltip/si-tooltip.directive.spec.ts +++ b/projects/element-ng/tooltip/si-tooltip.directive.spec.ts @@ -2,7 +2,8 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { Component, signal } from '@angular/core'; +import { Overlay, ScrollStrategy } from '@angular/cdk/overlay'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SiTooltipModule } from './si-tooltip.module'; @@ -137,4 +138,50 @@ describe('SiTooltipDirective', () => { expect(document.querySelector('.tooltip')).not.toBeInTheDocument(); }); }); + + describe('with scrollStrategy', () => { + let fixture: ComponentFixture; + let button: HTMLButtonElement; + + @Component({ + imports: [SiTooltipModule], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush + }) + class TestHostComponent { + readonly scrollStrategy = signal(undefined); + } + + beforeEach(() => { + vi.useFakeTimers(); + fixture = TestBed.createComponent(TestHostComponent); + button = fixture.nativeElement.querySelector('button') as HTMLButtonElement; + vi.spyOn(button, 'matches').mockReturnValue(true); + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should close tooltip on scroll when custom close scroll strategy is provided', async () => { + const overlay = TestBed.inject(Overlay); + fixture.componentInstance.scrollStrategy.set(overlay.scrollStrategies.close()); + fixture.detectChanges(); + + button.dispatchEvent(new Event('focus')); + vi.advanceTimersByTime(0); + await fixture.whenStable(); + + expect(document.querySelector('.tooltip')).toBeInTheDocument(); + + document.dispatchEvent(new Event('scroll', { bubbles: true })); + vi.advanceTimersByTime(0); + await fixture.whenStable(); + + expect(document.querySelector('.tooltip')).not.toBeInTheDocument(); + }); + }); }); diff --git a/projects/element-ng/tooltip/si-tooltip.directive.ts b/projects/element-ng/tooltip/si-tooltip.directive.ts index 07ec29024e..e975d06364 100644 --- a/projects/element-ng/tooltip/si-tooltip.directive.ts +++ b/projects/element-ng/tooltip/si-tooltip.directive.ts @@ -2,6 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ +import { ScrollStrategy } from '@angular/cdk/overlay'; import { isPlatformBrowser } from '@angular/common'; import { booleanAttribute, @@ -54,6 +55,12 @@ export class SiTooltipDirective implements OnDestroy { */ readonly isDisabled = input(false, { transform: booleanAttribute }); + /** + * Optional CDK scroll strategy used for the tooltip overlay. + * If not provided, the default reposition strategy is used. + */ + readonly tooltipScrollStrategy = input(); + /** * The context for the attached template */ @@ -95,7 +102,8 @@ export class SiTooltipDirective implements OnDestroy { element: this.elementRef, placement: this.placement(), tooltip: this.siTooltip, - tooltipContext: this.tooltipContext + tooltipContext: this.tooltipContext, + scrollStrategy: this.tooltipScrollStrategy() }); this.tooltipRef.show(); }, delay); diff --git a/projects/element-ng/tooltip/si-tooltip.service.ts b/projects/element-ng/tooltip/si-tooltip.service.ts index ef2b965dfc..1cef4c1690 100644 --- a/projects/element-ng/tooltip/si-tooltip.service.ts +++ b/projects/element-ng/tooltip/si-tooltip.service.ts @@ -2,7 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { Overlay, OverlayRef, ScrollStrategy } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentRef, ElementRef, inject, Injectable, Injector } from '@angular/core'; import { getOverlay, getPositionStrategy, positions } from '@siemens/element-ng/common'; @@ -71,6 +71,7 @@ export class SiTooltipService { injector?: Injector; tooltip: () => SiTooltipContent; tooltipContext: () => unknown; + scrollStrategy?: ScrollStrategy; }): TooltipRef { const injector = Injector.create({ parent: config.injector, @@ -87,7 +88,15 @@ export class SiTooltipService { }); return new TooltipRef( - getOverlay(config.element, this.overlay, false, config.placement), + getOverlay( + config.element, + this.overlay, + false, + config.placement, + false, + true, + config.scrollStrategy + ), config.element, injector );