Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(affix) #339

Closed
wants to merge 8 commits into from
3 changes: 3 additions & 0 deletions components/affix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {Affix} from './affix/affix.directive';
export {AffixStatus} from './affix/affix.directive'
export {AffixStatusChange} from './affix/affix.directive'
174 changes: 174 additions & 0 deletions components/affix/affix.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {Directive, OnInit, Input, Output, EventEmitter, ElementRef, OnDestroy, HostBinding} from 'angular2/core';
import {positionService, ElemPosition} from '../position';

export enum AffixStatus {AFFIX, AFFIX_TOP, AFFIX_BOTTOM}

export class AffixStatusChange {
constructor(public oldStatus:AffixStatus, public newStatus:AffixStatus) {
}
}

@Directive({
selector: '[affix]'
})
export class Affix implements OnInit, OnDestroy {

@Input()
public affixOffsetTop:number = 0;
@Input()
public affixOffsetBottom:number = 0;

@HostBinding('class.affix')
private isAffix:boolean = true;
@HostBinding('class.affix-top')
private isAffixedTop:boolean = true;
@HostBinding('class.affix-bottom')
private isAffixedBottom:boolean = true;
@HostBinding('style.top.px')
private top:number = null;

@Output()
public affixChange:EventEmitter<AffixStatusChange> = new EventEmitter(false);

private status:AffixStatus = null;
private body:HTMLBodyElement;
private window:Window;
private pinnedOffset:number = null;
private debouncedCheckPosition:Function = Affix.debounce(() => this.checkPosition(), 5);
private eventListener:Function = (ev:UIEvent) => this.debouncedCheckPosition();

constructor(private el:ElementRef) {
this.body = el.nativeElement.ownerDocument.body;
this.window = el.nativeElement.ownerDocument.defaultView;
}

ngOnInit() {
this.el.nativeElement.ownerDocument.defaultView.addEventListener('scroll', this.eventListener);
this.checkPosition();
}

ngOnDestroy():any {
this.el.nativeElement.ownerDocument.defaultView.removeEventListener('scroll', this.eventListener);
return undefined;
}

private checkPosition():void {
let elemPos = positionService.position(this.el.nativeElement);

This comment was marked as off-topic.

if (elemPos.height === 0 || elemPos.width === 0) {
// Element is not visible
return;
}
let scrollHeight:number = Math.max(this.window.innerHeight, this.body.scrollHeight);
let nativeElemPos:ElemPosition = positionService.offset(this.el.nativeElement);

let newAffixStatus:AffixStatus = this.getState(scrollHeight, nativeElemPos, this.affixOffsetTop, this.affixOffsetBottom);

if (this.status !== newAffixStatus) {

this.top = newAffixStatus === AffixStatus.AFFIX_BOTTOM ? this.getPinnedOffset() : null;

this.affixChange.emit(new AffixStatusChange(this.status, newAffixStatus));
this.status = newAffixStatus;
this.isAffix = false;
this.isAffixedBottom = false;
this.isAffixedTop = false;
switch (this.status) {
case AffixStatus.AFFIX_TOP:
this.isAffixedTop = true;
break;
case AffixStatus.AFFIX_BOTTOM:
this.isAffixedBottom = true;
break;
default:
this.isAffix = true;
break;
}
}

if (newAffixStatus === AffixStatus.AFFIX_BOTTOM) {
this.top = scrollHeight - nativeElemPos.height - this.affixOffsetBottom;
}
}

private getState(scrollHeight:number, nativeElemPos:ElemPosition, offsetTop:number, offsetBottom:number):AffixStatus {
let scrollTop:number = this.body.scrollTop; // current scroll position in pixels from top
let targetHeight:number = this.window.innerHeight; // Height of the window / viewport area

if (offsetTop !== null && this.status === AffixStatus.AFFIX_TOP) {

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

if (scrollTop < offsetTop) {
return AffixStatus.AFFIX_TOP;
}
return AffixStatus.AFFIX;
}

if (this.status === AffixStatus.AFFIX_BOTTOM) {
if (offsetTop !== null) {
if (scrollTop + this.pinnedOffset <= nativeElemPos.top) {
return AffixStatus.AFFIX;
}
return AffixStatus.AFFIX_BOTTOM;
}
if (scrollTop + targetHeight <= scrollHeight - offsetBottom) {
return AffixStatus.AFFIX;
}
return AffixStatus.AFFIX_BOTTOM;
}

if (offsetTop != null && scrollTop <= offsetTop) {
return AffixStatus.AFFIX_TOP;
}

let initializing:boolean = this.status === null;
let lowerEdgePosition:number = initializing ? scrollTop + targetHeight : nativeElemPos.top + nativeElemPos.height;
if (offsetBottom != null && (lowerEdgePosition >= scrollHeight - offsetBottom)) {
return AffixStatus.AFFIX_BOTTOM;
}

return AffixStatus.AFFIX;
}

private getPinnedOffset():number {
if (this.pinnedOffset !== null) {
return this.pinnedOffset;
}
let scrollTop:number = this.body.scrollTop;
let position:ElemPosition = positionService.offset(this.el.nativeElement);

this.pinnedOffset = position.top - scrollTop;
return this.pinnedOffset;
}

private static debounce(func:Function, wait:number):Function {
let timeout:any;
let args:Array<any>;
let timestamp:number;

return function () {
// save details of latest call
args = [].slice.call(arguments, 0);
timestamp = Date.now();

// this is where the magic happens
let later = function () {

// how long ago was the last call
let last = Date.now() - timestamp;

// if the latest call was less that the wait period ago
// then we reset the timeout to wait for the difference
if (last < wait) {
timeout = setTimeout(later, wait - last);
// or if not we can null out the timer and run the latest
} else {
timeout = null;
func.apply(this, args);
}
};

// we only need to set the timer now if one isn't already running
if (!timeout) {
timeout = setTimeout(later, wait);
}
};
};
}
33 changes: 33 additions & 0 deletions components/affix/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
### Usage
```typescript
import { Affix } from 'ng2-bootstrap/ng2-bootstrap';
```

