diff --git a/docs/structure.ts b/docs/structure.ts index 9fde19a8e6..6d1ca13be9 100644 --- a/docs/structure.ts +++ b/docs/structure.ts @@ -366,6 +366,38 @@ export const structure = [ }, ], }, + { + type: 'page', + name: 'LayoutScrollService', + children: [ + { + type: 'block', + block: 'component', + source: 'NbLayoutScrollService', + }, + { + type: 'block', + block: 'component', + source: 'NbScrollPosition', + }, + ], + }, + { + type: 'page', + name: 'LayoutRulerService', + children: [ + { + type: 'block', + block: 'component', + source: 'NbLayoutRulerService', + }, + { + type: 'block', + block: 'component', + source: 'NbLayoutDimensions', + }, + ], + }, ], }, { diff --git a/src/framework/theme/components/layout/layout.component.ts b/src/framework/theme/components/layout/layout.component.ts index 37dc478898..5a097fef53 100644 --- a/src/framework/theme/components/layout/layout.component.ts +++ b/src/framework/theme/components/layout/layout.component.ts @@ -17,6 +17,8 @@ import { convertToBoolProperty } from '../helpers'; import { NbThemeService } from '../../services/theme.service'; import { NbSpinnerService } from '../../services/spinner.service'; import { NbLayoutDirectionService } from '../../services/direction.service'; +import { NbScrollPosition, NbLayoutScrollService } from '../../services/scroll.service'; +import { NbLayoutDimensions, NbLayoutRulerService } from '../../services/ruler.service'; import { NB_WINDOW, NB_DOCUMENT } from '../../theme.options'; /** @@ -248,7 +250,7 @@ export class NbLayoutFooterComponent { styleUrls: ['./layout.component.scss'], template: ` -
+
@@ -332,6 +334,8 @@ export class NbLayoutComponent implements AfterViewInit, OnInit, OnDestroy { @Inject(NB_DOCUMENT) protected document, @Inject(PLATFORM_ID) protected platformId: Object, protected layoutDirectionService: NbLayoutDirectionService, + protected scrollService: NbLayoutScrollService, + protected rulerService: NbLayoutRulerService, ) { this.themeService.onThemeChange() @@ -371,6 +375,24 @@ export class NbLayoutComponent implements AfterViewInit, OnInit, OnDestroy { })); this.spinnerService.load(); + this.rulerService.onGetDimensions() + .pipe( + takeWhile(() => this.alive), + ) + .subscribe(({ listener }) => { + listener.next(this.getDimensions()); + listener.complete(); + }); + + this.scrollService.onGetPosition() + .pipe( + takeWhile(() => this.alive), + ) + .subscribe(({ listener }) => { + listener.next(this.getScrollPosition()); + listener.complete(); + }); + if (isPlatformBrowser(this.platformId)) { // trigger first time so that after the change we have the initial value this.themeService.changeWindowWidth(this.window.innerWidth); @@ -403,6 +425,10 @@ export class NbLayoutComponent implements AfterViewInit, OnInit, OnDestroy { this.renderer.setProperty(this.document, 'dir', direction); }); + this.scrollService.onManualScroll() + .pipe(takeWhile(() => this.alive)) + .subscribe(({ x, y }: NbScrollPosition) => this.scroll(x, y)); + this.afterViewInit$.next(true); } @@ -415,11 +441,73 @@ export class NbLayoutComponent implements AfterViewInit, OnInit, OnDestroy { this.alive = false; } + @HostListener('window:scroll', ['$event']) + onScroll($event) { + this.scrollService.fireScrollChange($event); + } + @HostListener('window:resize', ['$event']) onResize(event) { this.themeService.changeWindowWidth(event.target.innerWidth); } + /** + * Returns scroll and client height/width + * + * Depending on the current scroll mode (`withScroll=true`) returns sizes from the body element + * or from the `.scrollable-container` + * @returns {NbLayoutDimensions} + */ + getDimensions(): NbLayoutDimensions { + let clientWidth, clientHeight, scrollWidth, scrollHeight = 0; + if (this.withScrollValue) { + const container = this.scrollableContainerRef.nativeElement; + clientWidth = container.clientWidth; + clientHeight = container.clientHeight; + scrollWidth = container.scrollWidth; + scrollHeight = container.scrollHeight; + } else { + const { documentElement, body } = this.document; + clientWidth = documentElement.clientWidth || body.clientWidth; + clientHeight = documentElement.clientHeight || body.clientHeight; + scrollWidth = documentElement.scrollWidth || body.scrollWidth; + scrollHeight = documentElement.scrollHeight || body.scrollHeight; + } + + return { + clientWidth, + clientHeight, + scrollWidth, + scrollHeight, + }; + } + + /** + * Returns scroll position of current scroll container. + * + * If `withScroll` = true, returns scroll position of the `.scrollable-container` element, + * otherwise - of the scrollable element of the window (which may be different depending of a browser) + * + * @returns {NbScrollPosition} + */ + getScrollPosition(): NbScrollPosition { + if (this.withScrollValue) { + const container = this.scrollableContainerRef.nativeElement; + return { x: container.scrollLeft, y: container.scrollTop }; + } + + const documentRect = this.document.documentElement.getBoundingClientRect(); + + const x = -documentRect.left || this.document.body.scrollLeft || this.window.scrollX || + this.document.documentElement.scrollLeft || 0; + + const y = -documentRect.top || this.document.body.scrollTop || this.window.scrollY || + this.document.documentElement.scrollTop || 0; + + + return { x, y }; + } + private initScrollTop() { this.router.events .pipe( @@ -427,7 +515,24 @@ export class NbLayoutComponent implements AfterViewInit, OnInit, OnDestroy { filter(event => event instanceof NavigationEnd), ) .subscribe(() => { - this.scrollableContainerRef.nativeElement.scrollTo && this.scrollableContainerRef.nativeElement.scrollTo(0, 0); + this.scroll(0, 0); }); } + + private scroll(x: number, y: number) { + if (!isPlatformBrowser(this.platformId)) { + return; + } + if (this.withScrollValue) { + const scrollable = this.scrollableContainerRef.nativeElement; + if (scrollable.scrollTo) { + scrollable.scrollTo(x, y); + } else { + scrollable.scrollLeft = x; + scrollable.scrollTop = y; + } + } else { + this.window.scrollTo(x, y); + } + } } diff --git a/src/framework/theme/index.ts b/src/framework/theme/index.ts index a630261dd1..f06fc08eae 100644 --- a/src/framework/theme/index.ts +++ b/src/framework/theme/index.ts @@ -11,6 +11,8 @@ export * from './services/spinner.service'; export * from './services/breakpoints.service'; export * from './services/color.helper'; export * from './services/direction.service'; +export * from './services/scroll.service'; +export * from './services/ruler.service'; export * from './components/card/card.module'; export * from './components/layout/layout.module'; export * from './components/menu/menu.module'; diff --git a/src/framework/theme/services/ruler.service.spec.ts b/src/framework/theme/services/ruler.service.spec.ts new file mode 100644 index 0000000000..99f1cc3a01 --- /dev/null +++ b/src/framework/theme/services/ruler.service.spec.ts @@ -0,0 +1,133 @@ +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { APP_BASE_HREF } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { TestBed, ComponentFixture, fakeAsync, tick, async, inject } from '@angular/core/testing'; +import { NbLayoutRulerService, NbLayoutDimensions } from './ruler.service'; +import { NbLayoutModule } from '../components/layout/layout.module'; +import { NbThemeService } from './theme.service'; +import { NbThemeModule } from '../theme.module'; +import { NB_DOCUMENT } from '../theme.options'; + +let currentDocument; +let fixture: ComponentFixture; +let componentInstance: RulerTestComponent; +let rulerService: NbLayoutRulerService; + +@Component({ + template: ` + + +
+
+
+ `, +}) +class RulerTestComponent { + + @ViewChild('resize', { read: ElementRef }) private resizeElement: ElementRef; + @ViewChild('layout', { read: ElementRef }) private layout: ElementRef; + localScroll = false; + + setSize(width: string, height: string) { + this.resizeElement.nativeElement.style.width = width; + this.resizeElement.nativeElement.style.height = height; + } + + useLocalScroll() { + this.localScroll = true; + } + + useGlobalScroll() { + this.localScroll = false; + } + + getScrollableElement() { + return this.layout.nativeElement.querySelector('.scrollable-container'); + } +} + +// This is rather a smoke test +describe('NbLayoutRulerService', () => { + + beforeEach(() => { + fixture = TestBed.configureTestingModule({ + imports: [ RouterModule.forRoot([]), NbThemeModule.forRoot({ name: 'default' }), NbLayoutModule ], + providers: [ NbLayoutRulerService, NbThemeService, { provide: APP_BASE_HREF, useValue: '/' } ], + declarations: [ RulerTestComponent ], + }) + .createComponent(RulerTestComponent); + + componentInstance = fixture.componentInstance; + + fixture.detectChanges(); + }); + + beforeEach(async(inject( + [NbLayoutRulerService, NB_DOCUMENT], + (_rulerService, _document) => { + rulerService = _rulerService; + currentDocument = _document; + }, + ))); + + afterEach(fakeAsync(() => { + fixture.destroy(); + tick(); + fixture.nativeElement.remove(); + })); + + it('should get dimensions from document', (done) => { + fixture.detectChanges(); + rulerService.getDimensions() + .subscribe((size: NbLayoutDimensions) => { + expect(size.clientHeight).toEqual(currentDocument.documentElement.clientHeight); + expect(size.clientWidth).toEqual(currentDocument.documentElement.clientWidth); + expect(size.scrollHeight).toEqual(currentDocument.documentElement.scrollHeight); + expect(size.scrollWidth).toEqual(currentDocument.documentElement.scrollWidth); + done(); + }) + }); + + it('should get dimensions from document when scrolls', (done) => { + componentInstance.setSize('10000px', '10000px'); + fixture.detectChanges(); + rulerService.getDimensions() + .subscribe((size: NbLayoutDimensions) => { + expect(size.clientHeight).toEqual(currentDocument.documentElement.clientHeight); + expect(size.clientWidth).toEqual(currentDocument.documentElement.clientWidth); + expect(size.scrollHeight).toEqual(currentDocument.documentElement.scrollHeight); + expect(size.scrollWidth).toEqual(currentDocument.documentElement.scrollWidth); + done(); + }) + }); + + it('should get dimensions from scrollable', (done) => { + componentInstance.useLocalScroll(); + fixture.detectChanges(); + const scrollable = componentInstance.getScrollableElement(); + rulerService.getDimensions() + .subscribe((size: NbLayoutDimensions) => { + expect(size.clientHeight).toEqual(scrollable.clientHeight); + expect(size.clientWidth).toEqual(scrollable.clientWidth); + expect(size.scrollHeight).toEqual(scrollable.scrollHeight); + expect(size.scrollWidth).toEqual(scrollable.scrollWidth); + done(); + }) + }); + + it('should get dimensions from scrollable when scrolls', (done) => { + componentInstance.useLocalScroll(); + componentInstance.setSize('10000px', '10000px'); + fixture.detectChanges(); + const scrollable = componentInstance.getScrollableElement(); + rulerService.getDimensions() + .subscribe((size: NbLayoutDimensions) => { + expect(size.clientHeight).toEqual(scrollable.clientHeight); + expect(size.clientWidth).toEqual(scrollable.clientWidth); + expect(size.scrollHeight).toEqual(scrollable.scrollHeight); + expect(size.scrollWidth).toEqual(scrollable.scrollWidth); + done(); + }) + }); + +}); diff --git a/src/framework/theme/services/ruler.service.ts b/src/framework/theme/services/ruler.service.ts new file mode 100644 index 0000000000..3ed4adbaba --- /dev/null +++ b/src/framework/theme/services/ruler.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; +import { Observable, ReplaySubject, Subject } from 'rxjs'; + +/** + * Layout dimensions type + */ +export interface NbLayoutDimensions { + + /** + * clientWidth + * @type {number} + */ + clientWidth: number; + + /** + * clientHeight + * @type {number} + */ + clientHeight: number; + + /** + * scrollWidth + * @type {number} + */ + scrollWidth: number; + + /** + * scrollHeight + * @type {number} + */ + scrollHeight: number; +} + +/** + * Simple helper service to return Layout dimensions + * Depending of current Layout scroll mode (default or `withScroll` when scroll is moved to an element + * inside of the layout) corresponding dimensions will be returns - of `documentElement` in first case and + * `.scrollable-container` in the second. + */ +@Injectable() +export class NbLayoutRulerService { + + private contentDimensionsReq$ = new Subject(); + + /** + * Content dimensions + * @returns {Observable} + */ + getDimensions(): Observable { + const listener = new ReplaySubject(); + this.contentDimensionsReq$.next({ listener }); + + return listener.asObservable(); + } + + /** + * @private + * @returns {Subject} + */ + onGetDimensions(): Subject { + return this.contentDimensionsReq$; + } +} diff --git a/src/framework/theme/services/scroll.service.spec.ts b/src/framework/theme/services/scroll.service.spec.ts new file mode 100644 index 0000000000..3f420b0150 --- /dev/null +++ b/src/framework/theme/services/scroll.service.spec.ts @@ -0,0 +1,169 @@ +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { APP_BASE_HREF } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { TestBed, ComponentFixture, fakeAsync, tick, async, inject } from '@angular/core/testing'; +import { NbLayoutScrollService, NbScrollPosition } from './scroll.service'; +import { NbLayoutModule } from '../components/layout/layout.module'; +import { NbThemeService } from './theme.service'; +import { NbThemeModule } from '../theme.module'; +import { NB_WINDOW } from '../theme.options'; + +let currentWindow; +let fixture: ComponentFixture; +let componentInstance: ScrollTestComponent; +let scrollService: NbLayoutScrollService; + +@Component({ + template: ` + + +
+
+
+ `, + styles: [` + ::ng-deep nb-layout.with-scroll .scrollable-container { + overflow: auto; + height: 100vh; + } + `], +}) +class ScrollTestComponent { + + @ViewChild('resize', { read: ElementRef }) private resizeElement: ElementRef; + @ViewChild('layout', { read: ElementRef }) private layout: ElementRef; + localScroll = false; + + setSize(width: string, height: string) { + this.resizeElement.nativeElement.style.width = width; + this.resizeElement.nativeElement.style.height = height; + } + + useLocalScroll() { + this.localScroll = true; + } + + useGlobalScroll() { + this.localScroll = false; + } + + getScrollableElement() { + return this.layout.nativeElement.querySelector('.scrollable-container'); + } +} + +describe('NbScrollService', () => { + + beforeEach(() => { + fixture = TestBed.configureTestingModule({ + imports: [ RouterModule.forRoot([]), NbThemeModule.forRoot({ name: 'default' }), NbLayoutModule ], + providers: [ NbLayoutScrollService, NbThemeService, { provide: APP_BASE_HREF, useValue: '/' } ], + declarations: [ ScrollTestComponent ], + }) + .createComponent(ScrollTestComponent); + + componentInstance = fixture.componentInstance; + + fixture.detectChanges(); + }); + + beforeEach(async(inject( + [NbLayoutScrollService, NB_WINDOW], + (_scrollService, _window) => { + scrollService = _scrollService; + currentWindow = _window; + }, + ))); + + afterEach(fakeAsync(() => { + fixture.destroy(); + tick(); + fixture.nativeElement.remove(); + })); + + it('should get initial scroll position', (done) => { + fixture.detectChanges(); + scrollService.getPosition() + .subscribe((pos: NbScrollPosition) => { + expect(pos.x).toEqual(0); + expect(pos.y).toEqual(0); + done(); + }) + }); + + it('should get initial scroll position as nothing to scroll', (done) => { + currentWindow.scrollTo(10, 10); + fixture.detectChanges(); + scrollService.getPosition() + .subscribe((pos: NbScrollPosition) => { + expect(pos.x).toEqual(0); + expect(pos.y).toEqual(0); + done(); + }) + }); + + it('should get updated scroll position', (done) => { + componentInstance.setSize('10000px', '10000px'); + fixture.detectChanges(); + currentWindow.scrollTo(10, 10); + scrollService.getPosition() + .subscribe((pos: NbScrollPosition) => { + expect(pos.x).toEqual(10); + expect(pos.y).toEqual(10); + done(); + }) + }); + + it('should get initial scroll position on scrollable', (done) => { + componentInstance.useLocalScroll(); + fixture.detectChanges(); + const scrollable = componentInstance.getScrollableElement(); + scrollService.getPosition() + .subscribe((pos: NbScrollPosition) => { + expect(pos.x).toEqual(scrollable.scrollLeft); + expect(pos.y).toEqual(scrollable.scrollTop); + done(); + }); + }); + + it('should get updated scroll position on scrollable', (done) => { + componentInstance.useLocalScroll(); + componentInstance.setSize('10000px', '10000px'); + fixture.detectChanges(); + const scrollable = componentInstance.getScrollableElement(); + scrollable.scrollTo(10, 10); + fixture.detectChanges(); + scrollService.getPosition() + .subscribe((pos: NbScrollPosition) => { + expect(pos.x).toEqual(10); + expect(pos.y).toEqual(10); + done(); + }); + }); + + it('should scroll using service', (done) => { + componentInstance.useLocalScroll(); + componentInstance.setSize('10000px', '10000px'); + fixture.detectChanges(); + scrollService.scrollTo(10, 10); + fixture.detectChanges(); + scrollService.getPosition() + .subscribe((pos: NbScrollPosition) => { + expect(pos.x).toEqual(10); + expect(pos.y).toEqual(10); + done(); + }); + }); + + + it('should listen to scroll', (done) => { + scrollService.onScroll() + .subscribe((event: any) => { + expect(event).not.toBeNull(); + done(); + }); + + currentWindow.dispatchEvent(new Event('scroll')); + }); + +}); diff --git a/src/framework/theme/services/scroll.service.ts b/src/framework/theme/services/scroll.service.ts new file mode 100644 index 0000000000..0eb6c339a8 --- /dev/null +++ b/src/framework/theme/services/scroll.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { share } from 'rxjs/operators'; +import { ReplaySubject } from 'rxjs'; + +/** + * Scroll position type + */ +export interface NbScrollPosition { + + /** + * x - left + * @type {number} + */ + x: number; + + /** + * y - top + * @type {number} + */ + y: number; +} + +/** + * Layout scroll service. Provides information about current scroll position, + * as well as methods to update position of the scroll. + * + * The reason we added this service is that in Nebular there are two scroll modes: + * - the default mode when scroll is on body + * - and the `withScroll` mode, when scroll is removed from the body and moved to an element inside of the + * `nb-layout` component + */ +@Injectable() +export class NbLayoutScrollService { + + private scrollPositionReq$ = new Subject(); + private manualScroll$ = new Subject(); + private scroll$ = new Subject(); + + /** + * Returns scroll position + * + * @returns {Observable} + */ + getPosition(): Observable { + const listener = new ReplaySubject(); + this.scrollPositionReq$.next({ listener }); + + return listener.asObservable(); + } + + /** + * Sets scroll position + * + * @param {number} x + * @param {number} y + */ + scrollTo(x: number, y: number) { + this.manualScroll$.next({ x, y }); + } + + /** + * Returns a stream of scroll events + * + * @returns {Observable} + */ + onScroll() { + return this.scroll$.pipe(share()); + } + + /** + * @private + * @returns Observable. + */ + onManualScroll(): Observable { + return this.manualScroll$.pipe(share()); + } + + /** + * @private + * @returns {Subject} + */ + onGetPosition(): Subject { + return this.scrollPositionReq$; + } + + /** + * @private + * @param {any} event + */ + fireScrollChange(event: any) { + this.scroll$.next(event); + } +} diff --git a/src/framework/theme/theme.module.ts b/src/framework/theme/theme.module.ts index 611b4958d9..340842a579 100644 --- a/src/framework/theme/theme.module.ts +++ b/src/framework/theme/theme.module.ts @@ -26,6 +26,8 @@ import { NbMediaBreakpointsService, } from './services/breakpoints.service'; import { NbLayoutDirectionService, NbLayoutDirection, NB_LAYOUT_DIRECTION } from './services/direction.service'; +import { NbLayoutScrollService } from './services/scroll.service'; +import { NbLayoutRulerService } from './services/ruler.service'; export function nbWindowFactory() { return window; @@ -47,6 +49,7 @@ export class NbThemeModule { * @param nbThemeOptions {NbThemeOptions} Main theme options * @param nbJSThemes {NbJSThemeOptions[]} List of JS Themes, will be merged with default themes * @param nbMediaBreakpoints {NbMediaBreakpoint} Available media breakpoints + * @param layoutDirection {NbLayoutDirection} Layout direction * * @returns {ModuleWithProviders} */ @@ -70,6 +73,8 @@ export class NbThemeModule { NbSpinnerService, { provide: NB_LAYOUT_DIRECTION, useValue: layoutDirection || NbLayoutDirection.LTR }, NbLayoutDirectionService, + NbLayoutScrollService, + NbLayoutRulerService, ], }; } diff --git a/src/playground/playground-routing.module.ts b/src/playground/playground-routing.module.ts index f4396ae8ae..29776dc856 100644 --- a/src/playground/playground-routing.module.ts +++ b/src/playground/playground-routing.module.ts @@ -132,6 +132,7 @@ import { NbButtonHeroComponent } from './button/button-hero.component'; import { NbButtonOutlineComponent } from './button/button-outline.component'; import { NbButtonSizesComponent } from './button/button-sizes.component'; import { NbButtonTypesComponent } from './button/button-types.component'; +import { NbScrollWindowComponent } from './scroll/scroll-window.component'; export const routes: Routes = [ { @@ -784,6 +785,15 @@ export const routes: Routes = [ }, ], }, + { + path: 'scroll', + children: [ + { + path: 'scroll-window.component', + component: NbScrollWindowComponent, + }, + ], + }, ], }, { diff --git a/src/playground/playground.module.ts b/src/playground/playground.module.ts index 4b877591cc..624f9155de 100644 --- a/src/playground/playground.module.ts +++ b/src/playground/playground.module.ts @@ -161,6 +161,7 @@ import { NbButtonHeroComponent } from './button/button-hero.component'; import { NbButtonOutlineComponent } from './button/button-outline.component'; import { NbButtonSizesComponent } from './button/button-sizes.component'; import { NbButtonTypesComponent } from './button/button-types.component'; +import { NbScrollWindowComponent } from './scroll/scroll-window.component'; export const NB_MODULES = [ NbCardModule, @@ -312,6 +313,7 @@ export const NB_EXAMPLE_COMPONENTS = [ NbButtonOutlineComponent, NbButtonSizesComponent, NbButtonTypesComponent, + NbScrollWindowComponent, ]; diff --git a/src/playground/scroll/scroll-window.component.html b/src/playground/scroll/scroll-window.component.html new file mode 100644 index 0000000000..b4db1ce20c --- /dev/null +++ b/src/playground/scroll/scroll-window.component.html @@ -0,0 +1,12 @@ + + + + + Current: {{ mode }} + + + + {{ text }} + + + diff --git a/src/playground/scroll/scroll-window.component.ts b/src/playground/scroll/scroll-window.component.ts new file mode 100644 index 0000000000..a57ad4bb25 --- /dev/null +++ b/src/playground/scroll/scroll-window.component.ts @@ -0,0 +1,36 @@ +import { Component } from '@angular/core'; +import { NbLayoutScrollService, NbLayoutRulerService } from '@nebular/theme'; + +enum LayoutMode { + WINDOW = 'window', + LAYOUT = 'layout', +} + +@Component({ + selector: 'nb-scroll-window', + templateUrl: './scroll-window.component.html', +}) +export class NbScrollWindowComponent { + + mode = LayoutMode.WINDOW; + text = 'Hello World! '.repeat(1024 * 10); + + constructor(private scroll: NbLayoutScrollService, private ruler: NbLayoutRulerService) { + this.scroll.onScroll() + .subscribe((event) => console.info('Scroll', event)); + } + + changeMode() { + this.mode = this.mode === LayoutMode.WINDOW ? LayoutMode.LAYOUT : LayoutMode.WINDOW; + } + + scrollTo(x: number, y: number) { + this.scroll.scrollTo(x, y); + + this.ruler.getDimensions() + .subscribe(position => console.info('Content Dimensions', position)); + + this.scroll.getPosition() + .subscribe(size => console.info('Scroll Position', size)); + } +}