mirror of
https://github.com/Redocly/redoc.git
synced 2025-02-25 22:40:32 +03:00
Add sticky sidebar
This commit is contained in:
parent
032d96cc6f
commit
7cb76f977e
|
@ -11,8 +11,8 @@
|
|||
<button id="load-button"> Explore </button>
|
||||
</nav>
|
||||
</nav>
|
||||
<!-- The wrapper component -->
|
||||
<redoc>
|
||||
|
||||
<redoc scroll-y-offset='50'>
|
||||
Loading...
|
||||
</redoc>
|
||||
|
||||
|
|
|
@ -54,8 +54,6 @@ nav {
|
|||
redoc {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
pre {
|
||||
|
|
72
lib/common/components/StickySidebar/sticky-sidebar.js
Normal file
72
lib/common/components/StickySidebar/sticky-sidebar.js
Normal 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] ];
|
80
lib/common/components/StickySidebar/sticky-sidebar.spec.js
Normal file
80
lib/common/components/StickySidebar/sticky-sidebar.spec.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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]]);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 {
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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});
|
||||
|
|
Loading…
Reference in New Issue
Block a user