### Annotations
```typescript
// class Affix

@Directive({
selector: '[affix]'
})
export class Affix implements OnInit, OnDestroy {

@Input()
public affixOffsetTop:number = 0;
@Input()
public affixOffsetBottom:number = 0;

@Output()
public affixChange:EventEmitter<AffixStatusChange> = new EventEmitter(false);
}
```

### Alert properties
- `affixOffsetTop` (`?:number=0`) - Pixels to offset from document top when calculating position of scroll.
- `affixOffsetBottom` (`?:number=0`) - Pixels to offset from document bottom when calculating position of scroll.

### Affix events
- `affixChange` - fired when the affix state changes. `$event` is an instance of `AffixStatusChange` class, having two properties `oldStatus` and `newStatus`.

### CSS Positioning
The affix directive toggles between three css-classes `affix`, `affix-top` and `affix-bottom` based on the current scrolling position in the document. In case of `affix-bottom` the CSS property `top` is set, however, additional theme-related positioning is required for expected usage.
1 change: 1 addition & 0 deletions components/affix/title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
An example of the affix component can be seen on this page. The navigation bar at the top of the page uses the affix directive to switch between the affix-state ("fixed to top") and affix-top-state ("below purple header").
11 changes: 9 additions & 2 deletions components/position.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import {IAttribute} from './common';

export class ElemPosition {
width: number;
height: number;
top: number;
left: number;
}

