From 7222ce6010129fb08ac52566d2c91cd3a0d234ee Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Thu, 23 Jul 2026 17:40:49 +0300 Subject: [PATCH 01/13] feat: added reposition and scroll strategy (#DS-1920) --- .../core/overlay/hide-on-scroll.strategy.ts | 138 ++++++++++++++++++ packages/components/core/public-api.ts | 1 + .../components/popover/popover.component.ts | 75 ++-------- packages/components/popover/popover.module.ts | 10 +- 4 files changed, 156 insertions(+), 68 deletions(-) create mode 100644 packages/components/core/overlay/hide-on-scroll.strategy.ts diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/hide-on-scroll.strategy.ts new file mode 100644 index 0000000000..253bdc4ca1 --- /dev/null +++ b/packages/components/core/overlay/hide-on-scroll.strategy.ts @@ -0,0 +1,138 @@ +import { OverlayRef, ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; +import { ViewportRuler } from '@angular/cdk/scrolling'; +import { InjectionToken, NgZone } from '@angular/core'; +import { Observable, Subject, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +export interface KbqHideOnScrollStrategyConfig { + /** + * Element whose position is tracked against ancestor scroll container boundaries. + * When omitted, the overlay panel is checked against the viewport instead. + */ + originElement?: HTMLElement; + /** Scroll event throttle in ms. Defaults to 20. */ + scrollThrottle?: number; +} + +/** + * Scroll strategy that repositions the overlay on scroll and emits on `hide$` + * when the tracked element moves outside its boundary: + * + * - With `originElement`: tracks the origin against each ancestor `CdkScrollable` container. + * - Without `originElement`: tracks the overlay panel against the viewport. + * + * The caller is responsible for subscribing to `hide$` and hiding/closing the overlay. + */ +export class KbqHideOnScrollStrategy implements ScrollStrategy { + private readonly _hideSubject = new Subject(); + + /** Emits when the tracked element scrolls outside its boundary. */ + readonly hide$: Observable = this._hideSubject.asObservable(); + + private _overlayRef: OverlayRef | null = null; + private _scrollSubscription: Subscription | null = null; + + constructor( + private readonly _scrollDispatcher: ScrollDispatcher, + private readonly _viewportRuler: ViewportRuler, + private readonly _ngZone: NgZone, + private readonly _config: KbqHideOnScrollStrategyConfig + ) {} + + /** @docs-private */ + attach(overlayRef: OverlayRef): void { + this._overlayRef = overlayRef; + } + + /** Subscribes to scroll events and starts repositioning / out-of-bounds detection. */ + enable(): void { + if (this._scrollSubscription) return; + + const { originElement, scrollThrottle = 20 } = this._config; + + const stream$ = originElement + ? this._scrollDispatcher.ancestorScrolled(originElement, scrollThrottle) + : this._scrollDispatcher + .scrolled(scrollThrottle) + .pipe( + filter( + (scrollable) => + !scrollable || + !this._overlayRef!.overlayElement.contains(scrollable.getElementRef().nativeElement) + ) + ); + + this._scrollSubscription = stream$.subscribe(() => { + this._overlayRef!.updatePosition(); + + const isOutside = originElement + ? this._isOriginOutsideAncestors(originElement) + : this._isOverlayOutsideViewport(); + + if (isOutside) { + this._ngZone.run(() => this._hideSubject.next()); + } + }); + } + + /** Unsubscribes from scroll events. */ + disable(): void { + this._scrollSubscription?.unsubscribe(); + this._scrollSubscription = null; + } + + /** Disables the strategy and completes `hide$`. */ + detach(): void { + this.disable(); + this._hideSubject.complete(); + this._overlayRef = null; + } + + private _isOriginOutsideAncestors(originElement: HTMLElement): boolean { + const originRect = originElement.getBoundingClientRect(); + + return this._scrollDispatcher + .getAncestorScrollContainers(originElement) + .some((scrollable) => + this._isOutsideBounds(originRect, scrollable.getElementRef().nativeElement.getBoundingClientRect()) + ); + } + + private _isOverlayOutsideViewport(): boolean { + const overlayRect = this._overlayRef!.overlayElement.getBoundingClientRect(); + const { width, height } = this._viewportRuler.getViewportSize(); + + return this._isOutsideBounds(overlayRect, { top: 0, left: 0, bottom: height, right: width } as DOMRect); + } + + private _isOutsideBounds( + rect: DOMRect, + containerRect: DOMRect | Pick + ): boolean { + return ( + rect.bottom < containerRect.top || + rect.top > containerRect.bottom || + rect.right < containerRect.left || + rect.left > containerRect.right + ); + } +} + +export const KBQ_HIDE_ON_SCROLL_STRATEGY = new InjectionToken< + (config: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy +>('kbq-hide-on-scroll-strategy'); + +/** @docs-private */ +export function kbqHideOnScrollStrategyFactory( + scrollDispatcher: ScrollDispatcher, + viewportRuler: ViewportRuler, + ngZone: NgZone +): (config: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy { + return (config) => new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); +} + +export const KBQ_HIDE_ON_SCROLL_STRATEGY_PROVIDER = { + provide: KBQ_HIDE_ON_SCROLL_STRATEGY, + deps: [ScrollDispatcher, ViewportRuler, NgZone], + useFactory: kbqHideOnScrollStrategyFactory +}; diff --git a/packages/components/core/public-api.ts b/packages/components/core/public-api.ts index b0d07069af..7917628ffa 100644 --- a/packages/components/core/public-api.ts +++ b/packages/components/core/public-api.ts @@ -13,6 +13,7 @@ export * from './locales/index'; export * from './navbar/index'; export * from './option/index'; export * from './overflow-shadow/index'; +export * from './overlay/hide-on-scroll.strategy'; export * from './overlay/overlay-position-map'; export * from './overlay/panel-width'; export * from './overlay/shadow-dom-overlay-container'; diff --git a/packages/components/popover/popover.component.ts b/packages/components/popover/popover.component.ts index d0b22d1f72..5b2aebd3bc 100644 --- a/packages/components/popover/popover.component.ts +++ b/packages/components/popover/popover.component.ts @@ -2,16 +2,13 @@ import { CdkTrapFocus } from '@angular/cdk/a11y'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { CdkObserveContent } from '@angular/cdk/observers'; import { - CdkScrollable, FlexibleConnectedPositionStrategy, - Overlay, OverlayConfig, OverlayContainer, ScrollStrategy } from '@angular/cdk/overlay'; import { NgTemplateOutlet } from '@angular/common'; import { - AfterContentInit, AfterViewInit, ChangeDetectionStrategy, Component, @@ -19,9 +16,7 @@ import { Directive, ElementRef, EventEmitter, - InjectionToken, Input, - OnInit, Output, Renderer2, TemplateRef, @@ -37,6 +32,7 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { + KBQ_HIDE_ON_SCROLL_STRATEGY, KbqComponentColors, KbqOverflowShadowBottom, KbqOverflowShadowContainer, @@ -129,20 +125,6 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { protected readonly componentColors = KbqComponentColors; } -export const KBQ_POPOVER_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-popover-scroll-strategy'); - -/** @docs-private */ -export function kbqPopoverScrollStrategyFactory(overlay: Overlay): () => ScrollStrategy { - return () => overlay.scrollStrategies.reposition({ scrollThrottle: 20 }); -} - -/** @docs-private */ -export const KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER = { - provide: KBQ_POPOVER_SCROLL_STRATEGY, - deps: [Overlay], - useFactory: kbqPopoverScrollStrategyFactory -}; - /** Creates an error to be thrown if the user supplied an invalid popover position. */ export function getKbqPopoverInvalidPositionError(position: string) { return Error(`KbqPopover position "${position}" is invalid.`); @@ -158,11 +140,22 @@ export function getKbqPopoverInvalidPositionError(position: string) { }, exportAs: 'kbqPopover' }) -export class KbqPopoverTrigger extends KbqPopUpTrigger implements AfterContentInit, OnInit { +export class KbqPopoverTrigger extends KbqPopUpTrigger { private overlayContainer = inject(OverlayContainer); private renderer = inject(Renderer2); + private kbqHideOnScrollStrategy = inject(KBQ_HIDE_ON_SCROLL_STRATEGY); + + protected scrollStrategy = (): ScrollStrategy => { + if (this.closeOnScroll === null && this.hideIfNotInViewPort()) { + const strategy = this.kbqHideOnScrollStrategy({ originElement: this.elementRef.nativeElement }); + + strategy.hide$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.hide()); - protected scrollStrategy: () => ScrollStrategy = inject(KBQ_POPOVER_SCROLL_STRATEGY); + return strategy; + } + + return this.overlay.scrollStrategies.reposition({ scrollThrottle: 20 }); + }; /** Controls whether the component should be hidden when it is not visible in the viewport. */ readonly hideIfNotInViewPort = input(true, { transform: booleanAttribute }); @@ -441,30 +434,6 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl private classAddedToOverlayContainer: boolean = false; - ngOnInit(): void { - super.ngOnInit(); - - this.scrollable - ?.elementScrolled() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(this.hideIfScrolledOutOfView); - } - - ngAfterContentInit(): void { - if (this.closeOnScroll === null) { - this.scrollDispatcher.scrolled().subscribe((scrollable: CdkScrollable | void) => { - if (!scrollable?.getElementRef().nativeElement.classList.contains('kbq-hide-nested-popup')) return; - - const parentRects = scrollable.getElementRef().nativeElement.getBoundingClientRect(); - const childRects = this.elementRef.nativeElement.getBoundingClientRect(); - - if (childRects.bottom < parentRects.top || childRects.top > parentRects.bottom) { - this.hide(); - } - }); - } - } - /** * Overrides the base `show` method to display the overlay component with the * specified entry delay and apply default positioning offsets. @@ -545,22 +514,6 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl return merge(...this.closingActionsForClick(), this.closeOnScroll ? this.scrollDispatcher.scrolled() : NEVER); } - private hideIfScrolledOutOfView = () => { - if (!this.scrollable || !this.hideIfNotInViewPort()) return; - - const rect = this.elementRef.nativeElement.getBoundingClientRect(); - const containerRect = this.scrollable.getElementRef().nativeElement.getBoundingClientRect(); - - if (!( - rect.bottom >= containerRect.top && - rect.right >= containerRect.left && - rect.top <= containerRect.bottom && - rect.left <= containerRect.right - )) { - this.hide(); - } - }; - private addClassToOverlayContainer() { const overlayContainer = this.overlayContainer?.getContainerElement(); diff --git a/packages/components/popover/popover.module.ts b/packages/components/popover/popover.module.ts index 2348d17ef1..742550b5d9 100644 --- a/packages/components/popover/popover.module.ts +++ b/packages/components/popover/popover.module.ts @@ -9,14 +9,10 @@ import { OverlayModule } from '@angular/cdk/overlay'; import { NgTemplateOutlet } from '@angular/common'; import { NgModule } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; -import { EmptyFocusTrapStrategy } from '@koobiq/components/core'; +import { EmptyFocusTrapStrategy, KBQ_HIDE_ON_SCROLL_STRATEGY_PROVIDER } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqPopoverConfirmComponent, KbqPopoverConfirmTrigger } from './popover-confirm.component'; -import { - KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER, - KbqPopoverComponent, - KbqPopoverTrigger -} from './popover.component'; +import { KbqPopoverComponent, KbqPopoverTrigger } from './popover.component'; @NgModule({ imports: [ @@ -32,7 +28,7 @@ import { KbqPopoverConfirmTrigger ], providers: [ - KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER, + KBQ_HIDE_ON_SCROLL_STRATEGY_PROVIDER, { provide: FocusTrapFactory, useClass: ConfigurableFocusTrapFactory }, { provide: FOCUS_TRAP_INERT_STRATEGY, useClass: EmptyFocusTrapStrategy } ], From 5b37de8ef054d86d54d8eb0db672ae02d748a106 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 24 Jul 2026 13:14:07 +0300 Subject: [PATCH 02/13] chore: added popover hide on scroll example --- packages/components-dev/popover/module.ts | 2 + .../components/popover/examples.popover.en.md | 4 ++ .../docs-examples/components/popover/index.ts | 3 + .../popover-hide-on-scroll-example.ts | 58 +++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 packages/docs-examples/components/popover/popover-hide-on-scroll/popover-hide-on-scroll-example.ts diff --git a/packages/components-dev/popover/module.ts b/packages/components-dev/popover/module.ts index e1edb2c407..3c3c2af741 100644 --- a/packages/components-dev/popover/module.ts +++ b/packages/components-dev/popover/module.ts @@ -26,6 +26,8 @@ import { DevThemeToggle } from '../theme-toggle'; selector: 'dev-examples', imports: [PopoverExamplesModule], template: ` + +

diff --git a/packages/components/popover/examples.popover.en.md b/packages/components/popover/examples.popover.en.md index d9a3068f70..41852bb441 100644 --- a/packages/components/popover/examples.popover.en.md +++ b/packages/components/popover/examples.popover.en.md @@ -1,3 +1,7 @@ +#### Hide on scroll out of bounds + + + #### Opening on hover diff --git a/packages/docs-examples/components/popover/index.ts b/packages/docs-examples/components/popover/index.ts index c968e8ee5b..359ee4c34e 100644 --- a/packages/docs-examples/components/popover/index.ts +++ b/packages/docs-examples/components/popover/index.ts @@ -5,6 +5,7 @@ import { PopoverCloseExample } from './popover-close/popover-close-example'; import { PopoverContentExample } from './popover-content/popover-content-example'; import { PopoverHeaderExample } from './popover-header/popover-header-example'; import { PopoverHeightExample } from './popover-height/popover-height-example'; +import { PopoverHideOnScrollExample } from './popover-hide-on-scroll/popover-hide-on-scroll-example'; import { PopoverHoverExample } from './popover-hover/popover-hover-example'; import { PopoverOverviewExample } from './popover-overview/popover-overview-example'; import { PopoverPaddingsExample } from './popover-paddings/popover-paddings-example'; @@ -22,6 +23,7 @@ export { PopoverContentExample, PopoverHeaderExample, PopoverHeightExample, + PopoverHideOnScrollExample, PopoverHoverExample, PopoverOverviewExample, PopoverPaddingsExample, @@ -43,6 +45,7 @@ const EXAMPLES = [ PopoverHeaderExample, PopoverContentExample, PopoverScrollExample, + PopoverHideOnScrollExample, PopoverPlacementCenterExample, PopoverPlacementEdgesExample, PopoverHoverExample, diff --git a/packages/docs-examples/components/popover/popover-hide-on-scroll/popover-hide-on-scroll-example.ts b/packages/docs-examples/components/popover/popover-hide-on-scroll/popover-hide-on-scroll-example.ts new file mode 100644 index 0000000000..f3fdfa6355 --- /dev/null +++ b/packages/docs-examples/components/popover/popover-hide-on-scroll/popover-hide-on-scroll-example.ts @@ -0,0 +1,58 @@ +import { CdkScrollableModule } from '@angular/cdk/scrolling'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqPopoverModule } from '@koobiq/components/popover'; + +/** + * @title popover-hide-on-scroll + */ +@Component({ + selector: 'popover-hide-on-scroll-example', + imports: [CdkScrollableModule, KbqButtonModule, KbqPopoverModule], + template: ` +
+
Scroll down
+ +
+ + + +
+ +
Scroll up
+
+ `, + styles: ` + .popover-hide-on-scroll-example__container { + height: 200px; + overflow-y: auto; + border: 1px solid var(--kbq-line-contrast-less); + border-radius: 4px; + } + + .popover-hide-on-scroll-example__spacer { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--kbq-foreground-contrast-secondary); + } + + .popover-hide-on-scroll-example__triggers { + display: flex; + gap: 16px; + padding: 16px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PopoverHideOnScrollExample {} From 822d1812dca0a9a9ff44d26e4742a32f9d3df8cd Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 24 Jul 2026 13:15:02 +0300 Subject: [PATCH 03/13] chore: upd strategy to listen cdkScrollable/document scroll events --- .../core/overlay/hide-on-scroll.strategy.ts | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/hide-on-scroll.strategy.ts index 253bdc4ca1..6a6f3885c1 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.ts @@ -50,29 +50,26 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { const { originElement, scrollThrottle = 20 } = this._config; - const stream$ = originElement - ? this._scrollDispatcher.ancestorScrolled(originElement, scrollThrottle) - : this._scrollDispatcher - .scrolled(scrollThrottle) - .pipe( - filter( - (scrollable) => - !scrollable || - !this._overlayRef!.overlayElement.contains(scrollable.getElementRef().nativeElement) - ) - ); - - this._scrollSubscription = stream$.subscribe(() => { - this._overlayRef!.updatePosition(); - - const isOutside = originElement - ? this._isOriginOutsideAncestors(originElement) - : this._isOverlayOutsideViewport(); - - if (isOutside) { - this._ngZone.run(() => this._hideSubject.next()); - } - }); + this._scrollSubscription = this._scrollDispatcher + .scrolled(scrollThrottle) + .pipe( + filter( + (scrollable) => + !scrollable || + !this._overlayRef!.overlayElement.contains(scrollable.getElementRef().nativeElement) + ) + ) + .subscribe(() => { + this._overlayRef!.updatePosition(); + + const isOutside = originElement + ? this._isOriginOutsideAncestors(originElement) + : this._isOverlayOutsideViewport(); + + if (isOutside) { + this._ngZone.run(() => this._hideSubject.next()); + } + }); } /** Unsubscribes from scroll events. */ From 17ea994d34bfe30747946c6929de435e528431e4 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 24 Jul 2026 14:28:59 +0300 Subject: [PATCH 04/13] feat: refactored and simplified strategy usage --- packages/components-dev/popover/module.ts | 2 + .../core/overlay/hide-on-scroll.strategy.ts | 26 ++++---- .../components/popover/examples.popover.en.md | 4 ++ .../components/popover/popover.component.ts | 39 ++++++++--- packages/components/popover/popover.module.ts | 10 ++- .../docs-examples/components/popover/index.ts | 3 + .../popover-scroll-strategy-example.ts | 64 +++++++++++++++++++ 7 files changed, 125 insertions(+), 23 deletions(-) create mode 100644 packages/docs-examples/components/popover/popover-scroll-strategy/popover-scroll-strategy-example.ts diff --git a/packages/components-dev/popover/module.ts b/packages/components-dev/popover/module.ts index 3c3c2af741..fb30547d74 100644 --- a/packages/components-dev/popover/module.ts +++ b/packages/components-dev/popover/module.ts @@ -28,6 +28,8 @@ import { DevThemeToggle } from '../theme-toggle'; template: `
+ +

diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/hide-on-scroll.strategy.ts index 6a6f3885c1..c071116329 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.ts @@ -1,6 +1,6 @@ import { OverlayRef, ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; -import { InjectionToken, NgZone } from '@angular/core'; +import { NgZone } from '@angular/core'; import { Observable, Subject, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; @@ -115,11 +115,19 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { } } -export const KBQ_HIDE_ON_SCROLL_STRATEGY = new InjectionToken< - (config: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy ->('kbq-hide-on-scroll-strategy'); - -/** @docs-private */ +/** + * Factory function for `KbqHideOnScrollStrategy`. Use it directly as a `useFactory` value + * when providing a component-level scroll strategy token (e.g. `KBQ_POPOVER_SCROLL_STRATEGY`). + * + * @example + * ```ts + * { + * provide: KBQ_POPOVER_SCROLL_STRATEGY, + * deps: [ScrollDispatcher, ViewportRuler, NgZone], + * useFactory: kbqHideOnScrollStrategyFactory + * } + * ``` + */ export function kbqHideOnScrollStrategyFactory( scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, @@ -127,9 +135,3 @@ export function kbqHideOnScrollStrategyFactory( ): (config: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy { return (config) => new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); } - -export const KBQ_HIDE_ON_SCROLL_STRATEGY_PROVIDER = { - provide: KBQ_HIDE_ON_SCROLL_STRATEGY, - deps: [ScrollDispatcher, ViewportRuler, NgZone], - useFactory: kbqHideOnScrollStrategyFactory -}; diff --git a/packages/components/popover/examples.popover.en.md b/packages/components/popover/examples.popover.en.md index 41852bb441..ac90567c16 100644 --- a/packages/components/popover/examples.popover.en.md +++ b/packages/components/popover/examples.popover.en.md @@ -2,6 +2,10 @@ +#### Custom scroll strategy via DI + + + #### Opening on hover diff --git a/packages/components/popover/popover.component.ts b/packages/components/popover/popover.component.ts index 5b2aebd3bc..98001b0f8d 100644 --- a/packages/components/popover/popover.component.ts +++ b/packages/components/popover/popover.component.ts @@ -3,10 +3,13 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { CdkObserveContent } from '@angular/cdk/observers'; import { FlexibleConnectedPositionStrategy, + Overlay, OverlayConfig, OverlayContainer, + ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; +import { ViewportRuler } from '@angular/cdk/scrolling'; import { NgTemplateOutlet } from '@angular/common'; import { AfterViewInit, @@ -16,7 +19,9 @@ import { Directive, ElementRef, EventEmitter, + InjectionToken, Input, + NgZone, Output, Renderer2, TemplateRef, @@ -32,8 +37,9 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { - KBQ_HIDE_ON_SCROLL_STRATEGY, KbqComponentColors, + KbqHideOnScrollStrategy, + KbqHideOnScrollStrategyConfig, KbqOverflowShadowBottom, KbqOverflowShadowContainer, KbqOverflowShadowTop, @@ -45,7 +51,8 @@ import { POSITION_TO_CSS_MAP, PopUpSizes, PopUpTriggers, - applyPopupMargins + applyPopupMargins, + kbqHideOnScrollStrategyFactory } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { NEVER, merge } from 'rxjs'; @@ -125,6 +132,22 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { protected readonly componentColors = KbqComponentColors; } +export const KBQ_POPOVER_SCROLL_STRATEGY = new InjectionToken< + (config?: KbqHideOnScrollStrategyConfig) => ScrollStrategy +>('kbq-popover-scroll-strategy'); + +/** @docs-private */ +export function kbqPopoverScrollStrategyFactory(overlay: Overlay): () => ScrollStrategy { + return () => overlay.scrollStrategies.reposition({ scrollThrottle: 20 }); +} + +/** @docs-private */ +export const KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER = { + provide: KBQ_POPOVER_SCROLL_STRATEGY, + deps: [ScrollDispatcher, ViewportRuler, NgZone], + useFactory: kbqHideOnScrollStrategyFactory +}; + /** Creates an error to be thrown if the user supplied an invalid popover position. */ export function getKbqPopoverInvalidPositionError(position: string) { return Error(`KbqPopover position "${position}" is invalid.`); @@ -143,18 +166,18 @@ export function getKbqPopoverInvalidPositionError(position: string) { export class KbqPopoverTrigger extends KbqPopUpTrigger { private overlayContainer = inject(OverlayContainer); private renderer = inject(Renderer2); - private kbqHideOnScrollStrategy = inject(KBQ_HIDE_ON_SCROLL_STRATEGY); + private scrollStrategyFactory = inject(KBQ_POPOVER_SCROLL_STRATEGY); protected scrollStrategy = (): ScrollStrategy => { - if (this.closeOnScroll === null && this.hideIfNotInViewPort()) { - const strategy = this.kbqHideOnScrollStrategy({ originElement: this.elementRef.nativeElement }); + const strategy = this.scrollStrategyFactory({ + originElement: this.elementRef.nativeElement + }); + if (this.closeOnScroll === null && this.hideIfNotInViewPort() && strategy instanceof KbqHideOnScrollStrategy) { strategy.hide$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.hide()); - - return strategy; } - return this.overlay.scrollStrategies.reposition({ scrollThrottle: 20 }); + return strategy; }; /** Controls whether the component should be hidden when it is not visible in the viewport. */ diff --git a/packages/components/popover/popover.module.ts b/packages/components/popover/popover.module.ts index 742550b5d9..2348d17ef1 100644 --- a/packages/components/popover/popover.module.ts +++ b/packages/components/popover/popover.module.ts @@ -9,10 +9,14 @@ import { OverlayModule } from '@angular/cdk/overlay'; import { NgTemplateOutlet } from '@angular/common'; import { NgModule } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; -import { EmptyFocusTrapStrategy, KBQ_HIDE_ON_SCROLL_STRATEGY_PROVIDER } from '@koobiq/components/core'; +import { EmptyFocusTrapStrategy } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqPopoverConfirmComponent, KbqPopoverConfirmTrigger } from './popover-confirm.component'; -import { KbqPopoverComponent, KbqPopoverTrigger } from './popover.component'; +import { + KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER, + KbqPopoverComponent, + KbqPopoverTrigger +} from './popover.component'; @NgModule({ imports: [ @@ -28,7 +32,7 @@ import { KbqPopoverComponent, KbqPopoverTrigger } from './popover.component'; KbqPopoverConfirmTrigger ], providers: [ - KBQ_HIDE_ON_SCROLL_STRATEGY_PROVIDER, + KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER, { provide: FocusTrapFactory, useClass: ConfigurableFocusTrapFactory }, { provide: FOCUS_TRAP_INERT_STRATEGY, useClass: EmptyFocusTrapStrategy } ], diff --git a/packages/docs-examples/components/popover/index.ts b/packages/docs-examples/components/popover/index.ts index 359ee4c34e..f2d58bcfe5 100644 --- a/packages/docs-examples/components/popover/index.ts +++ b/packages/docs-examples/components/popover/index.ts @@ -11,6 +11,7 @@ import { PopoverOverviewExample } from './popover-overview/popover-overview-exam import { PopoverPaddingsExample } from './popover-paddings/popover-paddings-example'; import { PopoverPlacementCenterExample } from './popover-placement-center/popover-placement-center-example'; import { PopoverPlacementEdgesExample } from './popover-placement-edges/popover-placement-edges-example'; +import { PopoverScrollStrategyExample } from './popover-scroll-strategy/popover-scroll-strategy-example'; import { PopoverScrollExample } from './popover-scroll/popover-scroll-example'; import { PopoverScrollingAndLayeringExample } from './popover-scrolling-and-layering/popover-scrolling-and-layering-example'; import { PopoverSmallExample } from './popover-small/popover-small-example'; @@ -31,6 +32,7 @@ export { PopoverPlacementEdgesExample, PopoverScrollExample, PopoverScrollingAndLayeringExample, + PopoverScrollStrategyExample, PopoverSmallExample, PopoverWidthExample }; @@ -46,6 +48,7 @@ const EXAMPLES = [ PopoverContentExample, PopoverScrollExample, PopoverHideOnScrollExample, + PopoverScrollStrategyExample, PopoverPlacementCenterExample, PopoverPlacementEdgesExample, PopoverHoverExample, diff --git a/packages/docs-examples/components/popover/popover-scroll-strategy/popover-scroll-strategy-example.ts b/packages/docs-examples/components/popover/popover-scroll-strategy/popover-scroll-strategy-example.ts new file mode 100644 index 0000000000..bbb655c2df --- /dev/null +++ b/packages/docs-examples/components/popover/popover-scroll-strategy/popover-scroll-strategy-example.ts @@ -0,0 +1,64 @@ +import { Overlay } from '@angular/cdk/overlay'; +import { CdkScrollableModule } from '@angular/cdk/scrolling'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KBQ_POPOVER_SCROLL_STRATEGY, KbqPopoverModule } from '@koobiq/components/popover'; + +/** + * @title popover-scroll-strategy + */ +@Component({ + selector: 'popover-scroll-strategy-example', + imports: [CdkScrollableModule, KbqButtonModule, KbqPopoverModule], + template: ` +
+
Scroll down
+ +
+ +
+ +
Scroll up
+
+ `, + styles: ` + .example-popover-scroll-strategy__container { + height: 200px; + overflow-y: auto; + border: 1px solid var(--kbq-line-contrast-less); + border-radius: 4px; + } + + .example-popover-scroll-strategy__spacer { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--kbq-foreground-contrast-secondary); + } + + .example-popover-scroll-strategy__triggers { + display: flex; + gap: 16px; + padding: 16px; + } + `, + providers: [ + { + provide: KBQ_POPOVER_SCROLL_STRATEGY, + useFactory: () => { + const overlay = inject(Overlay); + + return () => overlay.scrollStrategies.reposition({ scrollThrottle: 20 }); + } + } + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PopoverScrollStrategyExample {} From 2e962ca54ae03d71dbf9abb71d67a671e53f025d Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 24 Jul 2026 14:52:08 +0300 Subject: [PATCH 05/13] feat: moved hiding functionality to the base pop-up-trigger.ts class --- .../components/core/pop-up/pop-up-trigger.ts | 12 ++++++++++- .../components/popover/popover.component.ts | 21 +++++-------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/components/core/pop-up/pop-up-trigger.ts b/packages/components/core/pop-up/pop-up-trigger.ts index 6de7773571..a8410a4795 100644 --- a/packages/components/core/pop-up/pop-up-trigger.ts +++ b/packages/components/core/pop-up/pop-up-trigger.ts @@ -32,6 +32,7 @@ import { BehaviorSubject, interval, Observable, Subscription } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { distinctUntilChanged, filter, delay as rxDelay } from 'rxjs/operators'; import { ENTER, ESCAPE, SPACE } from '../keycodes'; +import { KbqHideOnScrollStrategy } from '../overlay/hide-on-scroll.strategy'; import { EXTENDED_OVERLAY_POSITIONS, POSITION_MAP, @@ -141,6 +142,9 @@ export abstract class KbqPopUpTrigger implements OnInit, OnDestroy { * @docs-private */ protected abstract scrollStrategy: () => ScrollStrategy; + /** Whether to hide the pop-up when it scrolls out of its scroll container boundary. */ + shouldHideOnScrollOut: boolean = false; + /** Optional element used to anchor and measure the pop-up instead of the host element. * @docs-private */ protected externalNativeElement: HTMLElement; @@ -443,11 +447,17 @@ export abstract class KbqPopUpTrigger implements OnInit, OnDestroy { this.strategy.positionChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(this.onPositionChange); + const scrollStrategy = this.scrollStrategy(); + + if (scrollStrategy instanceof KbqHideOnScrollStrategy && this.shouldHideOnScrollOut) { + scrollStrategy.hide$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.hide()); + } + this.overlayRef = this.overlay.create({ ...this.overlayConfig, direction: this.direction || undefined, positionStrategy: this.strategy, - scrollStrategy: this.scrollStrategy() + scrollStrategy }); this.subscribeOnClosingActions(); diff --git a/packages/components/popover/popover.component.ts b/packages/components/popover/popover.component.ts index 98001b0f8d..6c7ab1155c 100644 --- a/packages/components/popover/popover.component.ts +++ b/packages/components/popover/popover.component.ts @@ -30,7 +30,6 @@ import { ViewEncapsulation, booleanAttribute, inject, - input, numberAttribute, viewChild } from '@angular/core'; @@ -38,7 +37,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqComponentColors, - KbqHideOnScrollStrategy, KbqHideOnScrollStrategyConfig, KbqOverflowShadowBottom, KbqOverflowShadowContainer, @@ -166,22 +164,13 @@ export function getKbqPopoverInvalidPositionError(position: string) { export class KbqPopoverTrigger extends KbqPopUpTrigger { private overlayContainer = inject(OverlayContainer); private renderer = inject(Renderer2); - private scrollStrategyFactory = inject(KBQ_POPOVER_SCROLL_STRATEGY); - protected scrollStrategy = (): ScrollStrategy => { - const strategy = this.scrollStrategyFactory({ - originElement: this.elementRef.nativeElement - }); - - if (this.closeOnScroll === null && this.hideIfNotInViewPort() && strategy instanceof KbqHideOnScrollStrategy) { - strategy.hide$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.hide()); - } - - return strategy; - }; + protected scrollStrategy = (): ScrollStrategy => + inject(KBQ_POPOVER_SCROLL_STRATEGY)({ originElement: this.elementRef.nativeElement }); - /** Controls whether the component should be hidden when it is not visible in the viewport. */ - readonly hideIfNotInViewPort = input(true, { transform: booleanAttribute }); + /** Controls whether the component should be hidden when it scrolls out of its container boundary. */ + @Input({ alias: 'hideIfNotInViewPort', transform: booleanAttribute }) + override shouldHideOnScrollOut: boolean = true; /** prevents closure by any event */ // TODO: Skipped for migration because: From 12e1056295df6699d2320b6332d3a831d2e02b1d Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 24 Jul 2026 16:20:26 +0300 Subject: [PATCH 06/13] feat: moved originElement assignment inside strategy, simplified popover --- .../core/overlay/hide-on-scroll.strategy.ts | 42 +++++++++++++++---- .../components/popover/popover.component.ts | 8 +--- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/hide-on-scroll.strategy.ts index c071116329..5930574379 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.ts @@ -1,6 +1,11 @@ -import { OverlayRef, ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; +import { + FlexibleConnectedPositionStrategyOrigin, + OverlayRef, + ScrollDispatcher, + ScrollStrategy +} from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; -import { NgZone } from '@angular/core'; +import { ElementRef, NgZone } from '@angular/core'; import { Observable, Subject, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; @@ -31,24 +36,33 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { private _overlayRef: OverlayRef | null = null; private _scrollSubscription: Subscription | null = null; + private _originElement: HTMLElement | null = null; constructor( private readonly _scrollDispatcher: ScrollDispatcher, private readonly _viewportRuler: ViewportRuler, private readonly _ngZone: NgZone, - private readonly _config: KbqHideOnScrollStrategyConfig - ) {} + private readonly _config: KbqHideOnScrollStrategyConfig = {} + ) { + this._originElement = _config.originElement ?? null; + } /** @docs-private */ attach(overlayRef: OverlayRef): void { this._overlayRef = overlayRef; + + if (!this._originElement) { + // FlexibleConnectedPositionStrategy stores the origin as a private field. + // Reading it here avoids requiring callers to pass originElement explicitly. + this._originElement = this.coerceOriginElement((overlayRef.getConfig().positionStrategy as any)?._origin); + } } /** Subscribes to scroll events and starts repositioning / out-of-bounds detection. */ enable(): void { if (this._scrollSubscription) return; - const { originElement, scrollThrottle = 20 } = this._config; + const { scrollThrottle = 20 } = this._config; this._scrollSubscription = this._scrollDispatcher .scrolled(scrollThrottle) @@ -62,8 +76,8 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { .subscribe(() => { this._overlayRef!.updatePosition(); - const isOutside = originElement - ? this._isOriginOutsideAncestors(originElement) + const isOutside = this._originElement + ? this._isOriginOutsideAncestors(this._originElement) : this._isOverlayOutsideViewport(); if (isOutside) { @@ -113,6 +127,16 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { rect.left > containerRect.right ); } + + private coerceOriginElement(raw?: FlexibleConnectedPositionStrategyOrigin) { + if (raw instanceof HTMLElement) { + return raw; + } else if (raw instanceof ElementRef && raw?.nativeElement instanceof HTMLElement) { + return raw.nativeElement; + } + + return null; + } } /** @@ -132,6 +156,6 @@ export function kbqHideOnScrollStrategyFactory( scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone -): (config: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy { - return (config) => new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); +): (config?: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy { + return (config = {}) => new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); } diff --git a/packages/components/popover/popover.component.ts b/packages/components/popover/popover.component.ts index 6c7ab1155c..3b954e3918 100644 --- a/packages/components/popover/popover.component.ts +++ b/packages/components/popover/popover.component.ts @@ -37,7 +37,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqComponentColors, - KbqHideOnScrollStrategyConfig, KbqOverflowShadowBottom, KbqOverflowShadowContainer, KbqOverflowShadowTop, @@ -130,9 +129,7 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { protected readonly componentColors = KbqComponentColors; } -export const KBQ_POPOVER_SCROLL_STRATEGY = new InjectionToken< - (config?: KbqHideOnScrollStrategyConfig) => ScrollStrategy ->('kbq-popover-scroll-strategy'); +export const KBQ_POPOVER_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('kbq-popover-scroll-strategy'); /** @docs-private */ export function kbqPopoverScrollStrategyFactory(overlay: Overlay): () => ScrollStrategy { @@ -165,8 +162,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger { private overlayContainer = inject(OverlayContainer); private renderer = inject(Renderer2); - protected scrollStrategy = (): ScrollStrategy => - inject(KBQ_POPOVER_SCROLL_STRATEGY)({ originElement: this.elementRef.nativeElement }); + protected scrollStrategy: () => ScrollStrategy = inject(KBQ_POPOVER_SCROLL_STRATEGY); /** Controls whether the component should be hidden when it scrolls out of its container boundary. */ @Input({ alias: 'hideIfNotInViewPort', transform: booleanAttribute }) From be91c60a71b01efa29ca343582ccd41004ae1a2c Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 24 Jul 2026 16:49:51 +0300 Subject: [PATCH 07/13] feat: reverted popover and pop up changes, added tests --- packages/components-dev/popover/module.ts | 4 - .../overlay/hide-on-scroll.strategy.spec.ts | 440 ++++++++++++++++++ .../core/overlay/hide-on-scroll.strategy.ts | 23 +- .../components/core/pop-up/pop-up-trigger.ts | 12 +- .../components/popover/examples.popover.en.md | 8 - .../components/popover/popover.component.ts | 61 ++- .../docs-examples/components/popover/index.ts | 6 - .../popover-hide-on-scroll-example.ts | 58 --- .../popover-scroll-strategy-example.ts | 64 --- 9 files changed, 513 insertions(+), 163 deletions(-) create mode 100644 packages/components/core/overlay/hide-on-scroll.strategy.spec.ts delete mode 100644 packages/docs-examples/components/popover/popover-hide-on-scroll/popover-hide-on-scroll-example.ts delete mode 100644 packages/docs-examples/components/popover/popover-scroll-strategy/popover-scroll-strategy-example.ts diff --git a/packages/components-dev/popover/module.ts b/packages/components-dev/popover/module.ts index fb30547d74..e1edb2c407 100644 --- a/packages/components-dev/popover/module.ts +++ b/packages/components-dev/popover/module.ts @@ -26,10 +26,6 @@ import { DevThemeToggle } from '../theme-toggle'; selector: 'dev-examples', imports: [PopoverExamplesModule], template: ` - -
- -

diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts b/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts new file mode 100644 index 0000000000..75c8890cb3 --- /dev/null +++ b/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts @@ -0,0 +1,440 @@ +import { CdkScrollable, ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; +import { ViewportRuler } from '@angular/cdk/scrolling'; +import { DestroyRef, ElementRef, NgZone } from '@angular/core'; +import { Subject } from 'rxjs'; +import { KbqHideOnScrollStrategy, wireHideOnScroll } from './hide-on-scroll.strategy'; + +function makeRect(top: number, left: number, bottom: number, right: number): DOMRect { + return { + top, + left, + bottom, + right, + width: right - left, + height: bottom - top, + x: left, + y: top, + toJSON: () => ({}) + } as DOMRect; +} + +function makeScrollDispatcher(scrollSubject: Subject, containers: CdkScrollable[] = []) { + return { + scrolled: jest.fn(() => scrollSubject.asObservable()), + getAncestorScrollContainers: jest.fn(() => containers) + } as unknown as ScrollDispatcher; +} + +function makeOverlayRef(overlayElement: HTMLElement, positionOrigin?: unknown) { + const positionStrategy = positionOrigin !== undefined ? { _origin: positionOrigin } : {}; + + return { + overlayElement, + updatePosition: jest.fn(), + getConfig: jest.fn(() => ({ positionStrategy })) + } as any; +} + +function makeViewportRuler(width = 1000, height = 800) { + return { getViewportSize: jest.fn(() => ({ width, height })) } as unknown as ViewportRuler; +} + +function makeNgZone() { + return { run: jest.fn((fn: () => void) => fn()) } as unknown as NgZone; +} + +function makeScrollable(el: HTMLElement) { + return { getElementRef: () => new ElementRef(el) } as CdkScrollable; +} + +function buildStrategy( + config: { originElement?: HTMLElement; scrollThrottle?: number } = {}, + deps: { + scrollDispatcher?: ScrollDispatcher; + viewportRuler?: ViewportRuler; + ngZone?: NgZone; + } = {} +) { + const scroll$ = new Subject(); + const scrollDispatcher = deps.scrollDispatcher ?? makeScrollDispatcher(scroll$); + const viewportRuler = deps.viewportRuler ?? makeViewportRuler(); + const ngZone = deps.ngZone ?? makeNgZone(); + const strategy = new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); + + return { strategy, scroll$, scrollDispatcher, viewportRuler, ngZone }; +} + +describe('KbqHideOnScrollStrategy', () => { + describe('lifecycle', () => { + it('attach() stores the overlayRef', () => { + const { strategy } = buildStrategy(); + const overlayEl = document.createElement('div'); + const overlayRef = makeOverlayRef(overlayEl); + + strategy.attach(overlayRef); + strategy.enable(); + + expect(overlayRef.updatePosition).toBeDefined(); + }); + + it('attach() does not throw when position strategy has no _origin', () => { + const { strategy } = buildStrategy(); + const overlayEl = document.createElement('div'); + const overlayRef = makeOverlayRef(overlayEl); + + expect(() => strategy.attach(overlayRef)).not.toThrow(); + }); + + it('enable() subscribes to scrolled(); second call is a no-op', () => { + const { strategy, scrollDispatcher } = buildStrategy(); + const overlayEl = document.createElement('div'); + + strategy.attach(makeOverlayRef(overlayEl)); + strategy.enable(); + strategy.enable(); + + expect(scrollDispatcher.scrolled).toHaveBeenCalledTimes(1); + }); + + it('disable() unsubscribes; calling again is a no-op', () => { + const { strategy, scroll$ } = buildStrategy(); + const overlayEl = document.createElement('div'); + + strategy.attach(makeOverlayRef(overlayEl)); + strategy.enable(); + strategy.disable(); + + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + + expect(count).toBe(0); + }); + + it('detach() unsubscribes and completes hide$', () => { + const { strategy } = buildStrategy(); + const overlayEl = document.createElement('div'); + + strategy.attach(makeOverlayRef(overlayEl)); + strategy.enable(); + + let completed = false; + + strategy.hide$.subscribe({ complete: () => (completed = true) }); + + strategy.detach(); + expect(completed).toBe(true); + }); + }); + + describe('auto-origin derivation in attach()', () => { + it('uses _origin when it is an HTMLElement', () => { + const originEl = document.createElement('div'); + const container = document.createElement('div'); + + container.getBoundingClientRect = () => makeRect(0, 0, 500, 500); + originEl.getBoundingClientRect = () => makeRect(-100, 0, -50, 100); // above container + + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$, [makeScrollable(container)]); + const { strategy } = buildStrategy({}, { scrollDispatcher }); + + strategy.attach(makeOverlayRef(document.createElement('div'), originEl)); + strategy.enable(); + + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + + expect(count).toBe(1); + }); + + it('uses _origin.nativeElement when _origin is an ElementRef', () => { + const originEl = document.createElement('div'); + const container = document.createElement('div'); + + container.getBoundingClientRect = () => makeRect(0, 0, 500, 500); + originEl.getBoundingClientRect = () => makeRect(-100, 0, -50, 100); + + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$, [makeScrollable(container)]); + const { strategy } = buildStrategy({}, { scrollDispatcher }); + + strategy.attach(makeOverlayRef(document.createElement('div'), new ElementRef(originEl))); + strategy.enable(); + + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + + expect(count).toBe(1); + }); + + it('falls back to viewport check when _origin is a point object', () => { + const overlayEl = document.createElement('div'); + + overlayEl.getBoundingClientRect = () => makeRect(0, 0, 100, 100); + + const viewportRuler = makeViewportRuler(1000, 800); + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$); + const { strategy } = buildStrategy({}, { scrollDispatcher, viewportRuler }); + + strategy.attach(makeOverlayRef(overlayEl, { x: 100, y: 100 })); + strategy.enable(); + + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + + // overlay is inside viewport → no emit + expect(count).toBe(0); + }); + + it('does NOT overwrite config.originElement set at construction time', () => { + const configOrigin = document.createElement('div'); + const attachOrigin = document.createElement('div'); + const container = document.createElement('div'); + + container.getBoundingClientRect = () => makeRect(0, 0, 500, 500); + configOrigin.getBoundingClientRect = () => makeRect(-100, 0, -50, 100); // outside + attachOrigin.getBoundingClientRect = () => makeRect(10, 10, 100, 200); // inside + + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$, [makeScrollable(container)]); + const { strategy } = buildStrategy({ originElement: configOrigin }, { scrollDispatcher }); + + strategy.attach(makeOverlayRef(document.createElement('div'), attachOrigin)); + strategy.enable(); + + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + + // configOrigin is outside → should still emit + expect(count).toBe(1); + }); + }); + + describe('hide$ with origin', () => { + function buildWithOrigin(originRect: DOMRect, containerRect: DOMRect) { + const originEl = document.createElement('div'); + const container = document.createElement('div'); + + originEl.getBoundingClientRect = () => originRect; + container.getBoundingClientRect = () => containerRect; + + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$, [makeScrollable(container)]); + const { strategy } = buildStrategy({ originElement: originEl }, { scrollDispatcher }); + const overlayEl = document.createElement('div'); + + strategy.attach(makeOverlayRef(overlayEl)); + strategy.enable(); + + return { strategy, scroll$ }; + } + + const containerRect = makeRect(0, 0, 500, 500); + + it('emits when origin scrolls above container', () => { + const { strategy, scroll$ } = buildWithOrigin(makeRect(-100, 0, -10, 100), containerRect); + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + expect(count).toBe(1); + }); + + it('emits when origin scrolls below container', () => { + const { strategy, scroll$ } = buildWithOrigin(makeRect(510, 0, 600, 100), containerRect); + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + expect(count).toBe(1); + }); + + it('emits when origin scrolls left of container', () => { + const { strategy, scroll$ } = buildWithOrigin(makeRect(10, -200, 50, -10), containerRect); + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + expect(count).toBe(1); + }); + + it('emits when origin scrolls right of container', () => { + const { strategy, scroll$ } = buildWithOrigin(makeRect(10, 510, 50, 600), containerRect); + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + expect(count).toBe(1); + }); + + it('does NOT emit when origin is within the container', () => { + const { strategy, scroll$ } = buildWithOrigin(makeRect(10, 10, 50, 100), containerRect); + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + expect(count).toBe(0); + }); + + it('calls updatePosition() on every scroll', () => { + const originEl = document.createElement('div'); + const container = document.createElement('div'); + + originEl.getBoundingClientRect = () => makeRect(10, 10, 50, 100); + container.getBoundingClientRect = () => makeRect(0, 0, 500, 500); + + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$, [makeScrollable(container)]); + const { strategy } = buildStrategy({ originElement: originEl }, { scrollDispatcher }); + const overlayEl = document.createElement('div'); + const overlayRef = makeOverlayRef(overlayEl); + + strategy.attach(overlayRef); + strategy.enable(); + scroll$.next(); + + expect(overlayRef.updatePosition).toHaveBeenCalledTimes(1); + }); + }); + + describe('hide$ without origin (viewport fallback)', () => { + function buildViewportCase(overlayRect: DOMRect, viewportWidth = 1000, viewportHeight = 800) { + const overlayEl = document.createElement('div'); + + overlayEl.getBoundingClientRect = () => overlayRect; + + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$); + const viewportRuler = makeViewportRuler(viewportWidth, viewportHeight); + const { strategy } = buildStrategy({}, { scrollDispatcher, viewportRuler }); + + strategy.attach(makeOverlayRef(overlayEl)); + strategy.enable(); + + return { strategy, scroll$ }; + } + + it('emits when overlay panel is outside viewport', () => { + const { strategy, scroll$ } = buildViewportCase(makeRect(-200, 0, -100, 100)); + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + expect(count).toBe(1); + }); + + it('does NOT emit when overlay panel is inside viewport', () => { + const { strategy, scroll$ } = buildViewportCase(makeRect(10, 10, 100, 200)); + let count = 0; + + strategy.hide$.subscribe(() => count++); + scroll$.next(); + expect(count).toBe(0); + }); + }); + + describe('scroll from inside overlay panel', () => { + it('is filtered out — no reposition and no hide check', () => { + const originEl = document.createElement('div'); + const container = document.createElement('div'); + const overlayEl = document.createElement('div'); + const innerScrollable = document.createElement('div'); + + overlayEl.appendChild(innerScrollable); + originEl.getBoundingClientRect = () => makeRect(-100, 0, -50, 100); + container.getBoundingClientRect = () => makeRect(0, 0, 500, 500); + + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$, [makeScrollable(container)]); + const { strategy } = buildStrategy({ originElement: originEl }, { scrollDispatcher }); + const overlayRef = makeOverlayRef(overlayEl); + + strategy.attach(overlayRef); + strategy.enable(); + + let count = 0; + + strategy.hide$.subscribe(() => count++); + + // emit a scroll event that comes from inside the overlay panel + scroll$.next(makeScrollable(innerScrollable)); + + expect(overlayRef.updatePosition).not.toHaveBeenCalled(); + expect(count).toBe(0); + }); + }); +}); + +describe('wireHideOnScroll', () => { + function makeDestroyRef(): DestroyRef { + const callbacks: (() => void)[] = []; + + return { + onDestroy: (cb: () => void) => { + callbacks.push(cb); + + return () => {}; + }, + _destroy: () => callbacks.forEach((cb) => cb()) + } as any; + } + + it('subscribes to hide$ when strategy is KbqHideOnScrollStrategy', () => { + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$); + const strategy = new KbqHideOnScrollStrategy(scrollDispatcher, makeViewportRuler(), makeNgZone(), {}); + const overlayEl = document.createElement('div'); + + overlayEl.getBoundingClientRect = () => makeRect(-200, 0, -100, 100); + strategy.attach(makeOverlayRef(overlayEl)); + strategy.enable(); + + const destroyRef = makeDestroyRef(); + const onHide = jest.fn(); + + wireHideOnScroll(strategy, destroyRef, onHide); + scroll$.next(); + + expect(onHide).toHaveBeenCalledTimes(1); + }); + + it('does nothing when strategy is not KbqHideOnScrollStrategy', () => { + const noop: ScrollStrategy = { attach: jest.fn(), enable: jest.fn(), disable: jest.fn(), detach: jest.fn() }; + const destroyRef = makeDestroyRef(); + const onHide = jest.fn(); + + expect(() => wireHideOnScroll(noop, destroyRef, onHide)).not.toThrow(); + expect(onHide).not.toHaveBeenCalled(); + }); + + it('unsubscribes when destroyRef fires', () => { + const scroll$ = new Subject(); + const scrollDispatcher = makeScrollDispatcher(scroll$); + const strategy = new KbqHideOnScrollStrategy(scrollDispatcher, makeViewportRuler(), makeNgZone(), {}); + const overlayEl = document.createElement('div'); + + overlayEl.getBoundingClientRect = () => makeRect(-200, 0, -100, 100); + strategy.attach(makeOverlayRef(overlayEl)); + strategy.enable(); + + const destroyRef = makeDestroyRef() as any; + const onHide = jest.fn(); + + wireHideOnScroll(strategy, destroyRef, onHide); + destroyRef._destroy(); + scroll$.next(); + + expect(onHide).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/hide-on-scroll.strategy.ts index 5930574379..f46896ef7b 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.ts @@ -5,10 +5,31 @@ import { ScrollStrategy } from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; -import { ElementRef, NgZone } from '@angular/core'; +import { DestroyRef, ElementRef, InputSignal, NgZone } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Observable, Subject, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; +/** + * Implemented by overlay-opening components that support hide-on-scroll-out. + * Declare `readonly shouldHideOnScrollOut = input(false, { transform: booleanAttribute })` and + * call `wireHideOnScroll` after the overlay is created to activate the behavior. + */ +export interface KbqHideOnScrollOverlay { + /** Whether the overlay closes when its trigger scrolls out of its scroll container boundary. */ + readonly shouldHideOnScrollOut: InputSignal; +} + +/** + * Subscribes to `strategy.hide$` when the strategy is a `KbqHideOnScrollStrategy`. + * Call this after creating the overlay in any component that implements `KbqHideOnScrollOverlay`. + */ +export function wireHideOnScroll(strategy: ScrollStrategy, destroyRef: DestroyRef, onHide: () => void): void { + if (strategy instanceof KbqHideOnScrollStrategy) { + strategy.hide$.pipe(takeUntilDestroyed(destroyRef)).subscribe(onHide); + } +} + export interface KbqHideOnScrollStrategyConfig { /** * Element whose position is tracked against ancestor scroll container boundaries. diff --git a/packages/components/core/pop-up/pop-up-trigger.ts b/packages/components/core/pop-up/pop-up-trigger.ts index a8410a4795..6de7773571 100644 --- a/packages/components/core/pop-up/pop-up-trigger.ts +++ b/packages/components/core/pop-up/pop-up-trigger.ts @@ -32,7 +32,6 @@ import { BehaviorSubject, interval, Observable, Subscription } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { distinctUntilChanged, filter, delay as rxDelay } from 'rxjs/operators'; import { ENTER, ESCAPE, SPACE } from '../keycodes'; -import { KbqHideOnScrollStrategy } from '../overlay/hide-on-scroll.strategy'; import { EXTENDED_OVERLAY_POSITIONS, POSITION_MAP, @@ -142,9 +141,6 @@ export abstract class KbqPopUpTrigger implements OnInit, OnDestroy { * @docs-private */ protected abstract scrollStrategy: () => ScrollStrategy; - /** Whether to hide the pop-up when it scrolls out of its scroll container boundary. */ - shouldHideOnScrollOut: boolean = false; - /** Optional element used to anchor and measure the pop-up instead of the host element. * @docs-private */ protected externalNativeElement: HTMLElement; @@ -447,17 +443,11 @@ export abstract class KbqPopUpTrigger implements OnInit, OnDestroy { this.strategy.positionChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(this.onPositionChange); - const scrollStrategy = this.scrollStrategy(); - - if (scrollStrategy instanceof KbqHideOnScrollStrategy && this.shouldHideOnScrollOut) { - scrollStrategy.hide$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.hide()); - } - this.overlayRef = this.overlay.create({ ...this.overlayConfig, direction: this.direction || undefined, positionStrategy: this.strategy, - scrollStrategy + scrollStrategy: this.scrollStrategy() }); this.subscribeOnClosingActions(); diff --git a/packages/components/popover/examples.popover.en.md b/packages/components/popover/examples.popover.en.md index ac90567c16..d9a3068f70 100644 --- a/packages/components/popover/examples.popover.en.md +++ b/packages/components/popover/examples.popover.en.md @@ -1,11 +1,3 @@ -#### Hide on scroll out of bounds - - - -#### Custom scroll strategy via DI - - - #### Opening on hover diff --git a/packages/components/popover/popover.component.ts b/packages/components/popover/popover.component.ts index 3b954e3918..d0b22d1f72 100644 --- a/packages/components/popover/popover.component.ts +++ b/packages/components/popover/popover.component.ts @@ -2,16 +2,16 @@ import { CdkTrapFocus } from '@angular/cdk/a11y'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { CdkObserveContent } from '@angular/cdk/observers'; import { + CdkScrollable, FlexibleConnectedPositionStrategy, Overlay, OverlayConfig, OverlayContainer, - ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; -import { ViewportRuler } from '@angular/cdk/scrolling'; import { NgTemplateOutlet } from '@angular/common'; import { + AfterContentInit, AfterViewInit, ChangeDetectionStrategy, Component, @@ -21,7 +21,7 @@ import { EventEmitter, InjectionToken, Input, - NgZone, + OnInit, Output, Renderer2, TemplateRef, @@ -30,6 +30,7 @@ import { ViewEncapsulation, booleanAttribute, inject, + input, numberAttribute, viewChild } from '@angular/core'; @@ -48,8 +49,7 @@ import { POSITION_TO_CSS_MAP, PopUpSizes, PopUpTriggers, - applyPopupMargins, - kbqHideOnScrollStrategyFactory + applyPopupMargins } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { NEVER, merge } from 'rxjs'; @@ -139,8 +139,8 @@ export function kbqPopoverScrollStrategyFactory(overlay: Overlay): () => ScrollS /** @docs-private */ export const KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER = { provide: KBQ_POPOVER_SCROLL_STRATEGY, - deps: [ScrollDispatcher, ViewportRuler, NgZone], - useFactory: kbqHideOnScrollStrategyFactory + deps: [Overlay], + useFactory: kbqPopoverScrollStrategyFactory }; /** Creates an error to be thrown if the user supplied an invalid popover position. */ @@ -158,15 +158,14 @@ export function getKbqPopoverInvalidPositionError(position: string) { }, exportAs: 'kbqPopover' }) -export class KbqPopoverTrigger extends KbqPopUpTrigger { +export class KbqPopoverTrigger extends KbqPopUpTrigger implements AfterContentInit, OnInit { private overlayContainer = inject(OverlayContainer); private renderer = inject(Renderer2); protected scrollStrategy: () => ScrollStrategy = inject(KBQ_POPOVER_SCROLL_STRATEGY); - /** Controls whether the component should be hidden when it scrolls out of its container boundary. */ - @Input({ alias: 'hideIfNotInViewPort', transform: booleanAttribute }) - override shouldHideOnScrollOut: boolean = true; + /** Controls whether the component should be hidden when it is not visible in the viewport. */ + readonly hideIfNotInViewPort = input(true, { transform: booleanAttribute }); /** prevents closure by any event */ // TODO: Skipped for migration because: @@ -442,6 +441,30 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger { private classAddedToOverlayContainer: boolean = false; + ngOnInit(): void { + super.ngOnInit(); + + this.scrollable + ?.elementScrolled() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(this.hideIfScrolledOutOfView); + } + + ngAfterContentInit(): void { + if (this.closeOnScroll === null) { + this.scrollDispatcher.scrolled().subscribe((scrollable: CdkScrollable | void) => { + if (!scrollable?.getElementRef().nativeElement.classList.contains('kbq-hide-nested-popup')) return; + + const parentRects = scrollable.getElementRef().nativeElement.getBoundingClientRect(); + const childRects = this.elementRef.nativeElement.getBoundingClientRect(); + + if (childRects.bottom < parentRects.top || childRects.top > parentRects.bottom) { + this.hide(); + } + }); + } + } + /** * Overrides the base `show` method to display the overlay component with the * specified entry delay and apply default positioning offsets. @@ -522,6 +545,22 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger { return merge(...this.closingActionsForClick(), this.closeOnScroll ? this.scrollDispatcher.scrolled() : NEVER); } + private hideIfScrolledOutOfView = () => { + if (!this.scrollable || !this.hideIfNotInViewPort()) return; + + const rect = this.elementRef.nativeElement.getBoundingClientRect(); + const containerRect = this.scrollable.getElementRef().nativeElement.getBoundingClientRect(); + + if (!( + rect.bottom >= containerRect.top && + rect.right >= containerRect.left && + rect.top <= containerRect.bottom && + rect.left <= containerRect.right + )) { + this.hide(); + } + }; + private addClassToOverlayContainer() { const overlayContainer = this.overlayContainer?.getContainerElement(); diff --git a/packages/docs-examples/components/popover/index.ts b/packages/docs-examples/components/popover/index.ts index f2d58bcfe5..c968e8ee5b 100644 --- a/packages/docs-examples/components/popover/index.ts +++ b/packages/docs-examples/components/popover/index.ts @@ -5,13 +5,11 @@ import { PopoverCloseExample } from './popover-close/popover-close-example'; import { PopoverContentExample } from './popover-content/popover-content-example'; import { PopoverHeaderExample } from './popover-header/popover-header-example'; import { PopoverHeightExample } from './popover-height/popover-height-example'; -import { PopoverHideOnScrollExample } from './popover-hide-on-scroll/popover-hide-on-scroll-example'; import { PopoverHoverExample } from './popover-hover/popover-hover-example'; import { PopoverOverviewExample } from './popover-overview/popover-overview-example'; import { PopoverPaddingsExample } from './popover-paddings/popover-paddings-example'; import { PopoverPlacementCenterExample } from './popover-placement-center/popover-placement-center-example'; import { PopoverPlacementEdgesExample } from './popover-placement-edges/popover-placement-edges-example'; -import { PopoverScrollStrategyExample } from './popover-scroll-strategy/popover-scroll-strategy-example'; import { PopoverScrollExample } from './popover-scroll/popover-scroll-example'; import { PopoverScrollingAndLayeringExample } from './popover-scrolling-and-layering/popover-scrolling-and-layering-example'; import { PopoverSmallExample } from './popover-small/popover-small-example'; @@ -24,7 +22,6 @@ export { PopoverContentExample, PopoverHeaderExample, PopoverHeightExample, - PopoverHideOnScrollExample, PopoverHoverExample, PopoverOverviewExample, PopoverPaddingsExample, @@ -32,7 +29,6 @@ export { PopoverPlacementEdgesExample, PopoverScrollExample, PopoverScrollingAndLayeringExample, - PopoverScrollStrategyExample, PopoverSmallExample, PopoverWidthExample }; @@ -47,8 +43,6 @@ const EXAMPLES = [ PopoverHeaderExample, PopoverContentExample, PopoverScrollExample, - PopoverHideOnScrollExample, - PopoverScrollStrategyExample, PopoverPlacementCenterExample, PopoverPlacementEdgesExample, PopoverHoverExample, diff --git a/packages/docs-examples/components/popover/popover-hide-on-scroll/popover-hide-on-scroll-example.ts b/packages/docs-examples/components/popover/popover-hide-on-scroll/popover-hide-on-scroll-example.ts deleted file mode 100644 index f3fdfa6355..0000000000 --- a/packages/docs-examples/components/popover/popover-hide-on-scroll/popover-hide-on-scroll-example.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { CdkScrollableModule } from '@angular/cdk/scrolling'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { KbqButtonModule } from '@koobiq/components/button'; -import { KbqPopoverModule } from '@koobiq/components/popover'; - -/** - * @title popover-hide-on-scroll - */ -@Component({ - selector: 'popover-hide-on-scroll-example', - imports: [CdkScrollableModule, KbqButtonModule, KbqPopoverModule], - template: ` -
-
Scroll down
- -
- - - -
- -
Scroll up
-
- `, - styles: ` - .popover-hide-on-scroll-example__container { - height: 200px; - overflow-y: auto; - border: 1px solid var(--kbq-line-contrast-less); - border-radius: 4px; - } - - .popover-hide-on-scroll-example__spacer { - display: flex; - align-items: center; - justify-content: center; - height: 200px; - color: var(--kbq-foreground-contrast-secondary); - } - - .popover-hide-on-scroll-example__triggers { - display: flex; - gap: 16px; - padding: 16px; - } - `, - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class PopoverHideOnScrollExample {} diff --git a/packages/docs-examples/components/popover/popover-scroll-strategy/popover-scroll-strategy-example.ts b/packages/docs-examples/components/popover/popover-scroll-strategy/popover-scroll-strategy-example.ts deleted file mode 100644 index bbb655c2df..0000000000 --- a/packages/docs-examples/components/popover/popover-scroll-strategy/popover-scroll-strategy-example.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Overlay } from '@angular/cdk/overlay'; -import { CdkScrollableModule } from '@angular/cdk/scrolling'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { KbqButtonModule } from '@koobiq/components/button'; -import { KBQ_POPOVER_SCROLL_STRATEGY, KbqPopoverModule } from '@koobiq/components/popover'; - -/** - * @title popover-scroll-strategy - */ -@Component({ - selector: 'popover-scroll-strategy-example', - imports: [CdkScrollableModule, KbqButtonModule, KbqPopoverModule], - template: ` -
-
Scroll down
- -
- -
- -
Scroll up
-
- `, - styles: ` - .example-popover-scroll-strategy__container { - height: 200px; - overflow-y: auto; - border: 1px solid var(--kbq-line-contrast-less); - border-radius: 4px; - } - - .example-popover-scroll-strategy__spacer { - display: flex; - align-items: center; - justify-content: center; - height: 200px; - color: var(--kbq-foreground-contrast-secondary); - } - - .example-popover-scroll-strategy__triggers { - display: flex; - gap: 16px; - padding: 16px; - } - `, - providers: [ - { - provide: KBQ_POPOVER_SCROLL_STRATEGY, - useFactory: () => { - const overlay = inject(Overlay); - - return () => overlay.scrollStrategies.reposition({ scrollThrottle: 20 }); - } - } - ], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class PopoverScrollStrategyExample {} From dba74d153d765b5530ae909ce9a16ccdb43ddc1e Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 24 Jul 2026 17:49:25 +0300 Subject: [PATCH 08/13] feat: widen type --- packages/components/core/overlay/hide-on-scroll.strategy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/hide-on-scroll.strategy.ts index f46896ef7b..69b183551e 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.ts @@ -5,7 +5,7 @@ import { ScrollStrategy } from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; -import { DestroyRef, ElementRef, InputSignal, NgZone } from '@angular/core'; +import { DestroyRef, ElementRef, NgZone, Signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Observable, Subject, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; @@ -17,7 +17,7 @@ import { filter } from 'rxjs/operators'; */ export interface KbqHideOnScrollOverlay { /** Whether the overlay closes when its trigger scrolls out of its scroll container boundary. */ - readonly shouldHideOnScrollOut: InputSignal; + readonly shouldHideOnScrollOut: Signal; } /** From 746d9ad439367b71ceac92b8c290d80ce476334d Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Mon, 27 Jul 2026 14:58:57 +0300 Subject: [PATCH 09/13] feat: replaced onHide subscription with callback --- .../overlay/hide-on-scroll.strategy.spec.ts | 69 +------------------ .../core/overlay/hide-on-scroll.strategy.ts | 51 +++++++------- tools/public_api_guard/components/core.api.md | 28 ++++++++ 3 files changed, 54 insertions(+), 94 deletions(-) diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts b/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts index 75c8890cb3..c497c488bd 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts @@ -1,8 +1,8 @@ -import { CdkScrollable, ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; +import { CdkScrollable, ScrollDispatcher } from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; -import { DestroyRef, ElementRef, NgZone } from '@angular/core'; +import { ElementRef, NgZone } from '@angular/core'; import { Subject } from 'rxjs'; -import { KbqHideOnScrollStrategy, wireHideOnScroll } from './hide-on-scroll.strategy'; +import { KbqHideOnScrollStrategy } from './hide-on-scroll.strategy'; function makeRect(top: number, left: number, bottom: number, right: number): DOMRect { return { @@ -375,66 +375,3 @@ describe('KbqHideOnScrollStrategy', () => { }); }); }); - -describe('wireHideOnScroll', () => { - function makeDestroyRef(): DestroyRef { - const callbacks: (() => void)[] = []; - - return { - onDestroy: (cb: () => void) => { - callbacks.push(cb); - - return () => {}; - }, - _destroy: () => callbacks.forEach((cb) => cb()) - } as any; - } - - it('subscribes to hide$ when strategy is KbqHideOnScrollStrategy', () => { - const scroll$ = new Subject(); - const scrollDispatcher = makeScrollDispatcher(scroll$); - const strategy = new KbqHideOnScrollStrategy(scrollDispatcher, makeViewportRuler(), makeNgZone(), {}); - const overlayEl = document.createElement('div'); - - overlayEl.getBoundingClientRect = () => makeRect(-200, 0, -100, 100); - strategy.attach(makeOverlayRef(overlayEl)); - strategy.enable(); - - const destroyRef = makeDestroyRef(); - const onHide = jest.fn(); - - wireHideOnScroll(strategy, destroyRef, onHide); - scroll$.next(); - - expect(onHide).toHaveBeenCalledTimes(1); - }); - - it('does nothing when strategy is not KbqHideOnScrollStrategy', () => { - const noop: ScrollStrategy = { attach: jest.fn(), enable: jest.fn(), disable: jest.fn(), detach: jest.fn() }; - const destroyRef = makeDestroyRef(); - const onHide = jest.fn(); - - expect(() => wireHideOnScroll(noop, destroyRef, onHide)).not.toThrow(); - expect(onHide).not.toHaveBeenCalled(); - }); - - it('unsubscribes when destroyRef fires', () => { - const scroll$ = new Subject(); - const scrollDispatcher = makeScrollDispatcher(scroll$); - const strategy = new KbqHideOnScrollStrategy(scrollDispatcher, makeViewportRuler(), makeNgZone(), {}); - const overlayEl = document.createElement('div'); - - overlayEl.getBoundingClientRect = () => makeRect(-200, 0, -100, 100); - strategy.attach(makeOverlayRef(overlayEl)); - strategy.enable(); - - const destroyRef = makeDestroyRef() as any; - const onHide = jest.fn(); - - wireHideOnScroll(strategy, destroyRef, onHide); - destroyRef._destroy(); - scroll$.next(); - - expect(onHide).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/hide-on-scroll.strategy.ts index 69b183551e..cd8547aa9b 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.ts @@ -5,31 +5,10 @@ import { ScrollStrategy } from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; -import { DestroyRef, ElementRef, NgZone, Signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ElementRef, NgZone } from '@angular/core'; import { Observable, Subject, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; -/** - * Implemented by overlay-opening components that support hide-on-scroll-out. - * Declare `readonly shouldHideOnScrollOut = input(false, { transform: booleanAttribute })` and - * call `wireHideOnScroll` after the overlay is created to activate the behavior. - */ -export interface KbqHideOnScrollOverlay { - /** Whether the overlay closes when its trigger scrolls out of its scroll container boundary. */ - readonly shouldHideOnScrollOut: Signal; -} - -/** - * Subscribes to `strategy.hide$` when the strategy is a `KbqHideOnScrollStrategy`. - * Call this after creating the overlay in any component that implements `KbqHideOnScrollOverlay`. - */ -export function wireHideOnScroll(strategy: ScrollStrategy, destroyRef: DestroyRef, onHide: () => void): void { - if (strategy instanceof KbqHideOnScrollStrategy) { - strategy.hide$.pipe(takeUntilDestroyed(destroyRef)).subscribe(onHide); - } -} - export interface KbqHideOnScrollStrategyConfig { /** * Element whose position is tracked against ancestor scroll container boundaries. @@ -40,14 +19,21 @@ export interface KbqHideOnScrollStrategyConfig { scrollThrottle?: number; } +/** Lifecycle hooks passed to a scroll strategy factory. New hooks can be added without breaking existing providers. */ +export interface KbqScrollStrategyHooks { + /** Called (inside Angular zone) when the tracked element scrolls outside its scroll container boundary. */ + onHide?: () => void; +} + /** - * Scroll strategy that repositions the overlay on scroll and emits on `hide$` + * Scroll strategy that repositions the overlay on scroll and calls the optional `onHide` callback * when the tracked element moves outside its boundary: * * - With `originElement`: tracks the origin against each ancestor `CdkScrollable` container. * - Without `originElement`: tracks the overlay panel against the viewport. * - * The caller is responsible for subscribing to `hide$` and hiding/closing the overlay. + * Pass `onHide` via the factory returned by `kbqHideOnScrollStrategyFactory` so the strategy + * calls it when the trigger scrolls out of bounds. */ export class KbqHideOnScrollStrategy implements ScrollStrategy { private readonly _hideSubject = new Subject(); @@ -63,7 +49,8 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { private readonly _scrollDispatcher: ScrollDispatcher, private readonly _viewportRuler: ViewportRuler, private readonly _ngZone: NgZone, - private readonly _config: KbqHideOnScrollStrategyConfig = {} + private readonly _config: KbqHideOnScrollStrategyConfig = {}, + private _hooks?: KbqScrollStrategyHooks ) { this._originElement = _config.originElement ?? null; } @@ -102,7 +89,10 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { : this._isOverlayOutsideViewport(); if (isOutside) { - this._ngZone.run(() => this._hideSubject.next()); + this._ngZone.run(() => { + this._hideSubject.next(); + this._hooks?.onHide?.(); + }); } }); } @@ -117,6 +107,7 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { detach(): void { this.disable(); this._hideSubject.complete(); + this._hooks = undefined; this._overlayRef = null; } @@ -164,6 +155,10 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { * Factory function for `KbqHideOnScrollStrategy`. Use it directly as a `useFactory` value * when providing a component-level scroll strategy token (e.g. `KBQ_POPOVER_SCROLL_STRATEGY`). * + * The returned factory accepts an optional `onHide` callback. When provided, the strategy calls + * it (instead of relying on external `hide$` subscriptions) whenever the trigger scrolls out of + * its scroll container. + * * @example * ```ts * { @@ -177,6 +172,6 @@ export function kbqHideOnScrollStrategyFactory( scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone -): (config?: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy { - return (config = {}) => new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); +): (hooks?: KbqScrollStrategyHooks) => KbqHideOnScrollStrategy { + return (hooks?) => new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, {}, hooks); } diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 58d00bd64b..3f2c440195 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -53,6 +53,7 @@ import { RendererFactory2 } from '@angular/core'; import { RepositionScrollStrategy } from '@angular/cdk/overlay'; import { ScrollDispatcher } from '@angular/cdk/overlay'; import { ScrollStrategy } from '@angular/cdk/overlay'; +import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; import { Subscription } from 'rxjs'; import { TemplateRef } from '@angular/core'; @@ -2591,6 +2592,30 @@ export class KbqFormsModule { // @public export function kbqGetPanelWidthOrigin(origin: KbqPanelWidthOrigin): number; +// @public +export interface KbqHideOnScrollOverlay { + readonly shouldHideOnScrollOut: Signal; +} + +// @public +export class KbqHideOnScrollStrategy implements ScrollStrategy { + constructor(_scrollDispatcher: ScrollDispatcher, _viewportRuler: ViewportRuler, _ngZone: NgZone, _config?: KbqHideOnScrollStrategyConfig); + attach(overlayRef: OverlayRef): void; + detach(): void; + disable(): void; + enable(): void; + readonly hide$: Observable; +} + +// @public (undocumented) +export interface KbqHideOnScrollStrategyConfig { + originElement?: HTMLElement; + scrollThrottle?: number; +} + +// @public +export function kbqHideOnScrollStrategyFactory(scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone): (config?: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy; + // @public export const kbqHighlightBackgroundMark: (text: string) => string; @@ -4979,6 +5004,9 @@ export const VOLUME_UP = 175; // @public (undocumented) export const W = 87; +// @public +export function wireHideOnScroll(strategy: ScrollStrategy, destroyRef: DestroyRef, onHide: () => void): void; + // @public (undocumented) export function wrappedErrorMessage(e: Error): RegExp; From a340a7e3b4ae5cafc98b05b90ec250dd501ced2d Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Mon, 27 Jul 2026 15:06:44 +0300 Subject: [PATCH 10/13] chore: updated naming according to conventions --- .../overlay/hide-on-scroll.strategy.spec.ts | 28 ++++---- .../core/overlay/hide-on-scroll.strategy.ts | 68 +++++++++---------- tools/public_api_guard/components/core.api.md | 20 +++--- 3 files changed, 56 insertions(+), 60 deletions(-) diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts b/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts index c497c488bd..d7ebdb3e5d 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts @@ -106,7 +106,7 @@ describe('KbqHideOnScrollStrategy', () => { let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(0); @@ -121,7 +121,7 @@ describe('KbqHideOnScrollStrategy', () => { let completed = false; - strategy.hide$.subscribe({ complete: () => (completed = true) }); + strategy.hide.subscribe({ complete: () => (completed = true) }); strategy.detach(); expect(completed).toBe(true); @@ -145,7 +145,7 @@ describe('KbqHideOnScrollStrategy', () => { let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(1); @@ -167,7 +167,7 @@ describe('KbqHideOnScrollStrategy', () => { let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(1); @@ -188,7 +188,7 @@ describe('KbqHideOnScrollStrategy', () => { let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); // overlay is inside viewport → no emit @@ -213,7 +213,7 @@ describe('KbqHideOnScrollStrategy', () => { let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); // configOrigin is outside → should still emit @@ -246,7 +246,7 @@ describe('KbqHideOnScrollStrategy', () => { const { strategy, scroll$ } = buildWithOrigin(makeRect(-100, 0, -10, 100), containerRect); let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(1); }); @@ -255,7 +255,7 @@ describe('KbqHideOnScrollStrategy', () => { const { strategy, scroll$ } = buildWithOrigin(makeRect(510, 0, 600, 100), containerRect); let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(1); }); @@ -264,7 +264,7 @@ describe('KbqHideOnScrollStrategy', () => { const { strategy, scroll$ } = buildWithOrigin(makeRect(10, -200, 50, -10), containerRect); let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(1); }); @@ -273,7 +273,7 @@ describe('KbqHideOnScrollStrategy', () => { const { strategy, scroll$ } = buildWithOrigin(makeRect(10, 510, 50, 600), containerRect); let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(1); }); @@ -282,7 +282,7 @@ describe('KbqHideOnScrollStrategy', () => { const { strategy, scroll$ } = buildWithOrigin(makeRect(10, 10, 50, 100), containerRect); let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(0); }); @@ -329,7 +329,7 @@ describe('KbqHideOnScrollStrategy', () => { const { strategy, scroll$ } = buildViewportCase(makeRect(-200, 0, -100, 100)); let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(1); }); @@ -338,7 +338,7 @@ describe('KbqHideOnScrollStrategy', () => { const { strategy, scroll$ } = buildViewportCase(makeRect(10, 10, 100, 200)); let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); scroll$.next(); expect(count).toBe(0); }); @@ -365,7 +365,7 @@ describe('KbqHideOnScrollStrategy', () => { let count = 0; - strategy.hide$.subscribe(() => count++); + strategy.hide.subscribe(() => count++); // emit a scroll event that comes from inside the overlay panel scroll$.next(makeScrollable(innerScrollable)); diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/hide-on-scroll.strategy.ts index cd8547aa9b..88585d7590 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.ts +++ b/packages/components/core/overlay/hide-on-scroll.strategy.ts @@ -36,62 +36,62 @@ export interface KbqScrollStrategyHooks { * calls it when the trigger scrolls out of bounds. */ export class KbqHideOnScrollStrategy implements ScrollStrategy { - private readonly _hideSubject = new Subject(); + private readonly hideSubject = new Subject(); /** Emits when the tracked element scrolls outside its boundary. */ - readonly hide$: Observable = this._hideSubject.asObservable(); + readonly hide: Observable = this.hideSubject.asObservable(); - private _overlayRef: OverlayRef | null = null; - private _scrollSubscription: Subscription | null = null; - private _originElement: HTMLElement | null = null; + private overlayRef: OverlayRef | null = null; + private scrollSubscription: Subscription | null = null; + private originElement: HTMLElement | null = null; constructor( - private readonly _scrollDispatcher: ScrollDispatcher, - private readonly _viewportRuler: ViewportRuler, - private readonly _ngZone: NgZone, - private readonly _config: KbqHideOnScrollStrategyConfig = {}, - private _hooks?: KbqScrollStrategyHooks + private readonly scrollDispatcher: ScrollDispatcher, + private readonly viewportRuler: ViewportRuler, + private readonly ngZone: NgZone, + private readonly config: KbqHideOnScrollStrategyConfig = {}, + private hooks?: KbqScrollStrategyHooks ) { - this._originElement = _config.originElement ?? null; + this.originElement = config.originElement ?? null; } /** @docs-private */ attach(overlayRef: OverlayRef): void { - this._overlayRef = overlayRef; + this.overlayRef = overlayRef; - if (!this._originElement) { + if (!this.originElement) { // FlexibleConnectedPositionStrategy stores the origin as a private field. // Reading it here avoids requiring callers to pass originElement explicitly. - this._originElement = this.coerceOriginElement((overlayRef.getConfig().positionStrategy as any)?._origin); + this.originElement = this.coerceOriginElement((overlayRef.getConfig().positionStrategy as any)?._origin); } } /** Subscribes to scroll events and starts repositioning / out-of-bounds detection. */ enable(): void { - if (this._scrollSubscription) return; + if (this.scrollSubscription) return; - const { scrollThrottle = 20 } = this._config; + const { scrollThrottle = 20 } = this.config; - this._scrollSubscription = this._scrollDispatcher + this.scrollSubscription = this.scrollDispatcher .scrolled(scrollThrottle) .pipe( filter( (scrollable) => !scrollable || - !this._overlayRef!.overlayElement.contains(scrollable.getElementRef().nativeElement) + !this.overlayRef!.overlayElement.contains(scrollable.getElementRef().nativeElement) ) ) .subscribe(() => { - this._overlayRef!.updatePosition(); + this.overlayRef!.updatePosition(); - const isOutside = this._originElement - ? this._isOriginOutsideAncestors(this._originElement) + const isOutside = this.originElement + ? this._isOriginOutsideAncestors(this.originElement) : this._isOverlayOutsideViewport(); if (isOutside) { - this._ngZone.run(() => { - this._hideSubject.next(); - this._hooks?.onHide?.(); + this.ngZone.run(() => { + this.hideSubject.next(); + this.hooks?.onHide?.(); }); } }); @@ -99,22 +99,22 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { /** Unsubscribes from scroll events. */ disable(): void { - this._scrollSubscription?.unsubscribe(); - this._scrollSubscription = null; + this.scrollSubscription?.unsubscribe(); + this.scrollSubscription = null; } - /** Disables the strategy and completes `hide$`. */ + /** Disables the strategy and completes `hide`. */ detach(): void { this.disable(); - this._hideSubject.complete(); - this._hooks = undefined; - this._overlayRef = null; + this.hideSubject.complete(); + this.hooks = undefined; + this.overlayRef = null; } private _isOriginOutsideAncestors(originElement: HTMLElement): boolean { const originRect = originElement.getBoundingClientRect(); - return this._scrollDispatcher + return this.scrollDispatcher .getAncestorScrollContainers(originElement) .some((scrollable) => this._isOutsideBounds(originRect, scrollable.getElementRef().nativeElement.getBoundingClientRect()) @@ -122,8 +122,8 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { } private _isOverlayOutsideViewport(): boolean { - const overlayRect = this._overlayRef!.overlayElement.getBoundingClientRect(); - const { width, height } = this._viewportRuler.getViewportSize(); + const overlayRect = this.overlayRef!.overlayElement.getBoundingClientRect(); + const { width, height } = this.viewportRuler.getViewportSize(); return this._isOutsideBounds(overlayRect, { top: 0, left: 0, bottom: height, right: width } as DOMRect); } @@ -156,7 +156,7 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { * when providing a component-level scroll strategy token (e.g. `KBQ_POPOVER_SCROLL_STRATEGY`). * * The returned factory accepts an optional `onHide` callback. When provided, the strategy calls - * it (instead of relying on external `hide$` subscriptions) whenever the trigger scrolls out of + * it (instead of relying on external `hide` subscriptions) whenever the trigger scrolls out of * its scroll container. * * @example diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 3f2c440195..6aba27e5b1 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -53,7 +53,6 @@ import { RendererFactory2 } from '@angular/core'; import { RepositionScrollStrategy } from '@angular/cdk/overlay'; import { ScrollDispatcher } from '@angular/cdk/overlay'; import { ScrollStrategy } from '@angular/cdk/overlay'; -import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; import { Subscription } from 'rxjs'; import { TemplateRef } from '@angular/core'; @@ -2592,19 +2591,14 @@ export class KbqFormsModule { // @public export function kbqGetPanelWidthOrigin(origin: KbqPanelWidthOrigin): number; -// @public -export interface KbqHideOnScrollOverlay { - readonly shouldHideOnScrollOut: Signal; -} - // @public export class KbqHideOnScrollStrategy implements ScrollStrategy { - constructor(_scrollDispatcher: ScrollDispatcher, _viewportRuler: ViewportRuler, _ngZone: NgZone, _config?: KbqHideOnScrollStrategyConfig); + constructor(scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone, config?: KbqHideOnScrollStrategyConfig, hooks?: KbqScrollStrategyHooks | undefined); attach(overlayRef: OverlayRef): void; detach(): void; disable(): void; enable(): void; - readonly hide$: Observable; + readonly hide: Observable; } // @public (undocumented) @@ -2614,7 +2608,7 @@ export interface KbqHideOnScrollStrategyConfig { } // @public -export function kbqHideOnScrollStrategyFactory(scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone): (config?: KbqHideOnScrollStrategyConfig) => KbqHideOnScrollStrategy; +export function kbqHideOnScrollStrategyFactory(scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone): (hooks?: KbqScrollStrategyHooks) => KbqHideOnScrollStrategy; // @public export const kbqHighlightBackgroundMark: (text: string) => string; @@ -3384,6 +3378,11 @@ export class KbqRoundDecimalPipe implements PipeTransform { static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public +export interface KbqScrollStrategyHooks { + onHide?: () => void; +} + // @public export interface KbqSelectAllAdapter { isSelectable: (item: T) => boolean; @@ -5004,9 +5003,6 @@ export const VOLUME_UP = 175; // @public (undocumented) export const W = 87; -// @public -export function wireHideOnScroll(strategy: ScrollStrategy, destroyRef: DestroyRef, onHide: () => void): void; - // @public (undocumented) export function wrappedErrorMessage(e: Error): RegExp; From 615fc1eaf14d3d35c2d8a428576d4c4cf91c1cec Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 28 Jul 2026 14:54:00 +0300 Subject: [PATCH 11/13] chore: after review --- ...c.ts => auto-hide-scroll-strategy.spec.ts} | 6 +- ...rategy.ts => auto-hide-scroll-strategy.ts} | 57 ++++++++++++++----- packages/components/core/public-api.ts | 2 +- 3 files changed, 47 insertions(+), 18 deletions(-) rename packages/components/core/overlay/{hide-on-scroll.strategy.spec.ts => auto-hide-scroll-strategy.spec.ts} (98%) rename packages/components/core/overlay/{hide-on-scroll.strategy.ts => auto-hide-scroll-strategy.ts} (70%) diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts b/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts similarity index 98% rename from packages/components/core/overlay/hide-on-scroll.strategy.spec.ts rename to packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts index d7ebdb3e5d..953e5529dd 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.spec.ts +++ b/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts @@ -1,8 +1,8 @@ import { CdkScrollable, ScrollDispatcher } from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; import { ElementRef, NgZone } from '@angular/core'; +import { KbqAutoHideScrollStrategy } from '@koobiq/components/core'; import { Subject } from 'rxjs'; -import { KbqHideOnScrollStrategy } from './hide-on-scroll.strategy'; function makeRect(top: number, left: number, bottom: number, right: number): DOMRect { return { @@ -59,12 +59,12 @@ function buildStrategy( const scrollDispatcher = deps.scrollDispatcher ?? makeScrollDispatcher(scroll$); const viewportRuler = deps.viewportRuler ?? makeViewportRuler(); const ngZone = deps.ngZone ?? makeNgZone(); - const strategy = new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); + const strategy = new KbqAutoHideScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); return { strategy, scroll$, scrollDispatcher, viewportRuler, ngZone }; } -describe('KbqHideOnScrollStrategy', () => { +describe('KbqAutoHideScrollStrategy', () => { describe('lifecycle', () => { it('attach() stores the overlayRef', () => { const { strategy } = buildStrategy(); diff --git a/packages/components/core/overlay/hide-on-scroll.strategy.ts b/packages/components/core/overlay/auto-hide-scroll-strategy.ts similarity index 70% rename from packages/components/core/overlay/hide-on-scroll.strategy.ts rename to packages/components/core/overlay/auto-hide-scroll-strategy.ts index 88585d7590..b610ffe16e 100644 --- a/packages/components/core/overlay/hide-on-scroll.strategy.ts +++ b/packages/components/core/overlay/auto-hide-scroll-strategy.ts @@ -1,15 +1,27 @@ import { + FlexibleConnectedPositionStrategy, FlexibleConnectedPositionStrategyOrigin, OverlayRef, ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; -import { ElementRef, NgZone } from '@angular/core'; +import { ElementRef, isDevMode, NgZone } from '@angular/core'; import { Observable, Subject, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; -export interface KbqHideOnScrollStrategyConfig { +/** + * `FlexibleConnectedPositionStrategy` narrowed to its private `_origin` field, which the + * class doesn't expose publicly. `_origin` isn't part of the typed public API surface, so a + * future `@angular/cdk` upgrade could rename or remove it without a compile error here — + * if that happens, `attach()` below warns in dev mode instead of silently losing the + * ancestor-scroll-container tracking. + */ +interface PositionStrategyWithOrigin { + _origin?: FlexibleConnectedPositionStrategyOrigin; +} + +export interface KbqAutoHideScrollStrategyConfig { /** * Element whose position is tracked against ancestor scroll container boundaries. * When omitted, the overlay panel is checked against the viewport instead. @@ -32,10 +44,10 @@ export interface KbqScrollStrategyHooks { * - With `originElement`: tracks the origin against each ancestor `CdkScrollable` container. * - Without `originElement`: tracks the overlay panel against the viewport. * - * Pass `onHide` via the factory returned by `kbqHideOnScrollStrategyFactory` so the strategy + * Pass `onHide` via the factory returned by `kbqAutoHideScrollStrategyFactory` so the strategy * calls it when the trigger scrolls out of bounds. */ -export class KbqHideOnScrollStrategy implements ScrollStrategy { +export class KbqAutoHideScrollStrategy implements ScrollStrategy { private readonly hideSubject = new Subject(); /** Emits when the tracked element scrolls outside its boundary. */ @@ -49,7 +61,7 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { private readonly scrollDispatcher: ScrollDispatcher, private readonly viewportRuler: ViewportRuler, private readonly ngZone: NgZone, - private readonly config: KbqHideOnScrollStrategyConfig = {}, + private readonly config: KbqAutoHideScrollStrategyConfig = {}, private hooks?: KbqScrollStrategyHooks ) { this.originElement = config.originElement ?? null; @@ -60,9 +72,22 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { this.overlayRef = overlayRef; if (!this.originElement) { + const positionStrategy = overlayRef.getConfig().positionStrategy; + // FlexibleConnectedPositionStrategy stores the origin as a private field. // Reading it here avoids requiring callers to pass originElement explicitly. - this.originElement = this.coerceOriginElement((overlayRef.getConfig().positionStrategy as any)?._origin); + if (positionStrategy instanceof FlexibleConnectedPositionStrategy) { + const origin = (positionStrategy as PositionStrategyWithOrigin)._origin; + + if (isDevMode() && origin === undefined) { + // eslint-disable-next-line no-console + console.warn( + 'KbqAutoHideScrollStrategy: `_origin` is missing on FlexibleConnectedPositionStrategy. Pass `originElement` explicitly.' + ); + } + + this.originElement = this.coerceOriginElement(origin); + } } } @@ -70,6 +95,11 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { enable(): void { if (this.scrollSubscription) return; + if (!this.overlayRef) { + throw new Error('KbqAutoHideScrollStrategy: enable() was called before attach(). Call attach() first.'); + } + + const overlayRef = this.overlayRef; const { scrollThrottle = 20 } = this.config; this.scrollSubscription = this.scrollDispatcher @@ -77,12 +107,11 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { .pipe( filter( (scrollable) => - !scrollable || - !this.overlayRef!.overlayElement.contains(scrollable.getElementRef().nativeElement) + !scrollable || !overlayRef.overlayElement.contains(scrollable.getElementRef().nativeElement) ) ) .subscribe(() => { - this.overlayRef!.updatePosition(); + overlayRef.updatePosition(); const isOutside = this.originElement ? this._isOriginOutsideAncestors(this.originElement) @@ -152,7 +181,7 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { } /** - * Factory function for `KbqHideOnScrollStrategy`. Use it directly as a `useFactory` value + * Factory function for `KbqAutoHideScrollStrategy`. Use it directly as a `useFactory` value * when providing a component-level scroll strategy token (e.g. `KBQ_POPOVER_SCROLL_STRATEGY`). * * The returned factory accepts an optional `onHide` callback. When provided, the strategy calls @@ -164,14 +193,14 @@ export class KbqHideOnScrollStrategy implements ScrollStrategy { * { * provide: KBQ_POPOVER_SCROLL_STRATEGY, * deps: [ScrollDispatcher, ViewportRuler, NgZone], - * useFactory: kbqHideOnScrollStrategyFactory + * useFactory: kbqAutoHideScrollStrategyFactory * } * ``` */ -export function kbqHideOnScrollStrategyFactory( +export function kbqAutoHideScrollStrategyFactory( scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone -): (hooks?: KbqScrollStrategyHooks) => KbqHideOnScrollStrategy { - return (hooks?) => new KbqHideOnScrollStrategy(scrollDispatcher, viewportRuler, ngZone, {}, hooks); +): (hooks?: KbqScrollStrategyHooks) => KbqAutoHideScrollStrategy { + return (hooks?) => new KbqAutoHideScrollStrategy(scrollDispatcher, viewportRuler, ngZone, {}, hooks); } diff --git a/packages/components/core/public-api.ts b/packages/components/core/public-api.ts index 7917628ffa..25ec41d82e 100644 --- a/packages/components/core/public-api.ts +++ b/packages/components/core/public-api.ts @@ -13,7 +13,7 @@ export * from './locales/index'; export * from './navbar/index'; export * from './option/index'; export * from './overflow-shadow/index'; -export * from './overlay/hide-on-scroll.strategy'; +export * from './overlay/auto-hide-scroll-strategy'; export * from './overlay/overlay-position-map'; export * from './overlay/panel-width'; export * from './overlay/shadow-dom-overlay-container'; From 0f0618bf886a7bbc8aedcfedfd60bf918e22b564 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 28 Jul 2026 14:59:20 +0300 Subject: [PATCH 12/13] chore: upd api --- tools/public_api_guard/components/core.api.md | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 6aba27e5b1..33b28a4a7a 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -2364,6 +2364,25 @@ export enum KbqAnimationDurations { Rapid = "100ms" } +// @public +export class KbqAutoHideScrollStrategy implements ScrollStrategy { + constructor(scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone, config?: KbqAutoHideScrollStrategyConfig, hooks?: KbqScrollStrategyHooks | undefined); + attach(overlayRef: OverlayRef): void; + detach(): void; + disable(): void; + enable(): void; + readonly hide: Observable; +} + +// @public (undocumented) +export interface KbqAutoHideScrollStrategyConfig { + originElement?: HTMLElement; + scrollThrottle?: number; +} + +// @public +export function kbqAutoHideScrollStrategyFactory(scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone): (hooks?: KbqScrollStrategyHooks) => KbqAutoHideScrollStrategy; + // @public (undocumented) export interface KbqBaseFileUploadLocaleConfig { // (undocumented) @@ -2591,25 +2610,6 @@ export class KbqFormsModule { // @public export function kbqGetPanelWidthOrigin(origin: KbqPanelWidthOrigin): number; -// @public -export class KbqHideOnScrollStrategy implements ScrollStrategy { - constructor(scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone, config?: KbqHideOnScrollStrategyConfig, hooks?: KbqScrollStrategyHooks | undefined); - attach(overlayRef: OverlayRef): void; - detach(): void; - disable(): void; - enable(): void; - readonly hide: Observable; -} - -// @public (undocumented) -export interface KbqHideOnScrollStrategyConfig { - originElement?: HTMLElement; - scrollThrottle?: number; -} - -// @public -export function kbqHideOnScrollStrategyFactory(scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, ngZone: NgZone): (hooks?: KbqScrollStrategyHooks) => KbqHideOnScrollStrategy; - // @public export const kbqHighlightBackgroundMark: (text: string) => string; From a7be6ec57ec880205ddb982d7076caafbd1d0068 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 28 Jul 2026 16:30:06 +0300 Subject: [PATCH 13/13] chore: upd tests --- .../core/overlay/auto-hide-scroll-strategy.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts b/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts index 953e5529dd..3c124a4a25 100644 --- a/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts +++ b/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts @@ -1,4 +1,4 @@ -import { CdkScrollable, ScrollDispatcher } from '@angular/cdk/overlay'; +import { CdkScrollable, FlexibleConnectedPositionStrategy, ScrollDispatcher } from '@angular/cdk/overlay'; import { ViewportRuler } from '@angular/cdk/scrolling'; import { ElementRef, NgZone } from '@angular/core'; import { KbqAutoHideScrollStrategy } from '@koobiq/components/core'; @@ -26,7 +26,12 @@ function makeScrollDispatcher(scrollSubject: Subject, cont } function makeOverlayRef(overlayElement: HTMLElement, positionOrigin?: unknown) { - const positionStrategy = positionOrigin !== undefined ? { _origin: positionOrigin } : {}; + // `instanceof FlexibleConnectedPositionStrategy` mirrors the real CDK class the strategy + // checks for in production, without needing a full DI-constructed instance. + const positionStrategy = + positionOrigin !== undefined + ? Object.assign(Object.create(FlexibleConnectedPositionStrategy.prototype), { _origin: positionOrigin }) + : {}; return { overlayElement,