diff --git a/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts b/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts new file mode 100644 index 0000000000..3c124a4a25 --- /dev/null +++ b/packages/components/core/overlay/auto-hide-scroll-strategy.spec.ts @@ -0,0 +1,382 @@ +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'; +import { Subject } from 'rxjs'; + +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) { + // `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, + 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 KbqAutoHideScrollStrategy(scrollDispatcher, viewportRuler, ngZone, config); + + return { strategy, scroll$, scrollDispatcher, viewportRuler, ngZone }; +} + +describe('KbqAutoHideScrollStrategy', () => { + 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); + }); + }); +}); diff --git a/packages/components/core/overlay/auto-hide-scroll-strategy.ts b/packages/components/core/overlay/auto-hide-scroll-strategy.ts new file mode 100644 index 0000000000..b610ffe16e --- /dev/null +++ b/packages/components/core/overlay/auto-hide-scroll-strategy.ts @@ -0,0 +1,206 @@ +import { + FlexibleConnectedPositionStrategy, + FlexibleConnectedPositionStrategyOrigin, + OverlayRef, + ScrollDispatcher, + ScrollStrategy +} from '@angular/cdk/overlay'; +import { ViewportRuler } from '@angular/cdk/scrolling'; +import { ElementRef, isDevMode, NgZone } from '@angular/core'; +import { Observable, Subject, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +/** + * `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. + */ + originElement?: HTMLElement; + /** Scroll event throttle in ms. Defaults to 20. */ + 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 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. + * + * Pass `onHide` via the factory returned by `kbqAutoHideScrollStrategyFactory` so the strategy + * calls it when the trigger scrolls out of bounds. + */ +export class KbqAutoHideScrollStrategy 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; + private originElement: HTMLElement | null = null; + + constructor( + private readonly scrollDispatcher: ScrollDispatcher, + private readonly viewportRuler: ViewportRuler, + private readonly ngZone: NgZone, + private readonly config: KbqAutoHideScrollStrategyConfig = {}, + private hooks?: KbqScrollStrategyHooks + ) { + this.originElement = config.originElement ?? null; + } + + /** @docs-private */ + attach(overlayRef: OverlayRef): void { + 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. + 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); + } + } + } + + /** Subscribes to scroll events and starts repositioning / out-of-bounds detection. */ + 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 + .scrolled(scrollThrottle) + .pipe( + filter( + (scrollable) => + !scrollable || !overlayRef.overlayElement.contains(scrollable.getElementRef().nativeElement) + ) + ) + .subscribe(() => { + overlayRef.updatePosition(); + + const isOutside = this.originElement + ? this._isOriginOutsideAncestors(this.originElement) + : this._isOverlayOutsideViewport(); + + if (isOutside) { + this.ngZone.run(() => { + this.hideSubject.next(); + this.hooks?.onHide?.(); + }); + } + }); + } + + /** 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.hooks = undefined; + 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 + ); + } + + private coerceOriginElement(raw?: FlexibleConnectedPositionStrategyOrigin) { + if (raw instanceof HTMLElement) { + return raw; + } else if (raw instanceof ElementRef && raw?.nativeElement instanceof HTMLElement) { + return raw.nativeElement; + } + + return null; + } +} + +/** + * 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 + * it (instead of relying on external `hide` subscriptions) whenever the trigger scrolls out of + * its scroll container. + * + * @example + * ```ts + * { + * provide: KBQ_POPOVER_SCROLL_STRATEGY, + * deps: [ScrollDispatcher, ViewportRuler, NgZone], + * useFactory: kbqAutoHideScrollStrategyFactory + * } + * ``` + */ +export function kbqAutoHideScrollStrategyFactory( + scrollDispatcher: ScrollDispatcher, + viewportRuler: ViewportRuler, + ngZone: NgZone +): (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 b0d07069af..25ec41d82e 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/auto-hide-scroll-strategy'; export * from './overlay/overlay-position-map'; export * from './overlay/panel-width'; export * from './overlay/shadow-dom-overlay-container'; diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 58d00bd64b..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) @@ -3359,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;