Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/components-dev/popover/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ import { DevThemeToggle } from '../theme-toggle';
<popover-arrowless-example />
<br />
<popover-arrow-and-offset-example />
<br />
<popover-hide-on-scroll-example />
<br />
<popover-scroll-strategy-example />
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
Expand Down
124 changes: 122 additions & 2 deletions packages/components/app-switcher/app-switcher.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -816,3 +819,120 @@ class ListItemHost {
class DropdownSiteHost {
site: KbqAppSwitcherSite = { ...SITE_A };
}

// ---------------------------------------------------------------------------
// hide-on-scroll tests
// ---------------------------------------------------------------------------

class TestAppSwitcherHideOnScrollStrategy extends KbqHideOnScrollStrategy {
readonly trigger$ = new Subject<void>();
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: `
<button kbqAppSwitcher [sites]="sites" [selectedSite]="sites[0]" [selectedApp]="sites[0].apps[0]">
Trigger
</button>
`
})
class AppSwitcherHideOnScrollDefault {
readonly trigger = viewChild.required(KbqAppSwitcherTrigger);
sites: KbqAppSwitcherSite[] = [{ ...SITE_A, apps: [...SITE_A.apps] }];
}

@Component({
imports: [KbqAppSwitcherModule],
template: `
<button
kbqAppSwitcher
[sites]="sites"
[selectedSite]="sites[0]"
[selectedApp]="sites[0].apps[0]"
[shouldHideOnScrollOut]="true"
>
Trigger
</button>
`
})
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();
}));
});
3 changes: 2 additions & 1 deletion packages/components/app-switcher/app-switcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -298,7 +299,7 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit {
})
export class KbqAppSwitcherTrigger
extends KbqPopUpTrigger<KbqAppSwitcherComponent>
implements AfterContentInit, OnInit
implements AfterContentInit, KbqHideOnScrollOverlay, OnInit
{
/** @docs-private */
protected scrollStrategy: () => ScrollStrategy = inject(KBQ_APP_SWITCHER_SCROLL_STRATEGY);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { DOCUMENT } from '@angular/common';
import {
AfterViewInit,
ChangeDetectorRef,
DestroyRef,
Directive,
ElementRef,
InjectionToken,
Expand All @@ -25,6 +26,7 @@ import {
Provider,
ViewContainerRef,
afterNextRender,
booleanAttribute,
forwardRef,
inject,
input
Expand All @@ -35,6 +37,7 @@ import {
ENTER,
ESCAPE,
KBQ_WINDOW,
KbqHideOnScrollOverlay,
KbqOption,
KbqOptionSelectionChange,
KbqResolvedPanelWidth,
Expand All @@ -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';
Expand Down Expand Up @@ -114,14 +118,15 @@ export function getKbqAutocompleteMissingPanelError(): Error {
exportAs: 'kbqAutocompleteTrigger'
})
export class KbqAutocompleteTrigger
implements AfterViewInit, ControlValueAccessor, OnDestroy, KeyboardNavigationHandler
implements AfterViewInit, ControlValueAccessor, OnDestroy, KeyboardNavigationHandler, KbqHideOnScrollOverlay
{
private elementRef = inject<ElementRef<HTMLInputElement>>(ElementRef);
private viewContainerRef = inject(ViewContainerRef);
private changeDetectorRef = inject(ChangeDetectorRef);
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);

Expand Down Expand Up @@ -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<KbqAutocomplete>(undefined!, { alias: 'kbqAutocomplete' });

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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()
});
Expand Down
117 changes: 117 additions & 0 deletions packages/components/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ENTER,
ESCAPE,
KBQ_PANEL_DEFAULT_MIN_WIDTH,
KbqHideOnScrollStrategy,
KbqLocaleServiceModule,
KbqOption,
KbqOptionSelectionChange,
Expand All @@ -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,
Expand Down Expand Up @@ -2793,3 +2795,118 @@ class AutocompleteWithCustomOnBlur {

customBlurSpy: jest.Mock<boolean, [FocusEvent]> = jest.fn().mockReturnValue(false);
}

// ---------------------------------------------------------------------------
// hide-on-scroll tests
// ---------------------------------------------------------------------------

class TestAutocompleteHideOnScrollStrategy extends KbqHideOnScrollStrategy {
readonly trigger$ = new Subject<void>();
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: `
<kbq-form-field>
<input kbqInput [kbqAutocomplete]="auto" />
</kbq-form-field>
<kbq-autocomplete #auto="kbqAutocomplete">
<kbq-option value="one">one</kbq-option>
</kbq-autocomplete>
`
})
class AutocompleteHideOnScrollDefault {
readonly trigger = viewChild.required(KbqAutocompleteTrigger);
}

@Component({
imports: [KbqInputModule, KbqAutocompleteModule],
template: `
<kbq-form-field>
<input kbqInput [kbqAutocomplete]="auto" [shouldHideOnScrollOut]="true" />
</kbq-form-field>
<kbq-autocomplete #auto="kbqAutocomplete">
<kbq-option value="one">one</kbq-option>
</kbq-autocomplete>
`
})
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();
}));
});
Loading
Loading