From 7cb76f977eb58ac2bba253f7e50191c1b6491160 Mon Sep 17 00:00:00 2001 From: Roman Hotsiy Date: Sat, 26 Dec 2015 19:44:39 +0200 Subject: [PATCH] Add sticky sidebar --- demo/index.html | 4 +- demo/main.css | 2 - .../StickySidebar/sticky-sidebar.js | 72 +++++++++++++++++ .../StickySidebar/sticky-sidebar.spec.js | 80 +++++++++++++++++++ lib/components/Redoc/redoc.html | 30 +++---- lib/components/Redoc/redoc.js | 26 +++++- lib/components/Redoc/redoc.scss | 19 +++-- lib/components/Redoc/redoc.spec.js | 17 +++- lib/components/SideMenu/side-menu.js | 19 +++-- lib/components/SideMenu/side-menu.spec.js | 21 ++--- 10 files changed, 242 insertions(+), 48 deletions(-) create mode 100644 lib/common/components/StickySidebar/sticky-sidebar.js create mode 100644 lib/common/components/StickySidebar/sticky-sidebar.spec.js diff --git a/demo/index.html b/demo/index.html index a5600c3e..b025201c 100644 --- a/demo/index.html +++ b/demo/index.html @@ -11,8 +11,8 @@ - - + + Loading... diff --git a/demo/main.css b/demo/main.css index d20e4efe..d5325a9e 100644 --- a/demo/main.css +++ b/demo/main.css @@ -54,8 +54,6 @@ nav { redoc { display: block; box-sizing: border-box; - height: 100%; - overflow: auto; } pre { diff --git a/lib/common/components/StickySidebar/sticky-sidebar.js b/lib/common/components/StickySidebar/sticky-sidebar.js new file mode 100644 index 00000000..997d197d --- /dev/null +++ b/lib/common/components/StickySidebar/sticky-sidebar.js @@ -0,0 +1,72 @@ +'use strict'; + +import {Component, View, OnInit, OnDestroy, ElementRef} from 'angular2/core'; +import {BrowserDomAdapter} from 'angular2/platform/browser'; + +@Component({ + selector: 'sticky-sidebar', + inputs: ['scrollParent', 'scrollOffsetY'] +}) +@View({ + template: ` + + `, + lifecycle: [OnInit, OnDestroy] +}) +export default class StickySidebar { + constructor(elementRef, adapter) { + this.element = elementRef.nativeElement; + this.adapter = adapter; + + // initial styling + this.adapter.setStyle(this.element, 'position', 'absolute'); + this.adapter.setStyle(this.element, 'top', '0'); + this.adapter.setStyle(this.element, 'bottom', '0'); + this.adapter.setStyle(this.element, 'max-height', '100%'); + } + + bind() { + this.cancelScrollBinding = this.adapter.onAndCancel(this.scrollParent, 'scroll', () => { this.updatePosition(); }); + this.updatePosition(); + } + + unbind() { + this.cancelScrollBinding && this.cancelScrollBinding(); + } + + updatePosition() { + if ( this.scrollY + this.scrollOffsetY >= this.redocEl.offsetTop) { + this.stick(); + } else { + this.unstick(); + } + } + + stick() { + this.adapter.setStyle(this.element, 'position', 'fixed'); + this.adapter.setStyle(this.element, 'top', this.scrollOffsetY); + } + + unstick() { + this.adapter.setStyle(this.element, 'position', 'absolute'); + this.adapter.setStyle(this.element, 'top', 0); + } + + get scrollY() { + return this.scrollParent.scrollY || this.scrollParent.scrollTop || 0; + } + + ngOnInit() { + this.redocEl = this.element.offsetParent; + this.scrollOffsetY = parseInt(this.scrollOffsetY); + this.bind(); + } + + ngOnDestroy() { + this.unbind(); + } +} + +StickySidebar.parameters = [ [ElementRef], [BrowserDomAdapter] ]; diff --git a/lib/common/components/StickySidebar/sticky-sidebar.spec.js b/lib/common/components/StickySidebar/sticky-sidebar.spec.js new file mode 100644 index 00000000..379c9de1 --- /dev/null +++ b/lib/common/components/StickySidebar/sticky-sidebar.spec.js @@ -0,0 +1,80 @@ +'use strict'; + +import { getChildDebugElement } from 'tests/helpers'; +import {Component, View, provide} from 'angular2/core'; +import {BrowserDomAdapter} from 'angular2/platform/browser'; + +import { + TestComponentBuilder, + inject, + beforeEach, + beforeEachProviders, + it +} from 'angular2/testing'; + +import StickySidebar from 'lib/common/components/StickySidebar/sticky-sidebar'; + +describe('Common components', () => { + describe('StickySidebar Component', () => { + let builder; + let component; + let fixture; + + beforeEachProviders(() => [ + provide(BrowserDomAdapter, {useValue: new BrowserDomAdapter()}) + ]); + beforeEach(inject([TestComponentBuilder], (tcb) => { + builder = tcb; + })); + beforeEach((done) => { + builder.createAsync(TestApp).then(_fixture => { + fixture = _fixture; + let debugEl = getChildDebugElement(fixture.debugElement, 'sticky-sidebar'); + component = debugEl.componentInstance; + done(); + }, err => done.fail(err)); + }); + + + it('should init component', () => { + expect(component).not.toBeNull(); + }); + + it('should start unsticked', () => { + spyOn(component, 'stick').and.callThrough(); + fixture.detectChanges(); + expect(component.stick).not.toHaveBeenCalled(); + }); + + it('should stick if scrolled more than scrollOffsetY', () => { + spyOn(component, 'stick').and.callThrough(); + fixture.detectChanges(); + window.scrollY = 40; + component.updatePosition(); + expect(component.stick).toHaveBeenCalled(); + }); + }); +}); + + +/** Test component that contains an ApiInfo. */ +@Component({selector: 'test-app'}) +@View({ + directives: [StickySidebar], + template: + `
+
+
+ + +
+
` + +}) +class TestApp { + constructor() { + this.options = {}; + this.options.scrollParent = window; + this.options.scrollOffsetY = 20; + } +} diff --git a/lib/components/Redoc/redoc.html b/lib/components/Redoc/redoc.html index 32e0647a..762ff9e4 100644 --- a/lib/components/Redoc/redoc.html +++ b/lib/components/Redoc/redoc.html @@ -1,15 +1,17 @@ - -
- - -
- + diff --git a/lib/components/Redoc/redoc.js b/lib/components/Redoc/redoc.js index cf3b5207..3b59ad3b 100644 --- a/lib/components/Redoc/redoc.js +++ b/lib/components/Redoc/redoc.js @@ -2,22 +2,42 @@ import {RedocComponent, BaseComponent} from '../base'; import SchemaManager from '../../utils/SchemaManager'; + import ApiInfo from '../ApiInfo/api-info'; import ApiLogo from '../ApiLogo/api-logo'; import MethodsList from '../MethodsList/methods-list'; import SideMenu from '../SideMenu/side-menu'; +import StickySidebar from '../../common/components/StickySidebar/sticky-sidebar'; + import {ChangeDetectionStrategy} from 'angular2/core'; +import {ElementRef} from 'angular2/core'; +import {BrowserDomAdapter} from 'angular2/platform/browser'; +import detectScollParent from 'scrollparent'; + @RedocComponent({ selector: 'redoc', - providers: [SchemaManager], + providers: [SchemaManager, BrowserDomAdapter], templateUrl: './lib/components/Redoc/redoc.html', styleUrls: ['./lib/components/Redoc/redoc.css'], - directives: [ApiInfo, ApiLogo, MethodsList, SideMenu], + directives: [ApiInfo, ApiLogo, MethodsList, SideMenu, StickySidebar], changeDetection: ChangeDetectionStrategy.Default }) export default class Redoc extends BaseComponent { - constructor(schemaMgr) { + constructor(schemaMgr, elementRef) { super(schemaMgr); + this.element = elementRef.nativeElement; + + let DOM = new BrowserDomAdapter(); + let el = this.element; + this.options = {}; + + //parse options (top level component doesn't support inputs) + this.options.scrollParent = detectScollParent(el); + this.options.scrollOffsetTop = parseInt(DOM.getAttribute(el, 'scroll-y-offset')) || 0; } } +Redoc.parameters = Redoc.parameters.concat([[ElementRef]]); + +// this doesn't work in side-menu.js because of some circular references issue +SideMenu.parameters = SideMenu.parameters.concat([[Redoc]]); diff --git a/lib/components/Redoc/redoc.scss b/lib/components/Redoc/redoc.scss index 8915f021..96a60806 100644 --- a/lib/components/Redoc/redoc.scss +++ b/lib/components/Redoc/redoc.scss @@ -1,10 +1,19 @@ @import '../../common/styles/variables'; +.redoc-wrap { + position: relative; +} + side-menu { display: block; box-sizing: border-box; } +methods-list { + display: block; + overflow: hidden; +} + api-info, .side-bar { display: block; padding: 10px 0; @@ -19,21 +28,19 @@ api-logo { text-align: center; } -.side-bar { - position: fixed; +sticky-sidebar { width: $side-bar-width; - height: 100%; overflow-y: auto; - overflow-x: hidden;; + overflow-x: hidden; background-color: $side-bar-bg-color; } -.api-content { +#api-content { margin-left: $side-bar-width; } footer { - text-align: center; + text-align: right; padding: 10px; font-size: 15px; diff --git a/lib/components/Redoc/redoc.spec.js b/lib/components/Redoc/redoc.spec.js index 87d037d2..dc03c488 100644 --- a/lib/components/Redoc/redoc.spec.js +++ b/lib/components/Redoc/redoc.spec.js @@ -2,6 +2,7 @@ import { getChildDebugElement } from 'tests/helpers'; import {Component, View, provide} from 'angular2/core'; +import {BrowserDomAdapter} from 'angular2/platform/browser'; import { TestComponentBuilder, @@ -18,7 +19,8 @@ describe('Redoc components', () => { describe('Redoc Component', () => { let builder; beforeEachProviders(() => [ - provide(SchemaManager, {useValue: new SchemaManager()}) + provide(SchemaManager, {useValue: new SchemaManager()}), + provide(BrowserDomAdapter, {useValue: new BrowserDomAdapter()}) ]); beforeEach(injectAsync([TestComponentBuilder, SchemaManager], (tcb, schemaMgr) => { builder = tcb; @@ -42,6 +44,17 @@ describe('Redoc components', () => { done(); }, err => done.fail(err)); }); + + it('should parse component options from host element', (done) => { + builder.createAsync(TestApp).then(fixture => { + let component = getChildDebugElement(fixture.debugElement, 'redoc').componentInstance; + fixture.detectChanges(); + console.log(component.options.scrollOffsetTop); + component.options.scrollOffsetTop.should.be.equal(50); + fixture.destroy(); + done(); + }, err => done.fail(err)); + }); }); }); @@ -50,7 +63,7 @@ describe('Redoc components', () => { @View({ directives: [Redoc], template: - `` + `` }) class TestApp { } diff --git a/lib/components/SideMenu/side-menu.js b/lib/components/SideMenu/side-menu.js index 2cd551bc..59180fad 100644 --- a/lib/components/SideMenu/side-menu.js +++ b/lib/components/SideMenu/side-menu.js @@ -1,11 +1,10 @@ 'use strict'; import {RedocComponent, BaseComponent} from '../base'; -import {NgZone, ChangeDetectionStrategy} from 'angular2/core'; import {redocEvents} from '../../events'; -import detectScollParent from 'scrollparent'; -import {document} from 'angular2/src/facade/browser'; +import {NgZone, ChangeDetectionStrategy} from 'angular2/core'; +import {document} from 'angular2/src/facade/browser'; import {BrowserDomAdapter} from 'angular2/platform/browser'; import {global} from 'angular2/src/facade/lang'; @@ -23,18 +22,18 @@ const INVIEW_POSITION = { @RedocComponent({ selector: 'side-menu', - providers: [BrowserDomAdapter], templateUrl: './lib/components/SideMenu/side-menu.html', styleUrls: ['./lib/components/SideMenu/side-menu.css'], changeDetection: ChangeDetectionStrategy.Default }) export default class SideMenu extends BaseComponent { - constructor(schemaMgr, adapter, zone) { + constructor(schemaMgr, adapter, zone, redoc) { super(schemaMgr); this.zone = zone; this.adapter = adapter; + this.redoc = redoc; - this.scrollParent = detectScollParent(document.querySelector('methods-list')); + this.scrollParent = this.redoc.options.scrollParent; // for some reason constructor is not run inside zone // as workaround running it manually @@ -66,7 +65,7 @@ export default class SideMenu extends BaseComponent { bindEvents() { this.prevOffsetY = this.scrollY(); - this.viewBoxTop = this.scrollParent.offsetTop || 0; + this.scrollOffsetY = this.redoc.options.scrollOffsetTop || 0; this._cancel = {}; this._cancel.scroll = this.adapter.onAndCancel(this.scrollParent, 'scroll', () => { this.scrollHandler(); }); this._cancel.hash = this.adapter.onAndCancel(global, 'hashchange', evt => this.hashScroll(evt)); @@ -85,7 +84,7 @@ export default class SideMenu extends BaseComponent { scrollTo(el) { // TODO: rewrite this to use offsetTop as more reliable solution let subjRect = el.getBoundingClientRect(); - let offset = this.scrollY() + subjRect.top - this.viewBoxTop + 1; + let offset = this.scrollY() + subjRect.top - this.scrollOffsetY + 1; if (this.scrollParent.scrollTo) { this.scrollParent.scrollTo(0, offset); } else { @@ -163,11 +162,11 @@ export default class SideMenu extends BaseComponent { /* returns 1 if element if above the view, 0 if in view and -1 below the view */ getElementInViewPos(el) { - if (Math.floor(el.getBoundingClientRect().top) > this.viewBoxTop) { + if (Math.floor(el.getBoundingClientRect().top) > this.scrollOffsetY) { return INVIEW_POSITION.ABOVE; } - if (el.getBoundingClientRect().bottom <= this.viewBoxTop) { + if (el.getBoundingClientRect().bottom <= this.scrollOffsetY) { return INVIEW_POSITION.BELLOW; } return INVIEW_POSITION.INVIEW; diff --git a/lib/components/SideMenu/side-menu.spec.js b/lib/components/SideMenu/side-menu.spec.js index 936bcb47..0d29f025 100644 --- a/lib/components/SideMenu/side-menu.spec.js +++ b/lib/components/SideMenu/side-menu.spec.js @@ -2,6 +2,7 @@ import { getChildDebugElement, mouseclick} from 'tests/helpers'; import {Component, View, provide, ViewMetadata} from 'angular2/core'; +import {BrowserDomAdapter} from 'angular2/platform/browser'; import { TestComponentBuilder, @@ -14,15 +15,24 @@ import { import {redocEvents} from 'lib/events'; import MethodsList from 'lib/components/MethodsList/methods-list'; import SideMenu from 'lib/components/SideMenu/side-menu'; +import Redoc from 'lib/components/Redoc/redoc'; import SchemaManager from 'lib/utils/SchemaManager'; +let _mockRedoc = { + options: { + scrollOffsetTop: 0, + scrollParent: window + } +}; describe('Redoc components', () => { describe('SideMenu Component', () => { let builder; let component; let fixture; beforeEachProviders(() => [ - provide(SchemaManager, {useValue: new SchemaManager()}) + provide(SchemaManager, {useValue: new SchemaManager()}), + provide(BrowserDomAdapter, {useValue: new BrowserDomAdapter()}), + provide(Redoc, {useValue: _mockRedoc}) ]); beforeEach(injectAsync([TestComponentBuilder, SchemaManager], (tcb, schemaMgr) => { builder = tcb; @@ -50,10 +60,6 @@ describe('Redoc components', () => { expect(component.data).not.toBeNull(); }); - it('should use window as scrollParent', () => { - expect(component.scrollParent).toBe(window); - }); - it('should run hashScroll when redoc bootstrapped', (done) => { spyOn(component.adapter, 'getLocation').and.returnValue({hash: ''}); spyOn(component, 'hashScroll').and.stub(); @@ -99,6 +105,7 @@ describe('Redoc components', () => { fixture = _fixture; component = getChildDebugElement(fixture.debugElement, 'side-menu').componentInstance; menuNativeEl = getChildDebugElement(fixture.debugElement, 'side-menu').nativeElement; + component.scrollParent = _fixture.nativeElement.children[0]; fixture.detectChanges(); done(); @@ -114,10 +121,6 @@ describe('Redoc components', () => { expect(component.data).not.toBeNull(); }); - it('should use scrollable div as scrollParent', () => { - component.scrollParent.should.be.instanceof(Element); - }); - it('should scroll to method when location hash is present', (done) => { let hash = '#pet/paths/~1pet~1findByStatus/get'; spyOn(component.adapter, 'getLocation').and.returnValue({hash: hash});