diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index b3b36ed7..9bbbaabf 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: # murhafsousli +github: murhafsousli patreon: murhaf open_collective: ngx-scrollbar ko_fi: # Replace with a single Ko-fi username diff --git a/angular.json b/angular.json index 48b4b6a3..761b3798 100644 --- a/angular.json +++ b/angular.json @@ -140,7 +140,6 @@ "zone.js", "zone.js/testing" ], - "codeCoverage": true, "karmaConfig": "projects/ngx-scrollbar/karma.conf.js" } } diff --git a/package.json b/package.json index 46edcde6..d3bc735f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "watch": "ng build --watch --configuration development", "build-lib": "ng build ngx-scrollbar", "test-lib": "ng test ngx-scrollbar", - "test-lib-headless": "ng test ngx-scrollbar --watch=false --no-progress --browsers=ChromeHeadless", + "test-lib-headless": "ng test ngx-scrollbar --watch=false --no-progress --browsers=ChromeHeadless --code-coverage", "serve:ssr:ngx-scrollbar-demo": "node dist/ngx-scrollbar-demo/server/server.mjs" }, "private": true, diff --git a/projects/ngx-scrollbar/README.md b/projects/ngx-scrollbar/README.md index b5815e15..fc63aad2 100644 --- a/projects/ngx-scrollbar/README.md +++ b/projects/ngx-scrollbar/README.md @@ -7,7 +7,8 @@ [![Stackblitz](https://img.shields.io/badge/stackblitz-online-orange.svg)](https://stackblitz.com/edit/ngx-scrollbar) [![Backers on Open Collective](https://opencollective.com/ngx-scrollbar/tiers/backers/badge.svg?label=Backers&color=brightgreen)](#sponsoring-ngx-scrollbar) [![npm](https://img.shields.io/npm/v/ngx-scrollbar.svg?maxAge=2592000?style=plastic)](https://www.npmjs.com/package/ngx-scrollbar) -[![tests](https://github.com/MurhafSousli/ngx-scrollbar/workflows/tests/badge.svg)](https://github.com/MurhafSousli/ngx-scrollbar/actions?query=workflow%3Atests) +[![CI Build](https://github.com/MurhafSousli/ngx-scrollbar/workflows/tests/badge.svg)](https://github.com/MurhafSousli/ngx-scrollbar/actions?query=workflow%3Aci-build) +[![codecov](https://codecov.io/gh/MurhafSousli/ngx-scrollbar/graph/badge.svg?token=TO2idZEE1i)](https://codecov.io/gh/MurhafSousli/ngx-scrollbar) [![Downloads](https://img.shields.io/npm/dt/ngx-scrollbar.svg?maxAge=2592000?style=plastic)](https://www.npmjs.com/package/ngx-scrollbar) [![NPM Downloads](https://img.shields.io/npm/dm/ngx-scrollbar.svg)](https://www.npmjs.com/package/ngx-scrollbar) [![License](https://img.shields.io/npm/l/express.svg?maxAge=2592000)](/LICENSE) diff --git a/projects/ngx-scrollbar/package.json b/projects/ngx-scrollbar/package.json index c6d9e7ca..6d3974c6 100644 --- a/projects/ngx-scrollbar/package.json +++ b/projects/ngx-scrollbar/package.json @@ -1,6 +1,6 @@ { "name": "ngx-scrollbar", - "version": "14.0.0-beta.4", + "version": "14.0.0-beta.5", "license": "MIT", "homepage": "https://ngx-scrollbar.netlify.com/", "author": { diff --git a/projects/ngx-scrollbar/src/lib/async-detection.ts b/projects/ngx-scrollbar/src/lib/async-detection.ts index 5dfafd05..e5cb397f 100644 --- a/projects/ngx-scrollbar/src/lib/async-detection.ts +++ b/projects/ngx-scrollbar/src/lib/async-detection.ts @@ -48,12 +48,11 @@ export class AsyncDetection implements OnInit, OnDestroy { } } else { const viewportElement: HTMLElement = this.scrollbar.nativeElement.querySelector(this.scrollbar.externalViewport); - if (!viewportElement) { - this.scrollbar.viewport.initialized.set(false); - return; - } const contentWrapperElement: HTMLElement = this.scrollbar.nativeElement.querySelector(this.scrollbar.externalContentWrapper); - if (!contentWrapperElement) { + + if (!viewportElement || !contentWrapperElement) { + this.scrollbar.viewport.nativeElement = null; + this.scrollbar.viewport.contentWrapperElement = null; this.scrollbar.viewport.initialized.set(false); } } diff --git a/projects/ngx-scrollbar/src/lib/ng-scrollbar-ext.ts b/projects/ngx-scrollbar/src/lib/ng-scrollbar-ext.ts index de06dd39..b0420d49 100644 --- a/projects/ngx-scrollbar/src/lib/ng-scrollbar-ext.ts +++ b/projects/ngx-scrollbar/src/lib/ng-scrollbar-ext.ts @@ -76,63 +76,68 @@ export class NgScrollbarExt extends NgScrollbarCore implements OnInit { override ngOnInit(): void { if (!this.skipInit) { + this.detectExternalSelectors(); + } + super.ngOnInit(); + } - let viewportElement: HTMLElement; - if (this.customViewport) { - viewportElement = this.customViewport.nativeElement; - } else { - // If viewport selector was defined, query the element - if (this.externalViewport) { - viewportElement = this.nativeElement.querySelector(this.externalViewport); - } - if (!viewportElement) { - console.error(`[NgScrollbar]: Could not find the viewport element for the provided selector "${ this.externalViewport }"`); - } + private detectExternalSelectors(): void { + let viewportElement: HTMLElement; + if (this.customViewport) { + viewportElement = this.customViewport.nativeElement; + } else { + // If viewport selector was defined, query the element + if (this.externalViewport) { + viewportElement = this.nativeElement.querySelector(this.externalViewport); } - - // If an external spacer selector is provided, attempt to query for it - let spacerElement: HTMLElement; - if (this.externalSpacer) { - spacerElement = this.nativeElement.querySelector(this.externalSpacer); - if (!spacerElement) { - console.error(`[NgScrollbar]: Spacer element not found for the provided selector "${ this.externalSpacer }"`); - } + if (!viewportElement) { + console.error(`[NgScrollbar]: Could not find the viewport element for the provided selector "${ this.externalViewport }"`); + return; } + } - // If an external content wrapper selector is provided, attempt to query for it - let contentWrapperElement: HTMLElement; - if (this.externalContentWrapper && !this.skipInit) { - contentWrapperElement = this.nativeElement.querySelector(this.externalContentWrapper); - if (!contentWrapperElement) { - console.error(`[NgScrollbar]: Content wrapper element not found for the provided selector "${ this.externalContentWrapper }"`); - } + // If an external spacer selector is provided, attempt to query for it + let spacerElement: HTMLElement; + if (this.externalSpacer) { + spacerElement = this.nativeElement.querySelector(this.externalSpacer); + if (!spacerElement) { + console.error(`[NgScrollbar]: Spacer element not found for the provided selector "${ this.externalSpacer }"`); + return; } + } - // Make sure viewport element is defined to proceed - if (viewportElement) { - // If no external spacer or content wrapper is provided, create a content wrapper element - if (!this.externalSpacer && !this.externalContentWrapper) { - contentWrapperElement = this.renderer.createElement('div'); - - // Move all content of the viewport into the content wrapper - const childNodes: ChildNode[] = Array.from(viewportElement.childNodes); - childNodes.forEach((node: ChildNode) => this.renderer.appendChild(contentWrapperElement, node)); - - // Append the content wrapper to the viewport - this.renderer.appendChild(viewportElement, contentWrapperElement); - } - - // Make sure content wrapper element is defined to proceed - if (contentWrapperElement) { - // Initialize viewport - this.viewport.init(viewportElement, contentWrapperElement, spacerElement); - // Attach scrollbars - this.attachScrollbars(); - } + // If an external content wrapper selector is provided, attempt to query for it + let contentWrapperElement: HTMLElement; + if (this.externalContentWrapper && !this.skipInit) { + contentWrapperElement = this.nativeElement.querySelector(this.externalContentWrapper); + if (!contentWrapperElement) { + console.error(`[NgScrollbar]: Content wrapper element not found for the provided selector "${ this.externalContentWrapper }"`); + return; } } - super.ngOnInit(); + // Make sure viewport element is defined to proceed + if (viewportElement) { + // If no external spacer or content wrapper is provided, create a content wrapper element + if (!this.externalSpacer && !this.externalContentWrapper) { + contentWrapperElement = this.renderer.createElement('div'); + + // Move all content of the viewport into the content wrapper + const childNodes: ChildNode[] = Array.from(viewportElement.childNodes); + childNodes.forEach((node: ChildNode) => this.renderer.appendChild(contentWrapperElement, node)); + + // Append the content wrapper to the viewport + this.renderer.appendChild(viewportElement, contentWrapperElement); + } + + // Make sure content wrapper element is defined to proceed + if (contentWrapperElement) { + // Initialize viewport + this.viewport.init(viewportElement, contentWrapperElement, spacerElement); + // Attach scrollbars + this.attachScrollbars(); + } + } } attachScrollbars(): void { diff --git a/projects/ngx-scrollbar/src/lib/scrollbar/scrollbar-adapter.ts b/projects/ngx-scrollbar/src/lib/scrollbar/scrollbar-adapter.ts index d577242b..175762a9 100644 --- a/projects/ngx-scrollbar/src/lib/scrollbar/scrollbar-adapter.ts +++ b/projects/ngx-scrollbar/src/lib/scrollbar/scrollbar-adapter.ts @@ -20,8 +20,8 @@ export abstract class ScrollbarAdapter { readonly thumb: ThumbAdapter; // Track directive reference readonly track: TrackAdapter; - // Pointer events subscription - private pointerEventsSub: Subscription; + // Pointer events subscription (made public for testing purpose) + _pointerEventsSub: Subscription; // Zone reference protected readonly zone: NgZone = inject(NgZone); // Host component reference @@ -33,10 +33,10 @@ export abstract class ScrollbarAdapter { constructor() { effect((onCleanup: EffectCleanupRegisterFn) => { if (this.cmp.disableInteraction()) { - this.pointerEventsSub?.unsubscribe(); + this._pointerEventsSub?.unsubscribe(); } else { this.zone.runOutsideAngular(() => { - this.pointerEventsSub = merge( + this._pointerEventsSub = merge( // Activate scrollbar thumb drag event this.thumb.dragged, // Activate scrollbar track click event @@ -45,7 +45,7 @@ export abstract class ScrollbarAdapter { }); } - onCleanup(() => this.pointerEventsSub?.unsubscribe()); + onCleanup(() => this._pointerEventsSub?.unsubscribe()); }); } } diff --git a/projects/ngx-scrollbar/src/lib/tests/clicks.spec.ts b/projects/ngx-scrollbar/src/lib/tests/clicks.spec.ts deleted file mode 100644 index bfc777da..00000000 --- a/projects/ngx-scrollbar/src/lib/tests/clicks.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; -import { NgScrollbar } from 'ngx-scrollbar'; -import { setDimensions } from './common-test.'; -import { firstValueFrom } from 'rxjs'; - -describe('Scrollbar thumb dragging styles', () => { - let component: NgScrollbar; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NgScrollbar], - providers: [ - { provide: ComponentFixtureAutoDetect, useValue: true }, - ] - }).compileComponents(); - fixture = TestBed.createComponent(NgScrollbar); - component = fixture.componentInstance; - }); - - it('[Pointer] should set "isDragging" to true when vertical scrollbar thumb is being dragged', async () => { - setDimensions(component, { cmpWidth: 100, cmpHeight: 100, contentWidth: 200, contentHeight: 200 }); - component.ngOnInit(); - component.ngAfterViewInit(); - await firstValueFrom(component.afterInit); - - component._scrollbars.y.thumb.nativeElement.dispatchEvent(new PointerEvent('pointerdown')); - expect(component.dragging()).toBe('y'); - fixture.detectChanges(); - expect(component.nativeElement.getAttribute('dragging')).toBe('y'); - - component.viewport.nativeElement.dispatchEvent(new PointerEvent('pointerup')); - expect(component.dragging()).toBe('none'); - fixture.detectChanges(); - expect(component.nativeElement.getAttribute('dragging')).toBe('none'); - }); - - - it('[Pointer] should set "isDragging" to true when horizontal scrollbar is being dragged', async () => { - setDimensions(component, { cmpWidth: 100, cmpHeight: 100, contentWidth: 200, contentHeight: 200 }); - component.ngOnInit(); - component.ngAfterViewInit(); - await firstValueFrom(component.afterInit); - - component._scrollbars.x.thumb.nativeElement.dispatchEvent(new PointerEvent('pointerdown')); - expect(component.dragging()).toBe('x'); - fixture.detectChanges(); - expect(component.nativeElement.getAttribute('dragging')).toBe('x'); - - component.viewport.nativeElement.dispatchEvent(new PointerEvent('pointerup')); - expect(component.dragging()).toBe('none'); - fixture.detectChanges(); - expect(component.nativeElement.getAttribute('dragging')).toBe('none'); - }); -}); diff --git a/projects/ngx-scrollbar/src/lib/tests/disable-interactions.spec.ts b/projects/ngx-scrollbar/src/lib/tests/disable-interactions.spec.ts new file mode 100644 index 00000000..26450201 --- /dev/null +++ b/projects/ngx-scrollbar/src/lib/tests/disable-interactions.spec.ts @@ -0,0 +1,77 @@ +import { NgScrollbar } from 'ngx-scrollbar'; +import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { setDimensions } from './common-test.'; + +describe('disableInteraction option', () => { + let component: NgScrollbar; + let fixture: ComponentFixture; + + let interactionSubscriptionXSpy: jasmine.Spy; + let interactionSubscriptionYSpy: jasmine.Spy; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgScrollbar], + providers: [ + { provide: ComponentFixtureAutoDetect, useValue: true } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NgScrollbar); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + function interactionEnabledCases(): void { + expect(component.disableInteraction()).toBeFalse(); + expect(component.nativeElement.getAttribute('disableInteraction')).toBe('false'); + + expect(component._scrollbars.x._pointerEventsSub.closed).toBeFalse(); + expect(component._scrollbars.y._pointerEventsSub.closed).toBeFalse(); + + const componentStyles: CSSStyleDeclaration = getComputedStyle(component.nativeElement); + const trackXStyles: CSSStyleDeclaration = getComputedStyle(component._scrollbars.x.track.nativeElement); + const trackYStyles: CSSStyleDeclaration = getComputedStyle(component._scrollbars.y.track.nativeElement); + expect(componentStyles.getPropertyValue('--_scrollbar-pointer-events')).toBe('auto'); + expect(trackXStyles.pointerEvents).toBe('auto'); + expect(trackYStyles.pointerEvents).toBe('auto'); + } + + function interactionDisabledCases(): void { + expect(component.disableInteraction()).toBeTrue(); + expect(component.nativeElement.getAttribute('disableInteraction')).toBe('true'); + + expect(interactionSubscriptionXSpy).toHaveBeenCalled(); + expect(interactionSubscriptionYSpy).toHaveBeenCalled(); + + const componentStyles: CSSStyleDeclaration = getComputedStyle(component.nativeElement); + const trackXStyles: CSSStyleDeclaration = getComputedStyle(component._scrollbars.x.track.nativeElement); + const trackYStyles: CSSStyleDeclaration = getComputedStyle(component._scrollbars.y.track.nativeElement); + expect(componentStyles.getPropertyValue('--_scrollbar-pointer-events')).toBe('none'); + expect(trackXStyles.pointerEvents).toBe('none'); + expect(trackYStyles.pointerEvents).toBe('none'); + } + + it('should disable interactions for track and thumb', async () => { + setDimensions(component, { cmpHeight: 100, cmpWidth: 100, contentHeight: 300, contentWidth: 300 }); + component.ngOnInit(); + component.ngAfterViewInit(); + await firstValueFrom(component.afterInit); + + interactionSubscriptionXSpy = spyOn(component._scrollbars.x._pointerEventsSub, 'unsubscribe'); + interactionSubscriptionYSpy = spyOn(component._scrollbars.y._pointerEventsSub, 'unsubscribe'); + + interactionEnabledCases(); + + fixture.componentRef.setInput('disableInteraction', true); + fixture.detectChanges(); + + interactionDisabledCases(); + + fixture.componentRef.setInput('disableInteraction', false); + fixture.detectChanges(); + + interactionEnabledCases(); + }); +}); diff --git a/projects/ngx-scrollbar/src/lib/tests/ext-viewport-async.spec.ts b/projects/ngx-scrollbar/src/lib/tests/ext-viewport-async.spec.ts index 16b9c8ef..1efed206 100644 --- a/projects/ngx-scrollbar/src/lib/tests/ext-viewport-async.spec.ts +++ b/projects/ngx-scrollbar/src/lib/tests/ext-viewport-async.spec.ts @@ -4,6 +4,7 @@ import { Component, ViewChild, DebugElement, ElementRef } from '@angular/core'; import { NgScrollbarExt, NgScrollbarModule, AsyncDetection, } from 'ngx-scrollbar'; import { firstValueFrom } from 'rxjs'; import { Scrollbars } from '../scrollbars/scrollbars'; +import { afterTimeout } from './common-test.'; @Component({ standalone: true, @@ -33,7 +34,7 @@ class SampleLibComponent { + [asyncDetection]="asyncDetection"> ` @@ -42,6 +43,7 @@ class ViewportClassExampleComponent { externalViewport: string; externalContentWrapper: string; externalSpacer: string; + asyncDetection: '' | 'auto'; @ViewChild(NgScrollbarExt, { static: true }) scrollbar: NgScrollbarExt; @ViewChild(SampleLibComponent, { static: true }) library: SampleLibComponent; } @@ -146,4 +148,60 @@ describe('External viewport via classes [AsyncDetection]', () => { // Check if the created scrollbars component is the direct child of content wrapper element expect((scrollbarsDebugElement.nativeElement as Element).parentElement).toBe(scrollbar.viewport.contentWrapperElement); }); + + it('[asyncDetection="auto"] should detect content removal', async () => { + component.externalViewport = '.my-custom-viewport'; + component.externalContentWrapper = '.my-custom-content-wrapper'; + component.asyncDetection = 'auto'; + fixture.detectChanges(); + + scrollbar.ngOnInit(); + scrollbar.ngAfterViewInit(); + + expect(scrollbar.customViewport).toBeFalsy(); + expect(scrollbar.externalViewport).toBeTruthy(); + expect(scrollbar.externalContentWrapper).toBeTruthy(); + expect(scrollbar.externalSpacer).toBeFalsy(); + expect(scrollbar.skipInit).toBeTruthy(); + expect(scrollbar.viewport.initialized()).toBeFalsy(); + + asyncDetection.ngOnInit(); + + // Mock library render after the scrollbar has initialized + component.library.show = true; + fixture.detectChanges(); + + // Verify afterInit is called + await firstValueFrom(scrollbar.afterInit); + + const viewportElement: HTMLElement = fixture.debugElement.query(By.css(scrollbar.externalViewport))?.nativeElement; + const contentWrapperElement: HTMLElement = fixture.debugElement.query(By.css(scrollbar.externalContentWrapper))?.nativeElement; + + // Verify the viewport + expect(scrollbar.viewport.nativeElement).toBe(viewportElement); + // Verify that the content wrapper here is the content wrapper element + expect(scrollbar.viewport.contentWrapperElement).toBe(contentWrapperElement); + // Verify that the content is a direct child of the content wrapper element + expect(component.library.content.nativeElement.parentElement).toBe(contentWrapperElement); + + // Check if the scrollbars component is created + expect(scrollbar._scrollbars).toBeTruthy(); + const scrollbarsDebugElement: DebugElement = fixture.debugElement.query(By.directive(Scrollbars)); + // Verify if the created scrollbars component is the same component instance queried + expect(scrollbar._scrollbars).toBe(scrollbarsDebugElement.componentInstance); + // Check if the created scrollbars component is the direct child of content wrapper element + expect((scrollbarsDebugElement.nativeElement as Element).parentElement).toBe(scrollbar.viewport.contentWrapperElement); + + // MutationObserver has a throttleTime 100ms, need to wait before triggering a detection + await afterTimeout(100); + // Mock library removes the content (such as dropdown) + component.library.show = false; + fixture.detectChanges(); + // Wait 100ms for change to take effect + await afterTimeout(100); + + expect(scrollbar.viewport.initialized()).toBeFalse(); + expect(scrollbar.viewport.nativeElement).toBeFalsy(); + expect(scrollbar.viewport.contentWrapperElement).toBeFalsy(); + }); }); diff --git a/projects/ngx-scrollbar/src/lib/tests/ext-viewport-class.spec.ts b/projects/ngx-scrollbar/src/lib/tests/ext-viewport-class.spec.ts index 450fdf73..0702b726 100644 --- a/projects/ngx-scrollbar/src/lib/tests/ext-viewport-class.spec.ts +++ b/projects/ngx-scrollbar/src/lib/tests/ext-viewport-class.spec.ts @@ -274,4 +274,31 @@ describe('External viewport via classes', () => { expect(attachScrollbarSpy).not.toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalledOnceWith(`[NgScrollbar]: Content wrapper element not found for the provided selector "${ scrollbar.externalContentWrapper }"`) }); + + + it(`[Error handling - spacer doesn't exist] should NOT initialize viewport or attach scrollbars`, async () => { + component.externalViewport = '.my-custom-viewport'; + component.externalContentWrapper = '.my-custom-content-wrapper'; + component.externalSpacer = '.not-existing-spacer'; + component.withContentWrapper = true; + fixture.detectChanges(); + scrollbar = component.scrollbar; + + const consoleSpy: jasmine.Spy = spyOn(console, 'error'); + const viewportInitSpy: jasmine.Spy = spyOn(scrollbar.viewport, 'init'); + const attachScrollbarSpy: jasmine.Spy = spyOn(scrollbar, 'attachScrollbars'); + + scrollbar.ngOnInit(); + scrollbar.ngAfterViewInit(); + + fixture.detectChanges(); + + expect(scrollbar.customViewport).toBeFalsy(); + expect(scrollbar.skipInit).toBeFalsy(); + expect(scrollbar.viewport.initialized()).toBeFalsy(); + expect(scrollbar._scrollbars).toBeFalsy(); + expect(viewportInitSpy).not.toHaveBeenCalled(); + expect(attachScrollbarSpy).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledOnceWith(`[NgScrollbar]: Spacer element not found for the provided selector "${ scrollbar.externalSpacer }"`) + }); }); diff --git a/projects/ngx-scrollbar/src/lib/tests/ng-scrollbar.spec.ts b/projects/ngx-scrollbar/src/lib/tests/ng-scrollbar.spec.ts index 05fbb063..cd7b40a1 100644 --- a/projects/ngx-scrollbar/src/lib/tests/ng-scrollbar.spec.ts +++ b/projects/ngx-scrollbar/src/lib/tests/ng-scrollbar.spec.ts @@ -167,5 +167,22 @@ describe('NgScrollbar Component', () => { expect(component.horizontalUsed()).toBeFalsy(); expect(getComputedStyle(component.nativeElement).height).toBe('300px'); }); + + it('should forward scrollToElement function call to SmoothScrollManager service', async () => { + setDimensions(component, { contentHeight: 300, contentWidth: 300 }); + component.ngOnInit(); + component.ngAfterViewInit(); + + const smoothScrollSpy: jasmine.Spy = spyOn(component.smoothScroll, 'scrollToElement'); + + await firstValueFrom(component.afterInit); + + component.scrollToElement('.fake-child-element', { top: 100, duration: 500 }) + + expect(smoothScrollSpy).toHaveBeenCalledOnceWith(component.viewport.nativeElement, '.fake-child-element', { + top: 100, + duration: 500 + }); + }); }); diff --git a/projects/ngx-scrollbar/src/lib/tests/scroll-viewport.spec.ts b/projects/ngx-scrollbar/src/lib/tests/scroll-viewport.spec.ts index 327134b4..b0088c8e 100644 --- a/projects/ngx-scrollbar/src/lib/tests/scroll-viewport.spec.ts +++ b/projects/ngx-scrollbar/src/lib/tests/scroll-viewport.spec.ts @@ -1,73 +1,49 @@ -// import { ComponentFixture, ComponentFixtureAutoDetect, TestBed, waitForAsync } from '@angular/core/testing'; -// import { NgScrollbar, ViewportClasses } from 'ngx-scrollbar'; -// -// -// describe('Viewport Adapter', () => { -// let component: NgScrollbar; -// let fixture: ComponentFixture; -// // let directiveElement: DebugElement; -// -// beforeEach(waitForAsync(() => { -// TestBed.configureTestingModule({ -// imports: [NgScrollbar], -// providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }] -// }).compileComponents(); -// })); -// -// beforeEach(() => { -// fixture = TestBed.createComponent(NgScrollbar); -// component = fixture.componentInstance; -// }); -// -// afterEach(() => { -// fixture.destroy(); -// }); -// -// it('should have a default viewport element and its content', () => { -// expect(component.viewport).toBeDefined(); -// expect(component.viewport.contentWrapperElement).toBeDefined(); -// }); -// -// it('should have set the proper classes on viewport and scrollbars elements', () => { -// expect(component.viewport.nativeElement.classList.contains(ViewportClasses.Viewport)).toBeTrue(); -// expect(component.viewport.contentWrapperElement.classList.contains(ViewportClasses.Content)).toBeTrue(); -// }); -// -// -// it('should initialize viewport', () => { -// component.sensorDebounceTimeSetter = 100; -// component.disableSensorSetter = true; -// component.ngOnInit(); -// const spy = spyOn(component.viewport, 'init'); -// // expect(spy).toHaveBeenCalledOnceWith({ -// // sensorDebounceTime: 100, -// // disableSensor: true, -// // mouseMovePropagationDisabled: this.mousemovePropagationDisabled -// // }); -// }); -// -// // it('should create the default viewport element', () => { -// // expect(directiveElement).toBeDefined(); -// // }); -// // -// // it('should set on the custom viewport element', () => { -// // -// // }); -// // -// // it('should set the viewClass on the viewport', () => { -// // -// // }); -// -// -// -// // it('[viewClass]: viewport should add the classes to selected viewport classes', () => { -// // component.viewClass = 'test1 test2'; -// // component.viewport.nativeElement.innerHTML = '
'; -// // component.ngOnInit(); -// // component.ngAfterViewInit(); -// // fixture.detectChanges(); -// // -// // // expect(component['scrollbarY']).toBeFalsy(); -// // expect(component.viewport.nativeElement).toContain('test1'); -// // }); -// }); +import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; +import { NgScrollbar } from 'ngx-scrollbar'; +import { ViewportClasses } from '../utils/common'; +import { setDimensions } from './common-test.'; + +describe('Viewport Adapter', () => { + let component: NgScrollbar; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgScrollbar], + providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }] + }).compileComponents(); + + fixture = TestBed.createComponent(NgScrollbar); + component = fixture.componentInstance; + }); + + it('should initialize viewport and add the proper classes to viewport and content wrapper', () => { + expect(component.viewport).toBeDefined(); + + const viewportInitSpy: jasmine.Spy = spyOn(component.viewport, 'init'); + component.ngOnInit(); + + expect(viewportInitSpy).toHaveBeenCalledOnceWith(component.nativeElement, component.contentWrapper.nativeElement); + component.ngAfterViewInit(); + + expect(component.viewport.nativeElement).toBeDefined(); + expect(component.viewport.contentWrapperElement).toBeDefined(); + + expect(component.viewport.nativeElement.classList).toContain(ViewportClasses.Viewport); + expect(component.viewport.contentWrapperElement.classList).toContain(ViewportClasses.Content); + + expect(component.viewport.initialized()).toBeTrue(); + }); + + it('should instantly jump to scroll position when using scrollYTo and scrollXTo', () => { + setDimensions(component, { cmpWidth: 100, cmpHeight: 100, contentWidth: 400, contentHeight: 400 }); + component.ngOnInit(); + component.ngAfterViewInit(); + + component.viewport.scrollYTo(200); + expect(component.viewport.scrollTop).toBe(200); + + component.viewport.scrollXTo(300); + expect(component.viewport.scrollLeft).toBe(300); + }); +}); diff --git a/projects/ngx-scrollbar/src/lib/tests/scrollbar-manager.spec.ts b/projects/ngx-scrollbar/src/lib/tests/scrollbar-manager.spec.ts index da5d5246..bfb39bef 100644 --- a/projects/ngx-scrollbar/src/lib/tests/scrollbar-manager.spec.ts +++ b/projects/ngx-scrollbar/src/lib/tests/scrollbar-manager.spec.ts @@ -1,30 +1,121 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; +import { PLATFORM_ID } from '@angular/core'; import { getRtlScrollAxisType } from '@angular/cdk/platform'; import { provideScrollbarPolyfill } from 'ngx-scrollbar'; import { ScrollbarManager } from '../utils/scrollbar-manager'; describe('ScrollbarManager Service', () => { - let service: ScrollbarManager; + let scrollbarManager: ScrollbarManager; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ providers: [ ScrollbarManager, - provideScrollbarPolyfill('xyz') + { provide: PLATFORM_ID, useValue: 'browser' }, ] }); + scrollbarManager = TestBed.inject(ScrollbarManager); + }); + + it('should be created and initPolyfill', () => { + expect(scrollbarManager).toBeDefined(); + }); + + it('should not load rtlScrollAxisType set', () => { + expect(scrollbarManager.rtlScrollAxisType).toBe(getRtlScrollAxisType()); + }); + + it('should load polyfill script and set scrollTimelinePolyfill', async () => { + // In Chrome, it will not be possible to test if the polyfill will set window.ScrollTimeline + // Therefore, we will only test the script functionality + const scriptMock: jasmine.SpyObj = scrollbarManager.document.createElement('script') as jasmine.SpyObj; + spyOn(scrollbarManager.document, 'createElement').and.returnValue(scriptMock); + + const scrollTimelineFunction: jasmine.Spy = jasmine.createSpy('scrollTimelineFunction'); + spyOnProperty(scrollbarManager.document, 'defaultView').and.returnValue({ ScrollTimeline: scrollTimelineFunction } as any); + + await scrollbarManager.initPolyfill(); + + expect(scrollbarManager.document.createElement).toHaveBeenCalledWith('script'); + expect(scriptMock.src).toBe(scrollbarManager._polyfillUrl); + expect(scrollbarManager.window['ScrollTimeline']).toBeTruthy(); + expect(scrollbarManager.scrollTimelinePolyfill()).toBe(scrollbarManager.window['ScrollTimeline']); + }); - service = TestBed.inject(ScrollbarManager); - })); - it('should be created', () => { - expect(service).toBeDefined(); + it('should log an error if an error occurs while loading the ScrollTimeline script', async () => { + const consoleErrorSpy: jasmine.Spy = spyOn(console, 'error').and.callThrough(); // Use "and.callThrough()" to allow the actual console.error to be called + const errorMessage: string = 'mock_error_message'; + spyOn(document, 'createElement').and.throwError(new Error(errorMessage)); // Throw an Error object + + await scrollbarManager.initPolyfill(); + + expect(consoleErrorSpy).toHaveBeenCalledWith('[NgScrollbar]: Error loading ScrollTimeline script:', jasmine.any(Error)); // Adjust expectation to match the Error object }); +}); - // TODO: Test loading the polyfill script +describe('Override ScrollTimeline polyfill', () => { - it('should have rtlScrollAxisType set', () => { - expect(service.rtlScrollAxisType).toBe(getRtlScrollAxisType()); + let scrollbarManager: ScrollbarManager; + const inlineCode: string = `console.log('Fake script');`; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ScrollbarManager, + provideScrollbarPolyfill(`data:text/javascript;charset=utf-8,${ encodeURIComponent(inlineCode) }`) + ] + }); + scrollbarManager = TestBed.inject(ScrollbarManager); + }); + + it('should log an error if ScrollTimeline is not attached to the window object', async () => { + const scrollTimelineBackup = window['ScrollTimeline']; + // In chrome ScrollTimeline is supported, we need to remove it + delete window['ScrollTimeline']; + + const consoleErrorSpy: jasmine.Spy = spyOn(console, 'error'); + + await scrollbarManager.initPolyfill(); + + expect(scrollbarManager.scrollTimelinePolyfill()).toBeFalsy(); + expect(consoleErrorSpy).toHaveBeenCalledWith('[NgScrollbar]: ScrollTimeline is not attached to the window object.'); + + // Restore the ScrollTimeline function + window['ScrollTimeline'] = scrollTimelineBackup; + }); +}); + + +describe('ScrollbarManager: call initPolyfill in constructor based on browser', () => { + let initPolyfillSpy: jasmine.Spy; + + beforeEach(() => { + initPolyfillSpy = spyOn(ScrollbarManager.prototype, 'initPolyfill'); + }); + + it('should call initPolyfill if conditions are met (Firefox/Safari)', () => { + // Mock Firefox/Safari environment + const scrollTimelineBackup = window['ScrollTimeline']; + // In chrome ScrollTimeline is supported, we need to remove it + delete window['ScrollTimeline']; + + TestBed.runInInjectionContext(() => { + const service: ScrollbarManager = new ScrollbarManager(); + expect(initPolyfillSpy).toHaveBeenCalled(); + }); + + // Restore the ScrollTimeline function + window['ScrollTimeline'] = scrollTimelineBackup; + }); + + + it('should call initPolyfill if conditions are not met (Chrome)', () => { + // Mock Chrome environment + TestBed.runInInjectionContext(() => { + const service: ScrollbarManager = new ScrollbarManager(); + expect(initPolyfillSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/projects/ngx-scrollbar/src/lib/tests/thumb-dragging.spec.ts b/projects/ngx-scrollbar/src/lib/tests/thumb-dragging.spec.ts new file mode 100644 index 00000000..13ec3489 --- /dev/null +++ b/projects/ngx-scrollbar/src/lib/tests/thumb-dragging.spec.ts @@ -0,0 +1,119 @@ +import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { Directionality } from '@angular/cdk/bidi'; +import { NgScrollbar, ScrollbarAppearance } from 'ngx-scrollbar'; +import { BehaviorSubject, firstValueFrom } from 'rxjs'; +import { setDimensions } from './common-test.'; + +describe('Scrollbar thumb dragging styles', () => { + let component: NgScrollbar; + let fixture: ComponentFixture; + + const directionalityMock = { + value: 'ltr', + change: new BehaviorSubject('ltr'), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgScrollbar], + providers: [ + { provide: ComponentFixtureAutoDetect, useValue: true }, + { provide: Directionality, useValue: directionalityMock } + ] + }).compileComponents(); + + directionalityMock.value = 'ltr'; + + fixture = TestBed.createComponent(NgScrollbar); + component = fixture.componentInstance; + + component.appearance = signal('compact') as any; + component.nativeElement.style.setProperty('--scrollbar-offset', '0'); + component.nativeElement.style.setProperty('--scrollbar-thumb-color', 'red'); + }); + + it('should set "isDragging" to true and scroll accordingly when vertical scrollbar thumb is being dragged', async () => { + setDimensions(component, { cmpWidth: 100, cmpHeight: 100, contentWidth: 100, contentHeight: 400 }); + component.ngOnInit(); + component.ngAfterViewInit(); + await firstValueFrom(component.afterInit); + + component._scrollbars.y.thumb.nativeElement.dispatchEvent(new PointerEvent('pointerdown')); + + // Verify dragging signal and attribute is set to 'Y' + expect(component.dragging()).toBe('y'); + fixture.detectChanges(); + expect(component.nativeElement.getAttribute('dragging')).toBe('y'); + + // Drag by 240px (thumb size will be 25px) + document.dispatchEvent(new PointerEvent('pointermove', { clientY: 240 })); + expect(component.viewport.scrollTop).toBe(300); + + // Drag back to 0 + document.dispatchEvent(new PointerEvent('pointermove', { clientY: 0 })); + expect(component.viewport.scrollTop).toBe(0); + + document.dispatchEvent(new PointerEvent('pointerup')); + expect(component.dragging()).toBe('none'); + fixture.detectChanges(); + expect(component.nativeElement.getAttribute('dragging')).toBe('none'); + }); + + + it('should set "isDragging" to true and scroll accordingly when horizontal scrollbar is being dragged', async () => { + setDimensions(component, { cmpWidth: 100, cmpHeight: 100, contentWidth: 400, contentHeight: 100 }); + component.ngOnInit(); + component.ngAfterViewInit(); + await firstValueFrom(component.afterInit); + + component._scrollbars.x.thumb.nativeElement.dispatchEvent(new PointerEvent('pointerdown')); + + // Verify dragging signal and attribute is set to 'X' + expect(component.dragging()).toBe('x'); + fixture.detectChanges(); + expect(component.nativeElement.getAttribute('dragging')).toBe('x'); + + // Drag by 240px (thumb size will be 25px) + document.dispatchEvent(new PointerEvent('pointermove', { clientX: 240 })); + expect(component.viewport.scrollLeft).toBe(300); + + // Drag back to 0 + document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 })); + expect(component.viewport.scrollLeft).toBe(0); + + component.viewport.nativeElement.dispatchEvent(new PointerEvent('pointerup')); + expect(component.dragging()).toBe('none'); + fixture.detectChanges(); + expect(component.nativeElement.getAttribute('dragging')).toBe('none'); + }); + + it('[RTL] should set "isDragging" to true and scroll accordingly when horizontal scrollbar is being dragged', async () => { + directionalityMock.value = 'rtl'; + + setDimensions(component, { cmpWidth: 100, cmpHeight: 100, contentWidth: 400, contentHeight: 100 }); + component.ngOnInit(); + component.ngAfterViewInit(); + await firstValueFrom(component.afterInit); + + component._scrollbars.x.thumb.nativeElement.dispatchEvent(new PointerEvent('pointerdown')); + + // Verify dragging signal and attribute is set to 'X' + expect(component.dragging()).toBe('x'); + fixture.detectChanges(); + expect(component.nativeElement.getAttribute('dragging')).toBe('x'); + + // Drag by 240px (thumb size will be 25px) + document.dispatchEvent(new PointerEvent('pointermove', { clientX: -240 })); + expect(component.viewport.scrollLeft).toBe(-300); + + // Drag back to 0 + document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 })); + expect(component.viewport.scrollLeft).toBe(0); + + component.viewport.nativeElement.dispatchEvent(new PointerEvent('pointerup')); + expect(component.dragging()).toBe('none'); + fixture.detectChanges(); + expect(component.nativeElement.getAttribute('dragging')).toBe('none'); + }); +}); diff --git a/projects/ngx-scrollbar/src/lib/thumb/thumb-adapter.ts b/projects/ngx-scrollbar/src/lib/thumb/thumb-adapter.ts index cdc8edd8..cbb19188 100644 --- a/projects/ngx-scrollbar/src/lib/thumb/thumb-adapter.ts +++ b/projects/ngx-scrollbar/src/lib/thumb/thumb-adapter.ts @@ -21,16 +21,16 @@ export abstract class ThumbAdapter { protected animation: Animation; // Returns either 'clientX' or 'clientY' coordinate of the pointer relative to the viewport - protected abstract get clientProperty(): string; + protected abstract readonly clientProperty: 'clientX' | 'clientY'; - abstract get dragStartOffset(): number; + protected abstract get dragStartOffset(): number; abstract get offset(): number; // Returns thumb size abstract get size(): number; - abstract get viewportScrollMax(): number; + protected abstract get viewportScrollMax(): number; protected abstract axis: 'x' | 'y'; diff --git a/projects/ngx-scrollbar/src/lib/thumb/thumb.ts b/projects/ngx-scrollbar/src/lib/thumb/thumb.ts index f5e28dc6..344b6afa 100644 --- a/projects/ngx-scrollbar/src/lib/thumb/thumb.ts +++ b/projects/ngx-scrollbar/src/lib/thumb/thumb.ts @@ -9,11 +9,9 @@ import { ThumbAdapter } from './thumb-adapter'; }) export class ThumbXDirective extends ThumbAdapter { - protected get clientProperty(): string { - return 'clientX'; - } + protected readonly clientProperty: 'clientX' | 'clientY' = 'clientX'; - get viewportScrollMax(): number { + protected get viewportScrollMax(): number { return this.cmp.viewport.scrollMaxX; } @@ -21,14 +19,14 @@ export class ThumbXDirective extends ThumbAdapter { return this.clientRect.left; } - get dragStartOffset(): number { - return this.offset + this.document.defaultView.scrollX; - } - get size(): number { return this.nativeElement.clientWidth; } + protected get dragStartOffset(): number { + return this.offset + this.document.defaultView.scrollX; + } + protected axis: 'x' | 'y' = 'x'; protected handleDrag: (position: number, scrollMax: number) => number; @@ -61,11 +59,9 @@ export class ThumbXDirective extends ThumbAdapter { }) export class ThumbYDirective extends ThumbAdapter { - protected get clientProperty(): string { - return 'clientY'; - } + protected readonly clientProperty: 'clientX' | 'clientY' = 'clientY'; - get viewportScrollMax(): number { + protected get viewportScrollMax(): number { return this.cmp.viewport.scrollMaxY; } @@ -73,15 +69,15 @@ export class ThumbYDirective extends ThumbAdapter { return this.clientRect.top; } - get dragStartOffset(): number { - return this.offset + this.document.defaultView.scrollY; - } - get size(): number { return this.nativeElement.clientHeight; } - protected axis: 'x' | 'y' = 'y'; + protected get dragStartOffset(): number { + return this.offset + this.document.defaultView.scrollY; + } + + protected readonly axis: 'x' | 'y' = 'y'; protected handleDrag = (position: number): number => position; diff --git a/projects/ngx-scrollbar/src/lib/track/track-adapter.ts b/projects/ngx-scrollbar/src/lib/track/track-adapter.ts index 6ff79eaa..c3e8bf20 100644 --- a/projects/ngx-scrollbar/src/lib/track/track-adapter.ts +++ b/projects/ngx-scrollbar/src/lib/track/track-adapter.ts @@ -52,9 +52,9 @@ export abstract class TrackAdapter { private scrollMax: number; // Abstract properties and methods to be implemented by subclasses - abstract readonly clientProperty: string; + protected abstract readonly clientProperty: 'clientX' | 'clientY'; - abstract readonly cssLengthProperty: string; + protected abstract readonly cssLengthProperty: string; protected abstract get viewportScrollSize(): number; diff --git a/projects/ngx-scrollbar/src/lib/track/track.ts b/projects/ngx-scrollbar/src/lib/track/track.ts index 11556623..f3c0aaae 100644 --- a/projects/ngx-scrollbar/src/lib/track/track.ts +++ b/projects/ngx-scrollbar/src/lib/track/track.ts @@ -10,9 +10,9 @@ import { TrackAdapter } from './track-adapter'; }) export class TrackXDirective extends TrackAdapter { - readonly cssLengthProperty: string = '--track-x-length'; + protected readonly cssLengthProperty: string = '--track-x-length'; - readonly clientProperty: string = 'clientX'; + protected readonly clientProperty: 'clientX' | 'clientY' = 'clientX'; get offset(): number { return this.clientRect.left; @@ -53,9 +53,9 @@ export class TrackXDirective extends TrackAdapter { }) export class TrackYDirective extends TrackAdapter { - readonly cssLengthProperty: string = '--track-y-length'; + protected readonly cssLengthProperty: string = '--track-y-length'; - readonly clientProperty: string = 'clientY'; + protected readonly clientProperty: 'clientX' | 'clientY' = 'clientY'; get offset(): number { return this.clientRect.top; diff --git a/projects/ngx-scrollbar/src/lib/utils/scrollbar-manager.ts b/projects/ngx-scrollbar/src/lib/utils/scrollbar-manager.ts index 8535a70e..be60092a 100644 --- a/projects/ngx-scrollbar/src/lib/utils/scrollbar-manager.ts +++ b/projects/ngx-scrollbar/src/lib/utils/scrollbar-manager.ts @@ -10,7 +10,7 @@ export class ScrollbarManager { private readonly isBrowser: boolean = isPlatformBrowser(inject(PLATFORM_ID)); - private readonly polyfillUrl: string = inject(NG_SCROLLBAR_POLYFILL, { optional: true }) || scrollTimelinePolyfillUrl; + readonly _polyfillUrl: string = inject(NG_SCROLLBAR_POLYFILL, { optional: true }) || scrollTimelinePolyfillUrl; readonly document: Document = inject(DOCUMENT); @@ -24,7 +24,7 @@ export class ScrollbarManager { readonly scrollTimelinePolyfill: WritableSignal = signal(null); constructor() { - if (this.isBrowser && !this.window['ScrollTimeline'] && !CSS.supports('animation-timeline', 'scroll()')) { + if (this.isBrowser && (!this.window['ScrollTimeline'] || !CSS.supports('animation-timeline', 'scroll()'))) { this.initPolyfill(); } } @@ -33,7 +33,7 @@ export class ScrollbarManager { try { // Create a script element const script: HTMLScriptElement = this.document.createElement('script'); - script.src = this.polyfillUrl; + script.src = this._polyfillUrl; // Wait for the script to load await new Promise((resolve, reject) => { @@ -44,12 +44,12 @@ export class ScrollbarManager { // Once loaded, access and execute the function attached to the window object if (this.window['ScrollTimeline']) { - this.scrollTimelinePolyfill.set(window['ScrollTimeline']); + this.scrollTimelinePolyfill.set(this.window['ScrollTimeline']); } else { - console.error('ScrollTimeline is not attached to the window object.'); + console.error('[NgScrollbar]: ScrollTimeline is not attached to the window object.'); } } catch (error) { - console.error('Error loading ScrollTimeline script:', error); + console.error('[NgScrollbar]: Error loading ScrollTimeline script:', error); } } } diff --git a/projects/ngx-scrollbar/src/lib/viewport/viewport-adapter.ts b/projects/ngx-scrollbar/src/lib/viewport/viewport-adapter.ts index 477539b2..4cc4f465 100644 --- a/projects/ngx-scrollbar/src/lib/viewport/viewport-adapter.ts +++ b/projects/ngx-scrollbar/src/lib/viewport/viewport-adapter.ts @@ -3,6 +3,9 @@ import { ViewportClasses } from '../utils/common'; export class ViewportAdapter { + /** + * Viewport native element + */ nativeElement: HTMLElement; /** * The element that wraps the content inside the viewport,