diff --git a/packages/components-dev/popover/module.ts b/packages/components-dev/popover/module.ts index e1edb2c40..c3bbbafe9 100644 --- a/packages/components-dev/popover/module.ts +++ b/packages/components-dev/popover/module.ts @@ -41,6 +41,10 @@ import { DevThemeToggle } from '../theme-toggle';
+
+ +
+ `, changeDetection: ChangeDetectionStrategy.OnPush }) diff --git a/packages/components/app-switcher/app-switcher.spec.ts b/packages/components/app-switcher/app-switcher.spec.ts index b59b38fb0..fe296e06b 100644 --- a/packages/components/app-switcher/app-switcher.spec.ts +++ b/packages/components/app-switcher/app-switcher.spec.ts @@ -1,12 +1,15 @@ import { OverlayContainer } from '@angular/cdk/overlay'; import { IMAGE_LOADER, ImageLoaderConfig } from '@angular/common'; -import { Component, Provider, Type } from '@angular/core'; -import { ComponentFixture, TestBed, fakeAsync, inject, tick } from '@angular/core/testing'; +import { Component, Provider, Type, viewChild } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, flush, inject, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { KbqHideOnScrollStrategy } from '@koobiq/components/core'; +import { Subject } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { TestScheduler } from 'rxjs/testing'; import { + KBQ_APP_SWITCHER_SCROLL_STRATEGY, KBQ_MIN_NUMBER_OF_APPS_TO_ENABLE_SEARCH, KbqAppSwitcherApp, KbqAppSwitcherComponent, @@ -816,3 +819,120 @@ class ListItemHost { class DropdownSiteHost { site: KbqAppSwitcherSite = { ...SITE_A }; } + +// --------------------------------------------------------------------------- +// hide-on-scroll tests +// --------------------------------------------------------------------------- + +class TestAppSwitcherHideOnScrollStrategy extends KbqHideOnScrollStrategy { + readonly trigger$ = new Subject(); + override readonly hide$ = this.trigger$.asObservable(); + + constructor() { + super(null as any, null as any, null as any); + } + + override attach = jest.fn(); + override enable = jest.fn(); + override disable = jest.fn(); + override detach = jest.fn(); +} + +@Component({ + imports: [KbqAppSwitcherModule], + template: ` + + ` +}) +class AppSwitcherHideOnScrollDefault { + readonly trigger = viewChild.required(KbqAppSwitcherTrigger); + sites: KbqAppSwitcherSite[] = [{ ...SITE_A, apps: [...SITE_A.apps] }]; +} + +@Component({ + imports: [KbqAppSwitcherModule], + template: ` + + ` +}) +class AppSwitcherHideOnScrollEnabled { + readonly trigger = viewChild.required(KbqAppSwitcherTrigger); + sites: KbqAppSwitcherSite[] = [{ ...SITE_A, apps: [...SITE_A.apps] }]; +} + +describe('KbqAppSwitcherTrigger hide-on-scroll', () => { + it('does not hide when shouldHideOnScrollOut is false (default)', fakeAsync(() => { + const strategy = new TestAppSwitcherHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [AppSwitcherHideOnScrollDefault, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_APP_SWITCHER_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(AppSwitcherHideOnScrollDefault); + + fixture.detectChanges(); + + fixture.componentInstance.trigger().show(); + fixture.detectChanges(); + tick(); + + const hideSpy = jest.spyOn(fixture.componentInstance.trigger(), 'hide'); + + strategy.trigger$.next(); + flush(); + + expect(hideSpy).not.toHaveBeenCalled(); + })); + + it('hides when shouldHideOnScrollOut=true and hide$ emits', fakeAsync(() => { + const strategy = new TestAppSwitcherHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [AppSwitcherHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_APP_SWITCHER_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(AppSwitcherHideOnScrollEnabled); + + fixture.detectChanges(); + + fixture.componentInstance.trigger().show(); + fixture.detectChanges(); + tick(); + + const hideSpy = jest.spyOn(fixture.componentInstance.trigger(), 'hide'); + + strategy.trigger$.next(); + flush(); + + expect(hideSpy).toHaveBeenCalled(); + })); + + it('does not crash with a non-KbqHideOnScrollStrategy scroll strategy', fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [AppSwitcherHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + + const fixture = TestBed.createComponent(AppSwitcherHideOnScrollEnabled); + + fixture.detectChanges(); + + expect(() => { + fixture.componentInstance.trigger().show(); + fixture.detectChanges(); + tick(); + flush(); + }).not.toThrow(); + })); +}); diff --git a/packages/components/app-switcher/app-switcher.ts b/packages/components/app-switcher/app-switcher.ts index 61a6c7f77..56aaea8ca 100644 --- a/packages/components/app-switcher/app-switcher.ts +++ b/packages/components/app-switcher/app-switcher.ts @@ -32,6 +32,7 @@ import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KBQ_LOCALE_SERVICE, + KbqHideOnScrollOverlay, KbqOptionModule, KbqPopUp, KbqPopUpPlacementValues, @@ -298,7 +299,7 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit { }) export class KbqAppSwitcherTrigger extends KbqPopUpTrigger - implements AfterContentInit, OnInit + implements AfterContentInit, KbqHideOnScrollOverlay, OnInit { /** @docs-private */ protected scrollStrategy: () => ScrollStrategy = inject(KBQ_APP_SWITCHER_SCROLL_STRATEGY); diff --git a/packages/components/autocomplete/autocomplete-trigger.directive.ts b/packages/components/autocomplete/autocomplete-trigger.directive.ts index 9c0befd18..ea714e879 100644 --- a/packages/components/autocomplete/autocomplete-trigger.directive.ts +++ b/packages/components/autocomplete/autocomplete-trigger.directive.ts @@ -16,6 +16,7 @@ import { DOCUMENT } from '@angular/common'; import { AfterViewInit, ChangeDetectorRef, + DestroyRef, Directive, ElementRef, InjectionToken, @@ -25,6 +26,7 @@ import { Provider, ViewContainerRef, afterNextRender, + booleanAttribute, forwardRef, inject, input @@ -35,6 +37,7 @@ import { ENTER, ESCAPE, KBQ_WINDOW, + KbqHideOnScrollOverlay, KbqOption, KbqOptionSelectionChange, KbqResolvedPanelWidth, @@ -43,7 +46,8 @@ import { UP_ARROW, defaultOffsetY, kbqGetPanelWidthOrigin, - kbqResolvePanelWidth + kbqResolvePanelWidth, + wireHideOnScroll } from '@koobiq/components/core'; import { KbqFormField } from '@koobiq/components/form-field'; import { Observable, Subject, Subscription, defer, fromEvent, merge, of as observableOf } from 'rxjs'; @@ -114,7 +118,7 @@ export function getKbqAutocompleteMissingPanelError(): Error { exportAs: 'kbqAutocompleteTrigger' }) export class KbqAutocompleteTrigger - implements AfterViewInit, ControlValueAccessor, OnDestroy, KeyboardNavigationHandler + implements AfterViewInit, ControlValueAccessor, OnDestroy, KeyboardNavigationHandler, KbqHideOnScrollOverlay { private elementRef = inject>(ElementRef); private viewContainerRef = inject(ViewContainerRef); @@ -122,6 +126,7 @@ export class KbqAutocompleteTrigger private overlay = inject(Overlay); private zone = inject(NgZone); private dir = inject(Directionality, { optional: true })!; + private readonly destroyRef = inject(DestroyRef); private formField = inject(KbqFormField, { optional: true, host: true }); private viewportRuler = inject(ViewportRuler); @@ -151,6 +156,9 @@ export class KbqAutocompleteTrigger return this.overlayAttached && this.autocomplete().showPanel; } + /** Whether to hide the autocomplete panel when its trigger scrolls out of its scroll container boundary. */ + readonly shouldHideOnScrollOut = input(false, { transform: booleanAttribute }); + /** The autocomplete panel to be attached to this trigger. */ readonly autocomplete = input(undefined!, { alias: 'kbqAutocomplete' }); @@ -582,9 +590,15 @@ export class KbqAutocompleteTrigger if (!overlayRef) { this.portal = new TemplatePortal(autocomplete.template(), this.viewContainerRef); - overlayRef = this.overlay.create(this.getOverlayConfig()); + const scrollStrategy = this.scrollStrategy(); + + overlayRef = this.overlay.create(this.getOverlayConfig(scrollStrategy)); this.overlayRef = overlayRef; + if (this.shouldHideOnScrollOut()) { + wireHideOnScroll(scrollStrategy, this.destroyRef, () => this.closePanel()); + } + // Use the `keydownEvents` in order to take advantage of // the overlay event targeting provided by the CDK overlay. overlayRef.keydownEvents().subscribe((event) => { @@ -641,10 +655,10 @@ export class KbqAutocompleteTrigger }); } - private getOverlayConfig(): OverlayConfig { + private getOverlayConfig(scrollStrategy: ScrollStrategy): OverlayConfig { return new OverlayConfig({ positionStrategy: this.getOverlayPosition(), - scrollStrategy: this.scrollStrategy(), + scrollStrategy, direction: this.dir, ...this.getOverlaySize() }); diff --git a/packages/components/autocomplete/autocomplete.spec.ts b/packages/components/autocomplete/autocomplete.spec.ts index 25d9b8c60..4053bba90 100644 --- a/packages/components/autocomplete/autocomplete.spec.ts +++ b/packages/components/autocomplete/autocomplete.spec.ts @@ -23,6 +23,7 @@ import { ENTER, ESCAPE, KBQ_PANEL_DEFAULT_MIN_WIDTH, + KbqHideOnScrollStrategy, KbqLocaleServiceModule, KbqOption, KbqOptionSelectionChange, @@ -44,6 +45,7 @@ import { map, startWith, take } from 'rxjs/operators'; import { KbqInputModule } from '../input/index'; import { KBQ_AUTOCOMPLETE_DEFAULT_OPTIONS, + KBQ_AUTOCOMPLETE_SCROLL_STRATEGY, KbqAutocomplete, KbqAutocompleteModule, KbqAutocompleteOrigin, @@ -2793,3 +2795,118 @@ class AutocompleteWithCustomOnBlur { customBlurSpy: jest.Mock = jest.fn().mockReturnValue(false); } + +// --------------------------------------------------------------------------- +// hide-on-scroll tests +// --------------------------------------------------------------------------- + +class TestAutocompleteHideOnScrollStrategy extends KbqHideOnScrollStrategy { + readonly trigger$ = new Subject(); + override readonly hide$ = this.trigger$.asObservable(); + + constructor() { + super(null as any, null as any, null as any); + } + + override attach = jest.fn(); + override enable = jest.fn(); + override disable = jest.fn(); + override detach = jest.fn(); +} + +@Component({ + imports: [KbqInputModule, KbqAutocompleteModule], + template: ` + + + + + one + + ` +}) +class AutocompleteHideOnScrollDefault { + readonly trigger = viewChild.required(KbqAutocompleteTrigger); +} + +@Component({ + imports: [KbqInputModule, KbqAutocompleteModule], + template: ` + + + + + one + + ` +}) +class AutocompleteHideOnScrollEnabled { + readonly trigger = viewChild.required(KbqAutocompleteTrigger); +} + +describe('KbqAutocompleteTrigger hide-on-scroll', () => { + it('does not close when shouldHideOnScrollOut is false (default)', fakeAsync(() => { + const strategy = new TestAutocompleteHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [AutocompleteHideOnScrollDefault, NoopAnimationsModule, KbqLocaleServiceModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_AUTOCOMPLETE_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(AutocompleteHideOnScrollDefault); + + fixture.detectChanges(); + + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + tick(); + + const closeSpy = jest.spyOn(fixture.componentInstance.trigger(), 'closePanel'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).not.toHaveBeenCalled(); + })); + + it('closes when shouldHideOnScrollOut=true and hide$ emits', fakeAsync(() => { + const strategy = new TestAutocompleteHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [AutocompleteHideOnScrollEnabled, NoopAnimationsModule, KbqLocaleServiceModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_AUTOCOMPLETE_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(AutocompleteHideOnScrollEnabled); + + fixture.detectChanges(); + + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + tick(); + + const closeSpy = jest.spyOn(fixture.componentInstance.trigger(), 'closePanel'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).toHaveBeenCalled(); + })); + + it('does not crash with a non-KbqHideOnScrollStrategy scroll strategy', fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [AutocompleteHideOnScrollEnabled, NoopAnimationsModule, KbqLocaleServiceModule] + }).compileComponents(); + + const fixture = TestBed.createComponent(AutocompleteHideOnScrollEnabled); + + fixture.detectChanges(); + + expect(() => { + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + tick(); + flush(); + }).not.toThrow(); + })); +}); diff --git a/packages/components/core/pop-up/pop-up-trigger.ts b/packages/components/core/pop-up/pop-up-trigger.ts index 6de777357..f0c6ff27d 100644 --- a/packages/components/core/pop-up/pop-up-trigger.ts +++ b/packages/components/core/pop-up/pop-up-trigger.ts @@ -14,12 +14,14 @@ import { import { ComponentPortal } from '@angular/cdk/portal'; import { ViewportRuler } from '@angular/cdk/scrolling'; import { + booleanAttribute, ChangeDetectorRef, DestroyRef, Directive, ElementRef, EventEmitter, inject, + input, NgZone, OnDestroy, OnInit, @@ -32,6 +34,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 { wireHideOnScroll } from '../overlay/hide-on-scroll.strategy'; import { EXTENDED_OVERLAY_POSITIONS, POSITION_MAP, @@ -103,6 +106,9 @@ const getOffset = ( } }) export abstract class KbqPopUpTrigger implements OnInit, OnDestroy { + /** Whether to close the pop-up when its trigger scrolls out of its scroll container boundary. */ + readonly shouldHideOnScrollOut = input(false, { transform: booleanAttribute }); + /** Stream that emits when the popupTrigger is hovered. * @docs-private */ readonly hovered = new BehaviorSubject(false); @@ -443,13 +449,19 @@ export abstract class KbqPopUpTrigger implements OnInit, OnDestroy { this.strategy.positionChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(this.onPositionChange); + const scrollStrategy = this.scrollStrategy(); + this.overlayRef = this.overlay.create({ ...this.overlayConfig, direction: this.direction || undefined, positionStrategy: this.strategy, - scrollStrategy: this.scrollStrategy() + scrollStrategy }); + if (this.shouldHideOnScrollOut()) { + wireHideOnScroll(scrollStrategy, this.destroyRef, () => this.hide()); + } + this.subscribeOnClosingActions(); this.overlayRef.detachments().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(this.detach); diff --git a/packages/components/datepicker/datepicker.component.ts b/packages/components/datepicker/datepicker.component.ts index 14e8fbaab..f1e985049 100644 --- a/packages/components/datepicker/datepicker.component.ts +++ b/packages/components/datepicker/datepicker.component.ts @@ -6,10 +6,12 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { DOCUMENT } from '@angular/common'; import { AfterViewInit, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, + DestroyRef, inject, InjectionToken, Input, @@ -21,7 +23,7 @@ import { ViewContainerRef, ViewEncapsulation } from '@angular/core'; -import { DateAdapter, defaultOffsetY } from '@koobiq/components/core'; +import { DateAdapter, defaultOffsetY, KbqHideOnScrollOverlay, wireHideOnScroll } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { merge, Subject, Subscription } from 'rxjs'; import { take } from 'rxjs/operators'; @@ -126,12 +128,13 @@ export class KbqDatepickerContent implements OnDestroy, AfterViewInit { encapsulation: ViewEncapsulation.None, exportAs: 'kbqDatepicker' }) -export class KbqDatepicker implements OnDestroy { +export class KbqDatepicker implements OnDestroy, KbqHideOnScrollOverlay { private overlay = inject(Overlay); private ngZone = inject(NgZone); private viewContainerRef = inject(ViewContainerRef); private readonly dateAdapter = inject>(DateAdapter, { optional: true })!; private dir = inject(Directionality, { optional: true })!; + private readonly destroyRef = inject(DestroyRef); protected readonly document = inject(DOCUMENT); @@ -266,6 +269,9 @@ export class KbqDatepicker implements OnDestroy { /** The id for the datepicker calendar. */ id: string = `kbq-datepicker-${datepickerUid++}`; + /** Whether to hide the datepicker when its trigger scrolls out of its scroll container boundary. */ + readonly shouldHideOnScrollOut = input(false, { transform: booleanAttribute }); + /** A reference to the overlay when the calendar is opened as a popup. */ popupRef: OverlayRef | null; @@ -444,17 +450,22 @@ export class KbqDatepicker implements OnDestroy { /** Create the popup. */ private createPopup(): void { + const scrollStrategy = this.scrollStrategy(); const overlayConfig = new OverlayConfig({ positionStrategy: this.createPopupPositionStrategy(), hasBackdrop: this.hasBackdrop, backdropClass: this.backdropClass(), direction: this.dir, - scrollStrategy: this.scrollStrategy(), + scrollStrategy, panelClass: 'kbq-datepicker__popup' }); this.popupRef = this.overlay.create(overlayConfig); + if (this.shouldHideOnScrollOut()) { + wireHideOnScroll(scrollStrategy, this.destroyRef, () => this.close()); + } + this.closeSubscription = this.closingActions().subscribe(() => this.close(this.restoreFocus())); } diff --git a/packages/components/datepicker/datepicker.spec.ts b/packages/components/datepicker/datepicker.spec.ts index 8d7a2df2c..b5fb6b3bc 100644 --- a/packages/components/datepicker/datepicker.spec.ts +++ b/packages/components/datepicker/datepicker.spec.ts @@ -31,6 +31,7 @@ import { KBQ_DATE_FORMATS, KBQ_DATE_LOCALE, kbqErrorStateMatcherProvider, + KbqHideOnScrollStrategy, ONE, ShowOnControlDirtyErrorStateMatcher, ShowOnFormSubmitErrorStateMatcher, @@ -39,11 +40,11 @@ import { } from '@koobiq/components/core'; import { KbqFormFieldModule } from '@koobiq/components/form-field'; import { DateTime } from 'luxon'; -import { map, Observable, timer } from 'rxjs'; +import { map, Observable, Subject, timer } from 'rxjs'; import { KbqInputModule } from '../input/index'; import { KbqDatepickerInput, KbqDatepickerInputEvent } from './datepicker-input.directive'; import { KbqDatepickerToggleIconComponent } from './datepicker-toggle.component'; -import { KbqDatepicker } from './datepicker.component'; +import { KBQ_DATEPICKER_SCROLL_STRATEGY, KbqDatepicker } from './datepicker.component'; import { KbqDatepickerModule } from './index'; const getDatepickerInputElement = (fixture: ComponentFixture): HTMLInputElement => @@ -1768,3 +1769,110 @@ class DelayedDatepicker { date: DateTime | null; assignedDatepicker: KbqDatepicker; } + +// --------------------------------------------------------------------------- +// hide-on-scroll tests +// --------------------------------------------------------------------------- + +class TestDatepickerHideOnScrollStrategy extends KbqHideOnScrollStrategy { + readonly trigger$ = new Subject(); + override readonly hide$ = this.trigger$.asObservable(); + + constructor() { + super(null as any, null as any, null as any); + } + + override attach = jest.fn(); + override enable = jest.fn(); + override disable = jest.fn(); + override detach = jest.fn(); +} + +@Component({ + imports: [KbqDatepickerModule, KbqInputModule, KbqLuxonDateModule], + template: ` + + + ` +}) +class DatepickerHideOnScrollDefault { + readonly datepicker = viewChild.required>('picker'); +} + +@Component({ + imports: [KbqDatepickerModule, KbqInputModule, KbqLuxonDateModule], + template: ` + + + ` +}) +class DatepickerHideOnScrollEnabled { + readonly datepicker = viewChild.required>('picker'); +} + +describe('KbqDatepicker hide-on-scroll', () => { + it('does not close when shouldHideOnScrollOut is false (default)', fakeAsync(() => { + const strategy = new TestDatepickerHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [DatepickerHideOnScrollDefault, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_DATEPICKER_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(DatepickerHideOnScrollDefault); + + fixture.detectChanges(); + + fixture.componentInstance.datepicker().open(); + fixture.detectChanges(); + tick(); + + const closeSpy = jest.spyOn(fixture.componentInstance.datepicker(), 'close'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).not.toHaveBeenCalled(); + })); + + it('closes when shouldHideOnScrollOut=true and hide$ emits', fakeAsync(() => { + const strategy = new TestDatepickerHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [DatepickerHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_DATEPICKER_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(DatepickerHideOnScrollEnabled); + + fixture.detectChanges(); + + fixture.componentInstance.datepicker().open(); + fixture.detectChanges(); + tick(); + + const closeSpy = jest.spyOn(fixture.componentInstance.datepicker(), 'close'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).toHaveBeenCalled(); + })); + + it('does not crash with a non-KbqHideOnScrollStrategy scroll strategy', fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [DatepickerHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + + const fixture = TestBed.createComponent(DatepickerHideOnScrollEnabled); + + fixture.detectChanges(); + + expect(() => { + fixture.componentInstance.datepicker().open(); + fixture.detectChanges(); + tick(); + flush(); + }).not.toThrow(); + })); +}); diff --git a/packages/components/dropdown/dropdown-trigger.directive.ts b/packages/components/dropdown/dropdown-trigger.directive.ts index 4eca2f230..295502875 100644 --- a/packages/components/dropdown/dropdown-trigger.directive.ts +++ b/packages/components/dropdown/dropdown-trigger.directive.ts @@ -16,11 +16,13 @@ import { AfterContentInit, booleanAttribute, ChangeDetectorRef, + DestroyRef, Directive, ElementRef, EventEmitter, inject, InjectionToken, + input, Input, NgZone, numberAttribute, @@ -35,12 +37,14 @@ import { DOWN_ARROW, ENTER, kbqGetPanelWidthOrigin, + KbqHideOnScrollOverlay, KbqPanelWidthOrigin, KbqResolvedPanelWidth, kbqResolvePanelWidth, LEFT_ARROW, RIGHT_ARROW, - SPACE + SPACE, + wireHideOnScroll } from '@koobiq/components/core'; import { asapScheduler, merge, Observable, of as observableOf, Subscription } from 'rxjs'; import { delay, filter, take, takeUntil } from 'rxjs/operators'; @@ -126,11 +130,12 @@ const positionMap = { }, exportAs: 'kbqDropdownTrigger' }) -export class KbqDropdownTrigger implements AfterContentInit, OnDestroy { +export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqHideOnScrollOverlay { private overlay = inject(Overlay); private elementRef = inject>(ElementRef); private viewContainerRef = inject(ViewContainerRef); private scrollStrategy = inject(KBQ_DROPDOWN_SCROLL_STRATEGY); + private readonly destroyRef = inject(DestroyRef); private parent = inject(KbqDropdown, { optional: true })!; private dropdownItemInstance = inject(KbqDropdownItem, { optional: true, self: true })!; private _dir = inject(Directionality, { optional: true }); @@ -145,6 +150,9 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy { private readonly host = inject(KBQ_DROPDOWN_HOST, { optional: true }); lastDestroyReason: DropdownCloseReason; + /** Whether to hide the dropdown when its trigger scrolls out of its scroll container boundary. */ + readonly shouldHideOnScrollOut = input(false, { transform: booleanAttribute }); + /** * Element whose width the panel should match. Defaults to the trigger itself. * Useful when the trigger is only a part of a larger control, e.g. `kbq-split-button`. @@ -533,11 +541,16 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy { */ private createOverlay(): OverlayRef { if (!this.overlayRef) { - const config = this.getOverlayConfig(); + const scrollStrategy = this.scrollStrategy(); + const config = this.getOverlayConfig(scrollStrategy); this.subscribeToPositions(config.positionStrategy as FlexibleConnectedPositionStrategy); this.overlayRef = this.overlay.create(config); + if (this.shouldHideOnScrollOut()) { + wireHideOnScroll(scrollStrategy, this.destroyRef, () => this.close()); + } + // Consume the `keydownEvents` in order to prevent them from going to another overlay. // Ideally we'd also have our keyboard event logic in here, however doing so will // break anybody that may have implemented the `KbqDropdownPanel` themselves. @@ -558,7 +571,7 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy { * This method builds the configuration object needed to create the overlay, the OverlayState. * @returns OverlayConfig */ - private getOverlayConfig(): OverlayConfig { + private getOverlayConfig(scrollStrategy: ScrollStrategy): OverlayConfig { return new OverlayConfig({ positionStrategy: this.overlay .position() @@ -566,7 +579,7 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy { .withTransformOriginOn('.kbq-dropdown__panel') .withPush(false), backdropClass: this.dropdown.backdropClass || 'cdk-overlay-transparent-backdrop', - scrollStrategy: this.scrollStrategy(), + scrollStrategy, direction: this.dir, ...(this.shouldMatchTriggerWidth && this.getOverlaySize()) }); diff --git a/packages/components/dropdown/dropdown.spec.ts b/packages/components/dropdown/dropdown.spec.ts index 3c8de9232..1ec7ff11f 100644 --- a/packages/components/dropdown/dropdown.spec.ts +++ b/packages/components/dropdown/dropdown.spec.ts @@ -26,6 +26,7 @@ import { DOWN_ARROW, ENTER, ESCAPE, + KbqHideOnScrollStrategy, LEFT_ARROW, MockNgZone, RIGHT_ARROW, @@ -2470,3 +2471,114 @@ class VerticalTriggerDropdown { readonly trigger = viewChild.required(KbqDropdownTrigger); readonly triggerEl = viewChild.required>('triggerEl'); } + +// --------------------------------------------------------------------------- +// hide-on-scroll tests +// --------------------------------------------------------------------------- + +class TestDropdownHideOnScrollStrategy extends KbqHideOnScrollStrategy { + readonly trigger$ = new Subject(); + override readonly hide$ = this.trigger$.asObservable(); + + constructor() { + super(null as any, null as any, null as any); + } + + override attach = jest.fn(); + override enable = jest.fn(); + override disable = jest.fn(); + override detach = jest.fn(); +} + +@Component({ + imports: [KbqDropdownModule], + template: ` + + + + + ` +}) +class DropdownHideOnScrollDefault { + readonly trigger = viewChild.required(KbqDropdownTrigger); +} + +@Component({ + imports: [KbqDropdownModule], + template: ` + + + + + ` +}) +class DropdownHideOnScrollEnabled { + readonly trigger = viewChild.required(KbqDropdownTrigger); +} + +describe('KbqDropdownTrigger hide-on-scroll', () => { + it('does not close when shouldHideOnScrollOut is false (default)', fakeAsync(() => { + const strategy = new TestDropdownHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [DropdownHideOnScrollDefault, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_DROPDOWN_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(DropdownHideOnScrollDefault); + + fixture.detectChanges(); + + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + tick(); + + const closeSpy = jest.spyOn(fixture.componentInstance.trigger(), 'close'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).not.toHaveBeenCalled(); + })); + + it('closes when shouldHideOnScrollOut=true and hide$ emits', fakeAsync(() => { + const strategy = new TestDropdownHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [DropdownHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_DROPDOWN_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(DropdownHideOnScrollEnabled); + + fixture.detectChanges(); + + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + tick(); + + const closeSpy = jest.spyOn(fixture.componentInstance.trigger(), 'close'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).toHaveBeenCalled(); + })); + + it('does not crash with a non-KbqHideOnScrollStrategy scroll strategy', fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [DropdownHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + + const fixture = TestBed.createComponent(DropdownHideOnScrollEnabled); + + fixture.detectChanges(); + + expect(() => { + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + tick(); + flush(); + }).not.toThrow(); + })); +}); diff --git a/packages/components/notification-center/notification-center.spec.ts b/packages/components/notification-center/notification-center.spec.ts index 1bf0b0997..5e83ef852 100644 --- a/packages/components/notification-center/notification-center.spec.ts +++ b/packages/components/notification-center/notification-center.spec.ts @@ -1,11 +1,12 @@ import { OverlayContainer } from '@angular/cdk/overlay'; import { Component, DebugElement, ElementRef, Provider, Type, viewChild } from '@angular/core'; -import { ComponentFixture, TestBed, fakeAsync, inject, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, flush, inject, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { KbqLuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; -import { KbqFormattersModule, dispatchFakeEvent } from '@koobiq/components/core'; +import { KbqFormattersModule, KbqHideOnScrollStrategy, dispatchFakeEvent } from '@koobiq/components/core'; import { + KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY, KbqNotificationCenterModule, KbqNotificationCenterService, KbqNotificationCenterTrigger, @@ -13,6 +14,7 @@ import { } from '@koobiq/components/notification-center'; import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; import { KbqToastService } from '@koobiq/components/toast'; +import { Subject } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { TestScheduler } from 'rxjs/testing'; @@ -791,3 +793,108 @@ export class KbqNotificationCenterWithStickContainer { readonly trigger = viewChild.required(KbqNotificationCenterTrigger); readonly container = viewChild.required>('containerRef'); } + +// --------------------------------------------------------------------------- +// hide-on-scroll tests +// --------------------------------------------------------------------------- + +class TestNotificationCenterHideOnScrollStrategy extends KbqHideOnScrollStrategy { + readonly trigger$ = new Subject(); + override readonly hide$ = this.trigger$.asObservable(); + + constructor() { + super(null as any, null as any, null as any); + } + + override attach = jest.fn(); + override enable = jest.fn(); + override disable = jest.fn(); + override detach = jest.fn(); +} + +@Component({ + imports: [KbqNotificationCenterModule, KbqLuxonDateModule, KbqFormattersModule], + template: ` + + ` +}) +class NotificationCenterHideOnScrollDefault { + readonly trigger = viewChild.required(KbqNotificationCenterTrigger); +} + +@Component({ + imports: [KbqNotificationCenterModule, KbqLuxonDateModule, KbqFormattersModule], + template: ` + + ` +}) +class NotificationCenterHideOnScrollEnabled { + readonly trigger = viewChild.required(KbqNotificationCenterTrigger); +} + +describe('KbqNotificationCenterTrigger hide-on-scroll', () => { + it('does not hide when shouldHideOnScrollOut is false (default)', fakeAsync(() => { + const strategy = new TestNotificationCenterHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [NotificationCenterHideOnScrollDefault, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(NotificationCenterHideOnScrollDefault); + + fixture.detectChanges(); + + fixture.componentInstance.trigger().show(); + fixture.detectChanges(); + tick(); + + const hideSpy = jest.spyOn(fixture.componentInstance.trigger(), 'hide'); + + strategy.trigger$.next(); + flush(); + + expect(hideSpy).not.toHaveBeenCalled(); + })); + + it('hides when shouldHideOnScrollOut=true and hide$ emits', fakeAsync(() => { + const strategy = new TestNotificationCenterHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [NotificationCenterHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(NotificationCenterHideOnScrollEnabled); + + fixture.detectChanges(); + + fixture.componentInstance.trigger().show(); + fixture.detectChanges(); + tick(); + + const hideSpy = jest.spyOn(fixture.componentInstance.trigger(), 'hide'); + + strategy.trigger$.next(); + flush(); + + expect(hideSpy).toHaveBeenCalled(); + })); + + it('does not crash with a non-KbqHideOnScrollStrategy scroll strategy', fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [NotificationCenterHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + + const fixture = TestBed.createComponent(NotificationCenterHideOnScrollEnabled); + + fixture.detectChanges(); + + expect(() => { + fixture.componentInstance.trigger().show(); + fixture.detectChanges(); + tick(); + flush(); + }).not.toThrow(); + })); +}); diff --git a/packages/components/notification-center/notification-center.ts b/packages/components/notification-center/notification-center.ts index e1dad2899..c67e0fef3 100644 --- a/packages/components/notification-center/notification-center.ts +++ b/packages/components/notification-center/notification-center.ts @@ -26,6 +26,7 @@ import { KbqButton, KbqButtonModule } from '@koobiq/components/button'; import { DateAdapter, KBQ_LOCALE_SERVICE, + KbqHideOnScrollOverlay, KbqOverflowShadowBottom, KbqOverflowShadowContainer, KbqOverflowShadowTop, @@ -355,7 +356,7 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie }) export class KbqNotificationCenterTrigger extends KbqPopUpTrigger - implements AfterContentInit + implements AfterContentInit, KbqHideOnScrollOverlay { /** @docs-private */ protected scrollStrategy: () => ScrollStrategy = inject(KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY); diff --git a/packages/components/popover/examples.popover.en.md b/packages/components/popover/examples.popover.en.md index d9a3068f7..a09a74f84 100644 --- a/packages/components/popover/examples.popover.en.md +++ b/packages/components/popover/examples.popover.en.md @@ -1,3 +1,15 @@ #### Opening on hover + +#### Hiding on scroll + +By default, the popover hides when its trigger scrolls out of its scroll container. Set `[hideIfNotInViewPort]="false"` to keep it open. + + + +#### Custom scroll strategy + +Override `KBQ_POPOVER_SCROLL_STRATEGY` to change how the overlay reacts to scrolling (e.g. `reposition` instead of the default hide-on-scroll). + + diff --git a/packages/components/popover/examples.popover.ru.md b/packages/components/popover/examples.popover.ru.md index 3f7bb88a7..a6469d38b 100644 --- a/packages/components/popover/examples.popover.ru.md +++ b/packages/components/popover/examples.popover.ru.md @@ -1,3 +1,15 @@ #### Открытие по hover + +#### Скрытие при прокрутке + +По умолчанию поповер скрывается, когда его триггер выходит за границы прокручиваемого контейнера. Передайте `[hideIfNotInViewPort]="false"`, чтобы поповер оставался открытым. + + + +#### Пользовательская стратегия прокрутки + +Переопределите токен `KBQ_POPOVER_SCROLL_STRATEGY` через DI, чтобы изменить поведение оверлея при прокрутке (например, использовать `reposition` вместо скрытия). + + diff --git a/packages/components/popover/popover.component.ts b/packages/components/popover/popover.component.ts index d0b22d1f7..f93730cf6 100644 --- a/packages/components/popover/popover.component.ts +++ b/packages/components/popover/popover.component.ts @@ -4,11 +4,12 @@ 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, @@ -21,7 +22,7 @@ import { EventEmitter, InjectionToken, Input, - OnInit, + NgZone, Output, Renderer2, TemplateRef, @@ -38,6 +39,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqComponentColors, + KbqHideOnScrollOverlay, KbqOverflowShadowBottom, KbqOverflowShadowContainer, KbqOverflowShadowTop, @@ -49,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'; @@ -131,16 +134,11 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { 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 + deps: [ScrollDispatcher, ViewportRuler, NgZone], + useFactory: kbqHideOnScrollStrategyFactory }; /** Creates an error to be thrown if the user supplied an invalid popover position. */ @@ -158,14 +156,20 @@ export function getKbqPopoverInvalidPositionError(position: string) { }, exportAs: 'kbqPopover' }) -export class KbqPopoverTrigger extends KbqPopUpTrigger implements AfterContentInit, OnInit { +export class KbqPopoverTrigger + extends KbqPopUpTrigger + implements AfterContentInit, KbqHideOnScrollOverlay +{ 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 is not visible in the viewport. */ - readonly hideIfNotInViewPort = input(true, { transform: booleanAttribute }); + override readonly shouldHideOnScrollOut = input(true, { + alias: 'hideIfNotInViewPort', + transform: booleanAttribute + }); /** prevents closure by any event */ // TODO: Skipped for migration because: @@ -441,15 +445,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) => { @@ -545,22 +540,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.spec.ts b/packages/components/popover/popover.spec.ts index 050e8a41e..0527cb7b9 100644 --- a/packages/components/popover/popover.spec.ts +++ b/packages/components/popover/popover.spec.ts @@ -1,6 +1,21 @@ import { coerceElement } from '@angular/cdk/coercion'; -import { FlexibleConnectedPositionStrategy, OverlayContainer } from '@angular/cdk/overlay'; -import { Component, DebugElement, ElementRef, Provider, Type, inject as inject_1, viewChild } from '@angular/core'; +import { + CdkScrollable, + FlexibleConnectedPositionStrategy, + OverlayContainer, + ScrollDispatcher +} from '@angular/cdk/overlay'; +import { ViewportRuler } from '@angular/cdk/scrolling'; +import { + Component, + DebugElement, + ElementRef, + NgZone, + Provider, + Type, + inject as inject_1, + viewChild +} from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, inject, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -8,15 +23,17 @@ import { ARROW_BOTTOM_MARGIN_AND_HALF_HEIGHT, ENTER, ESCAPE, + KbqHideOnScrollStrategy, SPACE, dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent } from '@koobiq/components/core'; +import { Subject } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { TestScheduler } from 'rxjs/testing'; import { KBQ_POPOVER_CONFIRM_BUTTON_TEXT, KBQ_POPOVER_CONFIRM_TEXT } from './popover-confirm.component'; -import { KbqPopoverTrigger } from './popover.component'; +import { KBQ_POPOVER_SCROLL_STRATEGY, KbqPopoverTrigger } from './popover.component'; import { KbqPopoverModule } from './popover.module'; function openAndAssertPopover(componentFixture: ComponentFixture, triggerElement: ElementRef) { @@ -402,6 +419,103 @@ describe('KbqPopover', () => { expect(footer).toEqual(componentInstance.context.footer); })); }); + + describe('shouldHideOnScrollOut / hideIfNotInViewPort', () => { + let capturedStrategy: KbqHideOnScrollStrategy | null; + + // configureTestingModule + overrideProvider ensures our strategy beats the module-level provider + const createHideOnScrollFixture = (component: Type) => { + capturedStrategy = null; + testScheduler = new TestScheduler((act, exp) => expect(exp).toEqual(act)); + + TestBed.configureTestingModule({ + imports: [component, NoopAnimationsModule], + providers: [{ provide: AsyncScheduler, useValue: testScheduler }] + }); + + TestBed.overrideProvider(KBQ_POPOVER_SCROLL_STRATEGY, { + useFactory: (sd: ScrollDispatcher, vr: ViewportRuler, ngZone: NgZone) => { + return () => { + capturedStrategy = new KbqHideOnScrollStrategy(sd, vr, ngZone, {}); + + return capturedStrategy!; + }; + }, + deps: [ScrollDispatcher, ViewportRuler, NgZone] + }); + + const fixture = TestBed.createComponent(component); + + fixture.autoDetectChanges(); + + return fixture; + }; + + it('hides popover when hide$ emits (default hideIfNotInViewPort=true)', fakeAsync(() => { + const fixture = createHideOnScrollFixture(PopoverSimple); + const trigger = fixture.componentInstance.popoverTrigger(); + + openAndAssertPopover(fixture, fixture.componentInstance.triggerElementRef()); + expect(capturedStrategy).toBeTruthy(); + + (capturedStrategy as any)._hideSubject.next(); + tick(); + fixture.detectChanges(); + + expect(trigger.isOpen).toBe(false); + })); + + it('does NOT hide popover when hide$ emits and [hideIfNotInViewPort]="false"', fakeAsync(() => { + const fixture = createHideOnScrollFixture(PopoverHideOnScrollDisabled); + const trigger = fixture.componentInstance.popoverTrigger(); + + openAndAssertPopover(fixture, fixture.componentInstance.triggerElementRef()); + expect(capturedStrategy).toBeTruthy(); + + (capturedStrategy as any)._hideSubject.next(); + tick(); + fixture.detectChanges(); + + expect(trigger.isOpen).toBe(true); + })); + + it('does not crash when scroll strategy is not KbqHideOnScrollStrategy', fakeAsync(() => { + testScheduler = new TestScheduler((act, exp) => expect(exp).toEqual(act)); + + const fixture = createComponent(PopoverSimple); + const trigger = fixture.componentInstance.popoverTrigger(); + + expect(() => openAndAssertPopover(fixture, fixture.componentInstance.triggerElementRef())).not.toThrow(); + expect(trigger.isOpen).toBe(true); + })); + + it('closeOnScroll=true closes on a scroll event independently of shouldHideOnScrollOut', fakeAsync(() => { + testScheduler = new TestScheduler((act, exp) => expect(exp).toEqual(act)); + + TestBed.configureTestingModule({ + imports: [PopoverCloseOnScrollHideDisabled, NoopAnimationsModule], + providers: [{ provide: AsyncScheduler, useValue: testScheduler }] + }); + + const scrollSubject = new Subject(); + + jest.spyOn(TestBed.inject(ScrollDispatcher), 'scrolled').mockReturnValue(scrollSubject.asObservable()); + + const fixture = TestBed.createComponent(PopoverCloseOnScrollHideDisabled); + + fixture.autoDetectChanges(); + + const trigger = fixture.componentInstance.popoverTrigger(); + + openAndAssertPopover(fixture, fixture.componentInstance.triggerElementRef()); + + scrollSubject.next(); + tick(0); // flush delay(0) in closingActionsSubscription + fixture.detectChanges(); + + expect(trigger.isOpen).toBe(false); + })); + }); }); @Component({ @@ -493,6 +607,32 @@ class KbqPopoverConfirmWithProvidersTestComponent { readonly test12 = viewChild.required('test12'); } +@Component({ + selector: 'popover-hide-on-scroll-disabled', + imports: [KbqPopoverModule], + template: ` + + ` +}) +class PopoverHideOnScrollDisabled { + readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); + readonly triggerElementRef = viewChild.required(KbqPopoverTrigger, { read: ElementRef }); +} + +@Component({ + selector: 'popover-close-on-scroll-hide-disabled', + imports: [KbqPopoverModule], + template: ` + + ` +}) +class PopoverCloseOnScrollHideDisabled { + readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); + readonly triggerElementRef = viewChild.required(KbqPopoverTrigger, { read: ElementRef }); +} + @Component({ selector: 'kbq-popover-wih-template-ref', imports: [KbqPopoverModule], diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 83f6643d9..803be9fd8 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -40,6 +40,8 @@ import { ESCAPE, ErrorStateMatcher, HOME, + KBQ_SELECT_SCROLL_STRATEGY, + KbqHideOnScrollStrategy, KbqOption, KbqOptionSelectionChange, KbqVirtualOption, @@ -7308,3 +7310,117 @@ describe('KbqSelect', () => { }); }); }); + +// --------------------------------------------------------------------------- +// hide-on-scroll tests +// --------------------------------------------------------------------------- + +class TestSelectHideOnScrollStrategy extends KbqHideOnScrollStrategy { + readonly trigger$ = new Subject(); + override readonly hide$ = this.trigger$.asObservable(); + + constructor() { + super(null as any, null as any, null as any); + } + + override attach = jest.fn(); + override enable = jest.fn(); + override disable = jest.fn(); + override detach = jest.fn(); +} + +@Component({ + imports: [KbqSelectModule, ReactiveFormsModule], + template: ` + + + One + + + ` +}) +class SelectHideOnScrollDefault { + readonly select = viewChild.required(KbqSelect); + readonly control = new FormControl(''); +} + +@Component({ + imports: [KbqSelectModule, ReactiveFormsModule], + template: ` + + + One + + + ` +}) +class SelectHideOnScrollEnabled { + readonly select = viewChild.required(KbqSelect); + readonly control = new FormControl(''); +} + +describe('KbqSelect hide-on-scroll', () => { + it('does not close when shouldHideOnScrollOut is false (default)', fakeAsync(() => { + const strategy = new TestSelectHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [SelectHideOnScrollDefault, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_SELECT_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(SelectHideOnScrollDefault); + + fixture.autoDetectChanges(); + flush(); + + fixture.componentInstance.select().open(); + fixture.detectChanges(); + flush(); + + const closeSpy = jest.spyOn(fixture.componentInstance.select(), 'close'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).not.toHaveBeenCalled(); + })); + + it('closes when shouldHideOnScrollOut=true and hide$ emits', fakeAsync(() => { + const strategy = new TestSelectHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [SelectHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_SELECT_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(SelectHideOnScrollEnabled); + + fixture.autoDetectChanges(); + flush(); + + fixture.componentInstance.select().open(); + fixture.detectChanges(); + flush(); + + const closeSpy = jest.spyOn(fixture.componentInstance.select(), 'close'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).toHaveBeenCalled(); + })); + + it('does not crash with a non-KbqHideOnScrollStrategy scroll strategy', fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [SelectHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + + const fixture = TestBed.createComponent(SelectHideOnScrollEnabled); + + fixture.autoDetectChanges(); + + expect(() => { + flush(); + }).not.toThrow(); + })); +}); diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts index ab2077d2d..874e97df4 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -61,6 +61,7 @@ import { KBQ_WINDOW, KbqAbstractSelect, KbqComponentColors, + KbqHideOnScrollOverlay, KbqLocaleService, KbqOptgroup, KbqOption, @@ -92,7 +93,8 @@ import { isUndefined, kbqSelectAnimations, shouldSelectSearchText, - toggleSelectAll + toggleSelectAll, + wireHideOnScroll } from '@koobiq/components/core'; import { KbqCleaner, KbqFormField, KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -219,7 +221,8 @@ export class KbqSelect DoCheck, ControlValueAccessor, KbqFormFieldControl, - CanUpdateErrorState + CanUpdateErrorState, + KbqHideOnScrollOverlay { private readonly _changeDetectorRef = inject(ChangeDetectorRef); private readonly _ngZone = inject(NgZone); @@ -280,6 +283,9 @@ export class KbqSelect /** Strategy that will be used to handle scrolling while the select panel is open. */ scrollStrategy = this.scrollStrategyFactory(); + /** Whether to hide the select panel when its trigger scrolls out of its scroll container boundary. */ + readonly shouldHideOnScrollOut = input(false, { transform: booleanAttribute }); + /** * The y-offset of the overlay panel in relation to the trigger's top start corner. * This must be adjusted to align the selected option text over the trigger text @@ -904,6 +910,10 @@ export class KbqSelect this.selectionModel = new SelectionModel(this.multiSelection); this.stateChanges.next(); + if (this.shouldHideOnScrollOut()) { + wireHideOnScroll(this.scrollStrategy, this.destroyRef, () => this.close()); + } + // We need `distinctUntilChanged` here, because some browsers will // fire the animation end event twice for the same animation. See: // https://github.com/angular/angular/issues/24084 diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index 7159aa47a..f6d65ea93 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -38,6 +38,8 @@ import { ErrorStateMatcher, HOME, KBQ_LOCALE_SERVICE, + KBQ_SELECT_SCROLL_STRATEGY, + KbqHideOnScrollStrategy, KbqLocaleService, KbqLocaleServiceModule, KbqPanelWidth, @@ -73,7 +75,7 @@ import { KbqTreeOption, KbqTreeSelection } from '@koobiq/components/tree'; -import { Observable, Subject, map, of, timer } from 'rxjs'; +import { Observable, Subject, map, of, timer } from 'rxjs'; // Subject used by hide-on-scroll tests import { KbqTreeSelect, KbqTreeSelectChange, kbqTreeSelectOptionsProvider } from './tree-select.component'; import { KbqTreeSelectModule } from './tree-select.module'; @@ -4941,3 +4943,113 @@ describe('KbqTreeSelect', () => { })); }); }); + +// --------------------------------------------------------------------------- +// hide-on-scroll tests +// --------------------------------------------------------------------------- + +class TestTreeSelectHideOnScrollStrategy extends KbqHideOnScrollStrategy { + readonly trigger$ = new Subject(); + override readonly hide$ = this.trigger$.asObservable(); + + constructor() { + super(null as any, null as any, null as any); + } + + override attach = jest.fn(); + override enable = jest.fn(); + override disable = jest.fn(); + override detach = jest.fn(); +} + +@Component({ + imports: [KbqTreeSelectModule, ReactiveFormsModule], + template: ` + + + + ` +}) +class TreeSelectHideOnScrollDefault { + readonly treeSelect = viewChild.required(KbqTreeSelect); + readonly control = new FormControl(''); +} + +@Component({ + imports: [KbqTreeSelectModule, ReactiveFormsModule], + template: ` + + + + ` +}) +class TreeSelectHideOnScrollEnabled { + readonly treeSelect = viewChild.required(KbqTreeSelect); + readonly control = new FormControl(''); +} + +describe('KbqTreeSelect hide-on-scroll', () => { + it('does not close when shouldHideOnScrollOut is false (default)', fakeAsync(() => { + const strategy = new TestTreeSelectHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [TreeSelectHideOnScrollDefault, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_SELECT_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(TreeSelectHideOnScrollDefault); + + fixture.autoDetectChanges(); + flush(); + + fixture.componentInstance.treeSelect().open(); + fixture.detectChanges(); + flush(); + + const closeSpy = jest.spyOn(fixture.componentInstance.treeSelect(), 'close'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).not.toHaveBeenCalled(); + })); + + it('closes when shouldHideOnScrollOut=true and hide$ emits', fakeAsync(() => { + const strategy = new TestTreeSelectHideOnScrollStrategy(); + + TestBed.configureTestingModule({ + imports: [TreeSelectHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + TestBed.overrideProvider(KBQ_SELECT_SCROLL_STRATEGY, { useValue: () => strategy }); + + const fixture = TestBed.createComponent(TreeSelectHideOnScrollEnabled); + + fixture.autoDetectChanges(); + flush(); + + fixture.componentInstance.treeSelect().open(); + fixture.detectChanges(); + flush(); + + const closeSpy = jest.spyOn(fixture.componentInstance.treeSelect(), 'close'); + + strategy.trigger$.next(); + flush(); + + expect(closeSpy).toHaveBeenCalled(); + })); + + it('does not crash with a non-KbqHideOnScrollStrategy scroll strategy', fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TreeSelectHideOnScrollEnabled, NoopAnimationsModule] + }).compileComponents(); + + const fixture = TestBed.createComponent(TreeSelectHideOnScrollEnabled); + + fixture.autoDetectChanges(); + + expect(() => { + flush(); + }).not.toThrow(); + })); +}); diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index 49a2b66d8..a35ba78b9 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -54,6 +54,7 @@ import { KBQ_WINDOW, KbqAbstractSelect, KbqComponentColors, + KbqHideOnScrollOverlay, KbqLocaleService, KbqPanelMaxWidth, KbqPanelMinWidth, @@ -78,7 +79,8 @@ import { isSelectAll, isUndefined, kbqSelectAnimations, - shouldSelectSearchText + shouldSelectSearchText, + wireHideOnScroll } from '@koobiq/components/core'; import { KbqCleaner, KbqFormField, KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -199,7 +201,8 @@ export class KbqTreeSelect DoCheck, ControlValueAccessor, KbqFormFieldControl, - CanUpdateErrorState + CanUpdateErrorState, + KbqHideOnScrollOverlay { elementRef = inject>(ElementRef); readonly changeDetectorRef = inject(ChangeDetectorRef); @@ -248,6 +251,9 @@ export class KbqTreeSelect /** Strategy that will be used to handle scrolling while the select panel is open. */ scrollStrategy = this.scrollStrategyFactory(); + /** Whether to hide the tree-select panel when its trigger scrolls out of its scroll container boundary. */ + readonly shouldHideOnScrollOut = input(false, { transform: booleanAttribute }); + /** * The y-offset of the overlay panel in relation to the trigger's top start corner. * This must be adjusted to align the selected option text over the trigger text. @@ -719,6 +725,10 @@ export class KbqTreeSelect ngOnInit() { this.stateChanges.next(); + if (this.shouldHideOnScrollOut()) { + wireHideOnScroll(this.scrollStrategy, this.destroyRef, () => this.close()); + } + // We need `distinctUntilChanged` here, because some browsers will // fire the animation end event twice for the same animation. See: // https://github.com/angular/angular/issues/24084 diff --git a/packages/docs-examples/components/popover/index.ts b/packages/docs-examples/components/popover/index.ts index c968e8ee5..f2d58bcfe 100644 --- a/packages/docs-examples/components/popover/index.ts +++ b/packages/docs-examples/components/popover/index.ts @@ -5,11 +5,13 @@ 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'; @@ -22,6 +24,7 @@ export { PopoverContentExample, PopoverHeaderExample, PopoverHeightExample, + PopoverHideOnScrollExample, PopoverHoverExample, PopoverOverviewExample, PopoverPaddingsExample, @@ -29,6 +32,7 @@ export { PopoverPlacementEdgesExample, PopoverScrollExample, PopoverScrollingAndLayeringExample, + PopoverScrollStrategyExample, PopoverSmallExample, PopoverWidthExample }; @@ -43,6 +47,8 @@ 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 new file mode 100644 index 000000000..f3fdfa635 --- /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 {} 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 000000000..4cc92953d --- /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: 250px; + 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 {}