diff --git a/packages/overlayscrollbars-ngx/src/overlayscrollbars.component.ts b/packages/overlayscrollbars-ngx/src/overlayscrollbars.component.ts index 55a7aed..d05d79d 100644 --- a/packages/overlayscrollbars-ngx/src/overlayscrollbars.component.ts +++ b/packages/overlayscrollbars-ngx/src/overlayscrollbars.component.ts @@ -1,16 +1,120 @@ -import { Component } from '@angular/core'; +/* eslint-disable @typescript-eslint/consistent-type-imports */ +import { + Component, + Input, + Output, + EventEmitter, + ViewChild, + ElementRef, + OnDestroy, + OnChanges, + AfterViewInit, + SimpleChanges, + NgZone, +} from '@angular/core'; import { OverlayScrollbars } from 'overlayscrollbars'; -import type { OnInit } from '@angular/core'; +import type { PartialOptions, EventListeners, EventListenerMap } from 'overlayscrollbars'; -console.log(OverlayScrollbars); +const mergeEventListeners = (emits: EventListeners, events: EventListeners) => + (Object.keys(emits) as (keyof EventListeners)[]).reduce( + (obj: EventListeners, name: N) => { + const emitListener = emits[name]; + const eventListener = events[name]; + /* istanbul ignore next */ + obj[name] = [ + emitListener, + ...(Array.isArray(eventListener) ? eventListener : [eventListener]).filter(Boolean), + ]; + return obj; + }, + {} + ); @Component({ - selector: 'overlay-scrollbars', - template: `

overlayscrollbars-ngx works!

`, - styles: [], + selector: '[overlay-scrollbars]', // https://angular.io/guide/styleguide#component-selectors + exportAs: 'overlayScrollbars', + host: { 'data-overlayscrollbars': '' }, + template: `
`, }) -export class OverlayscrollbarsComponent implements OnInit { - constructor() {} +export class OverlayScrollbarsComponent implements OnDestroy, OnChanges, AfterViewInit { + private instanceRef: OverlayScrollbars | null = null; - ngOnInit(): void {} + @ViewChild('content') + private contentRef?: ElementRef; + + @Input('options') + options?: PartialOptions | false | null; + @Input('events') + events?: EventListeners | false | null; + + @Output('osInitialized') + onInitialized = new EventEmitter(); + @Output('osUpdated') + onUpdated = new EventEmitter(); + @Output('osDestroyed') + onDestroyed = new EventEmitter(); + @Output('osScroll') + onScroll = new EventEmitter(); + + constructor(private targetRef: ElementRef, private ngZone: NgZone) {} + + private mergedEvents(originalEvents: OverlayScrollbarsComponent['events']) { + return mergeEventListeners( + { + initialized: (...args) => this.onInitialized.emit(args), + updated: (...args) => this.onUpdated.emit(args), + destroyed: (...args) => this.onDestroyed.emit(args), + scroll: (...args) => this.onScroll.emit(args), + }, + originalEvents || {} + ); + } + + instance(): OverlayScrollbars | null { + return this.instanceRef; + } + + element(): HTMLElement { + return this.targetRef.nativeElement; + } + + ngAfterViewInit() { + this.ngZone.runOutsideAngular(() => { + const targetElm = this.element(); + const contentElm = this.contentRef!.nativeElement; + + /* istanbul ignore else */ + if (targetElm && contentElm) { + this.instanceRef = OverlayScrollbars( + { + target: targetElm, + elements: { + viewport: contentElm, + content: contentElm, + }, + }, + this.options || {}, + this.mergedEvents(this.events) + ); + } + }); + } + + ngOnDestroy() { + this.instanceRef?.destroy(); + } + + ngOnChanges(changes: SimpleChanges) { + const optionsChange = changes.options; + const eventsChange = changes.events; + + if (OverlayScrollbars.valid(this.instanceRef)) { + if (optionsChange) { + this.instanceRef.options(optionsChange.currentValue || {}, true); + } + if (eventsChange) { + this.instanceRef.on(this.mergedEvents(eventsChange.currentValue), true); + } + } + } } diff --git a/packages/overlayscrollbars-ngx/src/overlayscrollbars.module.ts b/packages/overlayscrollbars-ngx/src/overlayscrollbars.module.ts index 3495eb0..2b86978 100644 --- a/packages/overlayscrollbars-ngx/src/overlayscrollbars.module.ts +++ b/packages/overlayscrollbars-ngx/src/overlayscrollbars.module.ts @@ -1,9 +1,8 @@ import { NgModule } from '@angular/core'; -import { OverlayscrollbarsComponent } from './overlayscrollbars.component'; +import { OverlayScrollbarsComponent } from './overlayscrollbars.component'; @NgModule({ - declarations: [OverlayscrollbarsComponent], - imports: [], - exports: [OverlayscrollbarsComponent], + declarations: [OverlayScrollbarsComponent], + exports: [OverlayScrollbarsComponent], }) export class OverlayscrollbarsModule {} diff --git a/packages/overlayscrollbars-ngx/src/overlayscrollbars.service.ts b/packages/overlayscrollbars-ngx/src/overlayscrollbars.service.ts deleted file mode 100644 index 46b1396..0000000 --- a/packages/overlayscrollbars-ngx/src/overlayscrollbars.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root', -}) -export class OverlayscrollbarsService { - constructor() {} -} diff --git a/packages/overlayscrollbars-ngx/src/public-api.ts b/packages/overlayscrollbars-ngx/src/public-api.ts index 0ec3b1a..4657260 100644 --- a/packages/overlayscrollbars-ngx/src/public-api.ts +++ b/packages/overlayscrollbars-ngx/src/public-api.ts @@ -1,3 +1,2 @@ -export * from './overlayscrollbars.service'; export * from './overlayscrollbars.component'; export * from './overlayscrollbars.module'; diff --git a/packages/overlayscrollbars-ngx/test/overlayscrollbars.component.spec.ts b/packages/overlayscrollbars-ngx/test/overlayscrollbars.component.spec.ts index 12fa81d..0e2bf05 100644 --- a/packages/overlayscrollbars-ngx/test/overlayscrollbars.component.spec.ts +++ b/packages/overlayscrollbars-ngx/test/overlayscrollbars.component.spec.ts @@ -1,22 +1,348 @@ +import { Component, ViewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { OverlayscrollbarsComponent } from '~/overlayscrollbars.component'; +import { OverlayScrollbars } from 'overlayscrollbars'; +import { OverlayScrollbarsComponent, OverlayscrollbarsModule } from '~/public-api'; import type { ComponentFixture } from '@angular/core/testing'; +import type { EventListenerMap } from 'overlayscrollbars'; + +@Component({ + template: ` +
+ hello angular +
empty
+
hi
+
+ + + `, +}) +class Test { + children = 1; + options: OverlayScrollbarsComponent['options']; + events: OverlayScrollbarsComponent['events']; + clazz?: string[]; + style?: Record; + initialized?: (...args: any) => void; + updated?: (...args: any) => void; + destroyed?: (...args: any) => void; + scroll?: (...args: any) => void; + + @ViewChild('ref', { read: OverlayScrollbarsComponent }) + ref?: OverlayScrollbarsComponent; + + onInitialized(args: EventListenerMap['initialized']) { + this.initialized?.(args); + } + + onUpdated(args: EventListenerMap['updated']) { + this.updated?.(args); + } + + onDestroyed(args: EventListenerMap['destroyed']) { + this.destroyed?.(args); + } + + onScroll(args: EventListenerMap['scroll']) { + this.scroll?.(args); + } + + add() { + this.children += 1; + } + + remove() { + this.children -= 1; + } +} describe('OverlayscrollbarsNgxComponent', () => { - let component: OverlayscrollbarsComponent; - let fixture: ComponentFixture; + let component: OverlayScrollbarsComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [OverlayscrollbarsComponent], + ...new OverlayscrollbarsModule(), + declarations: [OverlayScrollbarsComponent, Test], }).compileComponents(); - fixture = TestBed.createComponent(OverlayscrollbarsComponent); + fixture = TestBed.createComponent(OverlayScrollbarsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('correct rendering', () => { + it('has instance', async () => { + expect(component).toBeTruthy(); + expect(component.element()).toBeDefined(); + expect(OverlayScrollbars.valid(component.instance())).toBe(true); + }); + + it('has data-overlayscrollbars attribute', async () => { + const testFixture = TestBed.createComponent(Test); + const testOsComponent = testFixture.debugElement.children[0]; + + expect(testOsComponent.attributes['data-overlayscrollbars']).toBe(''); + }); + + it('has children', async () => { + const testFixture = TestBed.createComponent(Test); + const testComponent = testFixture.nativeElement as HTMLElement; + const osElement = testComponent.firstElementChild; + const child = osElement?.querySelector('span'); + const childrenParent = child?.parentElement; + + expect(child).toBeDefined(); + expect(childrenParent).toBeDefined(); + + testFixture.detectChanges(); + + expect(osElement?.querySelector('span')?.parentElement).toBe(childrenParent); + }); + + it('handles dynamic children', async () => { + const testFixture = TestBed.createComponent(Test); + const testComponent = testFixture.nativeElement as HTMLElement; + const osElement = testComponent.firstElementChild; + testFixture.detectChanges(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const children = osElement?.querySelectorAll('section')!; + const child = children[0]; + const childrenParent = child?.parentElement; + const addBtn = testComponent.querySelector('#add') as HTMLButtonElement; + const removeBtn = testComponent.querySelector('#remove') as HTMLButtonElement; + + expect(children.length).toBe(1); + expect(child).toBeTruthy(); + expect(childrenParent).toBeTruthy(); + expect(osElement?.querySelector('#empty')).toBeFalsy(); + + addBtn.click(); + addBtn.click(); + testFixture.detectChanges(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const newChildren = osElement?.querySelectorAll('section')!; + expect(newChildren.length).toBe(3); + newChildren.forEach((currChild) => { + expect(currChild.parentElement).toBe(childrenParent); + }); + + removeBtn.click(); + removeBtn.click(); + removeBtn.click(); + testFixture.detectChanges(); + + expect(osElement?.querySelectorAll('section')?.length).toBe(0); + expect(osElement?.querySelector('#empty')).toBeTruthy(); + }); + + it('handles class change', async () => { + const testFixture = TestBed.createComponent(Test); + const testComponent = testFixture.nativeElement as HTMLElement; + const testInstance = testFixture.componentInstance; + const osElement = testComponent.firstElementChild; + + testInstance.clazz = ['overlay', 'scrollbars']; + + testFixture.detectChanges(); + + expect(osElement?.className).toBe('overlay scrollbars'); + + testInstance.clazz = ['overlay', 'scrollbars', 'angular']; + + testFixture.detectChanges(); + + expect(osElement?.className).toBe('overlay scrollbars angular'); + }); + + it('handles style change', async () => { + const testFixture = TestBed.createComponent(Test); + const testComponent = testFixture.nativeElement as HTMLElement; + const testInstance = testFixture.componentInstance; + const osElement = testComponent.firstElementChild; + + testInstance.style = { width: '22px' }; + + testFixture.detectChanges(); + + expect(osElement?.getAttribute('style')).toBe('width: 22px;'); + + testInstance.style = { height: '33px' }; + + testFixture.detectChanges(); + + expect(osElement?.getAttribute('style')).toBe('height: 33px;'); + }); + }); + + it('has correct ref', () => { + const testFixture = TestBed.createComponent(Test); + const testInstance = testFixture.componentInstance; + + testFixture.detectChanges(); + + const ref = testInstance.ref!; + + expect(testInstance.ref).toBeDefined(); + expect(typeof ref.instance).toBe('function'); + expect(typeof ref.element).toBe('function'); + expect(OverlayScrollbars.valid(ref.instance())).toBe(true); + expect(ref.element()).toBe(testFixture.nativeElement.firstElementChild); + }); + + it('sets options correctly', async () => { + const testFixture = TestBed.createComponent(Test); + const testInstance = testFixture.componentInstance; + + testInstance.options = { paddingAbsolute: true, overflow: { y: 'hidden' } }; + testFixture.detectChanges(); + + const instance = testInstance.ref!.instance()!; + + const opts = instance.options(); + expect(opts.paddingAbsolute).toBe(true); + expect(opts.overflow.y).toBe('hidden'); + + testInstance.options = { overflow: { x: 'hidden' } }; + testFixture.detectChanges(); + + const newOpts = instance.options(); + expect(newOpts.paddingAbsolute).toBe(false); //switches back to default because its not specified in the new options + expect(newOpts.overflow.x).toBe('hidden'); + expect(newOpts.overflow.y).toBe('scroll'); //switches back to default because its not specified in the new options + + testInstance.options = { overflow: { x: 'hidden', y: 'hidden' } }; + testFixture.detectChanges(); + + const newElementNewOpts = instance.options(); + expect(newElementNewOpts.paddingAbsolute).toBe(false); + expect(newElementNewOpts.overflow.x).toBe('hidden'); + expect(newElementNewOpts.overflow.y).toBe('hidden'); + + // reset options with `undefined`, `null`, `false` or `{}` + testInstance.options = undefined; + testFixture.detectChanges(); + + const clearedOpts = instance.options(); + expect(clearedOpts.paddingAbsolute).toBe(false); + expect(clearedOpts.overflow.x).toBe('scroll'); + expect(clearedOpts.overflow.y).toBe('scroll'); + + // instance didn't change + expect(instance).toBe(testInstance.ref!.instance()!); + }); + + it('sets events correctly', async () => { + const onUpdatedInitial = jasmine.createSpy(); + const onUpdated = jasmine.createSpy(); + const testFixture = TestBed.createComponent(Test); + const testInstance = testFixture.componentInstance; + + testInstance.events = { updated: onUpdatedInitial }; + testFixture.detectChanges(); + + const instance = testInstance.ref!.instance()!; + + expect(onUpdatedInitial).toHaveBeenCalledTimes(1); + + testInstance.events = { updated: onUpdated }; + testFixture.detectChanges(); + + expect(onUpdated).not.toHaveBeenCalled(); + + instance.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(1); + expect(onUpdated).toHaveBeenCalledTimes(1); + + testInstance.events = { updated: [onUpdated, onUpdatedInitial] }; + testFixture.detectChanges(); + + instance.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(2); + expect(onUpdated).toHaveBeenCalledTimes(2); + + // unregister with `[]`, `null` or `undefined` + testInstance.events = { updated: null }; + testFixture.detectChanges(); + + instance.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(2); + expect(onUpdated).toHaveBeenCalledTimes(2); + + testInstance.events = { updated: [onUpdated, onUpdatedInitial] }; + testFixture.detectChanges(); + + instance.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(3); + expect(onUpdated).toHaveBeenCalledTimes(3); + + // reset events with `undefined`, `null`, `false` or `{}` + testInstance.events = undefined; + testFixture.detectChanges(); + + instance.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(3); + expect(onUpdated).toHaveBeenCalledTimes(3); + + // instance didn't change + expect(instance).toBe(testInstance.ref!.instance()!); + }); + + it('destroys correctly', async () => { + fixture.destroy(); + expect(OverlayScrollbars.valid(component.instance())).toBe(false); + }); + + it('emits events correctly', async () => { + const testFixture = TestBed.createComponent(Test); + const testInstance = testFixture.componentInstance; + + const onInitialized = jasmine.createSpy(); + const onUpdated = jasmine.createSpy(); + const onDestroyed = jasmine.createSpy(); + const onScroll = jasmine.createSpy(); + + testInstance.initialized = onInitialized; + testInstance.updated = onUpdated; + testInstance.destroyed = onDestroyed; + testInstance.scroll = onScroll; + + testFixture.detectChanges(); + + expect(onInitialized).toHaveBeenCalledTimes(1); + expect(onInitialized).toHaveBeenCalledWith([jasmine.any(Object)]); + + expect(onUpdated).toHaveBeenCalledTimes(1); + expect(onUpdated).toHaveBeenCalledWith([jasmine.any(Object), jasmine.any(Object)]); + + expect(onDestroyed).not.toHaveBeenCalled(); + expect(onScroll).not.toHaveBeenCalled(); + + (testFixture.nativeElement as HTMLElement).querySelectorAll('*').forEach((e) => { + e.dispatchEvent(new Event('scroll')); + }); + + expect(onDestroyed).not.toHaveBeenCalled(); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onScroll).toHaveBeenCalledWith([jasmine.any(Object), jasmine.any(Event)]); + + testFixture.destroy(); + testFixture.detectChanges(); + + expect(onDestroyed).toHaveBeenCalledTimes(1); + expect(onDestroyed).toHaveBeenCalledWith([jasmine.any(Object), jasmine.any(Boolean)]); }); }); diff --git a/packages/overlayscrollbars-ngx/test/overlayscrollbars.service.spec.ts b/packages/overlayscrollbars-ngx/test/overlayscrollbars.service.spec.ts deleted file mode 100644 index 50c0fe0..0000000 --- a/packages/overlayscrollbars-ngx/test/overlayscrollbars.service.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { OverlayscrollbarsService } from '~/overlayscrollbars.service'; - -describe('OverlayscrollbarsNgxService', () => { - let service: OverlayscrollbarsService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(OverlayscrollbarsService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts b/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts index 2d03ed6..b6f6faf 100644 --- a/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts +++ b/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts @@ -32,12 +32,12 @@ export const useOverlayScrollbars = ( params?: UseOverlayScrollbarsParams ): [UseOverlayScrollbarsInitialization, UseOverlayScrollbarsInstance] => { const { options, events } = params || {}; - const osInstanceRef = useRef>(null); + const instanceRef = useRef>(null); const optionsRef = useRef(options); const eventsRef = useRef(events); useEffect(() => { - const { current: instance } = osInstanceRef; + const { current: instance } = instanceRef; optionsRef.current = options; @@ -47,7 +47,7 @@ export const useOverlayScrollbars = ( }, [options]); useEffect(() => { - const { current: instance } = osInstanceRef; + const { current: instance } = instanceRef; eventsRef.current = events; @@ -60,14 +60,14 @@ export const useOverlayScrollbars = ( () => [ (target: InitializationTarget): OverlayScrollbars => { // if already initialized return the current instance - const presentInstance = osInstanceRef.current; + const presentInstance = instanceRef.current; if (OverlayScrollbars.valid(presentInstance)) { return presentInstance; } const currOptions = optionsRef.current || {}; const currEvents = eventsRef.current || {}; - const osInstance = (osInstanceRef.current = OverlayScrollbars( + const osInstance = (instanceRef.current = OverlayScrollbars( target, currOptions, currEvents @@ -75,7 +75,7 @@ export const useOverlayScrollbars = ( return osInstance; }, - () => osInstanceRef.current, + () => instanceRef.current, ], [] ); diff --git a/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx b/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx index a10c3e5..ea0a811 100644 --- a/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx +++ b/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx @@ -203,7 +203,7 @@ describe('OverlayScrollbarsComponent', () => { expect(onUpdatedInitial).toHaveBeenCalledTimes(2); expect(onUpdated).toHaveBeenCalledTimes(2); - // unregister works with `[]`, `null` or `undefined` + // unregister with `[]`, `null` or `undefined` rerender(); instance.update(true);