Add sticky sidebar

This commit is contained in:
Roman Hotsiy 2015-12-26 19:44:39 +02:00
parent 032d96cc6f
commit 7cb76f977e
10 changed files with 242 additions and 48 deletions

View File

@ -11,8 +11,8 @@
<button id="load-button"> Explore </button>
</nav>
</nav>
<!-- The wrapper component -->
<redoc>
<redoc scroll-y-offset='50'>
Loading...
</redoc>

View File

@ -54,8 +54,6 @@ nav {
redoc {
display: block;
box-sizing: border-box;
height: 100%;
overflow: auto;
}
pre {

View File

@ -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: `
<div class="sticky-sidebar">
<ng-content></ng-content>
</div>
`,
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] ];

View File

@ -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:
`<div style="padding-top: 20px">
<div style="height: 20px; position: fixed; top: 0;"> </div>
<div style="position: relative">
<sticky-sidebar [scrollParent]="options.scrollParent" [scrollOffsetY]="options.scrollOffsetY">
</sticky-sidebar>
</div>
</div>`
})
class TestApp {
constructor() {
this.options = {};
this.options.scrollParent = window;
this.options.scrollOffsetY = 20;
}
}

View File

@ -1,11 +1,11 @@
<div class="side-bar">
<div class="redoc-wrap">
<sticky-sidebar [scrollParent]="options.scrollParent" [scrollOffsetY]="options.scrollOffsetTop">
<api-logo> </api-logo>
<side-menu> </side-menu>
</div>
<div class="api-content">
</sticky-sidebar>
<div id="api-content">
<api-info> </api-info>
<methods-list> </methods-list>
</div>
<footer>
<div class="powered-by-badge">
<a href="https://github.com/Rebilly/ReDoc" title="Swagger-generated API Reference Documentation" target="_blank">
@ -13,3 +13,5 @@
</a>
</div>
</footer>
</div>
</div>

View File

@ -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]]);

View File

@ -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;

View File

@ -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:
`<redoc></redoc>`
`<redoc scroll-y-offset="50"></redoc>`
})
class TestApp {
}

View File

@ -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;

View File

@ -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});