export class PositionService {
private get window():any {
return window;
Expand Down Expand Up @@ -48,7 +55,7 @@ export class PositionService {
* Provides read-only equivalent of jQuery's position function:
* http://api.jquery.com/position/
*/
public position(nativeEl:any):{width:number, height:number, top:number, left:number} {
public position(nativeEl:any):ElemPosition {
let elBCR = this.offset(nativeEl);
let offsetParentBCR = {top: 0, left: 0};
let offsetParentEl = this.parentOffsetEl(nativeEl);
Expand All @@ -71,7 +78,7 @@ export class PositionService {
* Provides read-only equivalent of jQuery's offset function:
* http://api.jquery.com/offset/
*/
public offset(nativeEl:any):{width:number, height:number, top:number, left:number} {
public offset(nativeEl:any):ElemPosition {
let boundingClientRect = nativeEl.getBoundingClientRect();
return {
width: boundingClientRect.width || nativeEl.offsetWidth,
Expand Down
23 changes: 21 additions & 2 deletions demo/assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ section {
}

.bd-pageheader {
margin-top: 51px;
}

.page-header {
Expand All @@ -38,6 +37,7 @@ section {

.navbar {
padding: 0;
z-index: 5;
}

.navbar-nav .nav-item {
Expand Down Expand Up @@ -94,7 +94,6 @@ section {
}

.bd-pageheader {
margin-bottom: 40px;
font-size: 20px;
}

Expand Down Expand Up @@ -135,13 +134,18 @@ section {
padding-bottom: 60px;
font-size: 24px;
text-align: left;
height: 311px;
}

.bd-pageheader h1 {
font-size: 60px;
line-height: 1;
}

.navbar-container {
height: 82px; /* 30px + 52px navbar height */
}

.navbar-nav > li > a.nav-link {
padding-top: 15px;
padding-bottom: 15px;
Expand All @@ -151,6 +155,12 @@ section {
.navbar > .container .navbar-brand, .navbar > .container-fluid .navbar-brand {
margin-left: -15px;
}

.navbar.affix {
position: fixed;
top: 0;
width: 100%;
}
}

@media (max-width: 767px) {
Expand All @@ -167,6 +177,15 @@ section {
padding: 0;
margin: 0;
}
.navbar {
position: fixed;
top: 0;
width: 100%;
}

.bd-pageheader {
padding-top: 52px;
}
}

@media (max-width: 400px) {
Expand Down
55 changes: 55 additions & 0 deletions demo/components/affix-section.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {Component} from 'angular2/core';
import {CORE_DIRECTIVES} from 'angular2/common';

import {TAB_DIRECTIVES} from '../../ng2-bootstrap';
import {AffixDemo} from './affix/affix-demo';

let name = 'Affix';
let src = 'https://github.com/valor-software/ng2-bootstrap/blob/master/components/affix/';

// webpack html imports
let doc = require('../../components/affix/readme.md');
let titleDoc = require('../../components/affix/title.md');

let ts = require('!!prismjs?lang=typescript!./affix/affix-demo.ts');
let html = require('!!prismjs?lang=markup!./affix/affix-demo.html');

@Component({
selector: 'affix-section',
template: `
<section id="${name.toLowerCase()}">
<h1>${name}<small>(<a href="${src}">src</a>)</small></h1>

<hr>

<div class="description">${titleDoc}</div>

<br/>

<div class="markup">
<tabset>
<tab heading="Markup">
<div class="card card-block panel panel-default panel-body">
<pre class="language-html"><code class="language-html" ngNonBindable>${html}</code></pre>
</div>
</tab>
<tab heading="TypeScript">
<div class="card card-block panel panel-default panel-body">
<pre class="language-typescript"><code class="language-typescript" ngNonBindable>${ts}</code></pre>
</div>
</tab>
</tabset>
</div>

<br/>

<div class="api">
<h2>API</h2>
<div class="card card-block panel panel-default panel-body">${doc}</div>
</div>
</section>
`,
directives: [AffixDemo, TAB_DIRECTIVES, CORE_DIRECTIVES]
})
export class AffixSection {
}
1 change: 1 addition & 0 deletions demo/components/affix/affix-demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<nav id="mainNav" class="navbar navbar-default" affix affixOffsetTop="100" (affixChange)="onAffixChange($event)"> [...] </nav>
19 changes: 19 additions & 0 deletions demo/components/affix/affix-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {Component} from 'angular2/core';
import {CORE_DIRECTIVES} from 'angular2/common';
import {Affix, AffixStatusChange} from '../../../ng2-bootstrap';

// webpack html imports
let template = require('./affix-demo.html');

@Component({
selector: 'affix-demo',
template: template,
directives: [Affix, CORE_DIRECTIVES]
})
export class AffixDemo {

onAffixChange(event:AffixStatusChange) {
console.log('Navbar changed from ' + event.oldStatus + ' to ' + event.newStatus);
}

}
Loading