diff --git a/package.json b/package.json
index ce993d106..2c5a0c559 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "roe",
- "version": "0.8.29",
+ "version": "0.8.30",
"description": "A Collection of React Components for Project Development",
"main": "dist/js/index.js",
"types": "dist/js/index.d.ts",
diff --git a/src/less/index.less b/src/less/index.less
index 056acd868..3d5b1e112 100644
--- a/src/less/index.less
+++ b/src/less/index.less
@@ -3,6 +3,7 @@
@import 'variables.less';
@import 'overrides.less';
@import 'grid.less';
+@import 'nav-bar.less';
@import 'buttons.less';
@import 'inputs.less';
@import 'layout.less';
diff --git a/src/less/nav-bar.less b/src/less/nav-bar.less
new file mode 100644
index 000000000..6d35fb6bc
--- /dev/null
+++ b/src/less/nav-bar.less
@@ -0,0 +1,30 @@
+body.with-fixed-nav-bar {
+ padding-top: @nav-bar-height;
+}
+
+.nav-bar {
+ .clearfix();
+ padding: @padding-base 0;
+ background-color: @nav-bar-background;
+ border-bottom: @nav-bar-border;
+ box-shadow: @shadow-hard;
+ height: @nav-bar-height;
+ transition: ease-in-out 0.2s transform, ease-in-out 0.2s box-shadow;
+
+ &.no-shaddow {
+ box-shadow: @shadow-none;
+ }
+
+ &.fixed {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ z-index: 500;
+ }
+
+ &.shy.hidden {
+ transform: translate(0, -100%);
+ box-shadow: @shadow-none;
+ }
+}
diff --git a/src/less/variables.less b/src/less/variables.less
index 32297c37c..b4b4b8ef7 100644
--- a/src/less/variables.less
+++ b/src/less/variables.less
@@ -63,6 +63,10 @@
@container-background: @white;
+@nav-bar-background: @body-background;
+@nav-bar-border: @border-base;
+@nav-bar-height: @input-height + @padding-base * 2;
+
@button-text-color-dark: @grey-dark;
@button-text-color-light: @grey-lightest;
@button-background-default: @grey-lighter;
diff --git a/src/ts/components/navigation/nav-bar.examples.md b/src/ts/components/navigation/nav-bar.examples.md
new file mode 100644
index 000000000..6bcaddc10
--- /dev/null
+++ b/src/ts/components/navigation/nav-bar.examples.md
@@ -0,0 +1,71 @@
+#### Example
+
+```js
+class NavBarExample extends React.Component {
+ constructor (props) {
+ super(props);
+
+ this.state = {};
+
+ this.onChange = this.onChange.bind(this);
+ }
+
+ render () {
+ const {
+ type
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ onChange (event) {
+ this.setState({
+ type: event.target.value
+ });
+ }
+}
+
+
+```
+
+#### Less variables
+
+```less
+@nav-bar-background: @body-background; // @white;
+@nav-bar-border: @border-base;
+@nav-bar-height: @input-height + @padding-base * 2;
+```
diff --git a/src/ts/components/navigation/nav-bar.tsx b/src/ts/components/navigation/nav-bar.tsx
new file mode 100644
index 000000000..10424f0ea
--- /dev/null
+++ b/src/ts/components/navigation/nav-bar.tsx
@@ -0,0 +1,137 @@
+import * as classNames from 'classnames';
+import * as React from 'react';
+import { HTMLProps, PureComponent } from 'react';
+import * as ReactDOM from 'react-dom';
+import { ComponentProps } from '../../types';
+import { addClassName, getScrollOffset, removeClassName } from '../../utils';
+
+const WITH_FIXED_NAV_BAR = 'with-fixed-nav-bar';
+
+export interface NavBarProps extends ComponentProps, HTMLProps {
+ /**
+ * Fix the navbar to the top of the screen
+ */
+ fixed?: boolean;
+ /**
+ * Hide the navbar when scrolling down, but display when scrolling up
+ */
+ shy?: boolean;
+ /**
+ * Remove NavBar shadow
+ */
+ noShadow?: boolean;
+}
+
+export interface NavBarState {
+ hidden: boolean;
+}
+
+export class NavBar extends PureComponent {
+ private previousScrollY: number;
+
+ public constructor (props: NavBarProps) {
+ super(props);
+
+ this.previousScrollY = getScrollOffset().y;
+
+ this.state = {
+ hidden: false,
+ };
+ }
+
+ public componentWillMount () {
+ this.updateBodyClass(this.props);
+ this.toggleShyListeners(this.props);
+ }
+
+ public componentWillUpdate (nextProps: NavBarProps) {
+ if (this.props.shy !== nextProps.shy) {
+ this.toggleShyListeners(nextProps);
+ }
+
+ if (this.props.fixed !== nextProps.fixed || this.props.shy !== nextProps.shy) {
+ this.updateBodyClass(nextProps);
+ }
+ }
+
+ public componentWillUnmount () {
+ window.removeEventListener('scroll', this.hideOrShowNavBar);
+ window.removeEventListener('resize', this.hideOrShowNavBar);
+ }
+
+ public render () {
+ const {
+ children,
+ className,
+ fixed,
+ shy,
+ noShadow,
+ component: Component = 'div',
+ ...remainingProps,
+ } = this.props;
+
+ const {
+ hidden,
+ } = this.state;
+
+ const myClassNames = [
+ 'nav-bar',
+ fixed || shy ? 'fixed' : null,
+ shy ? 'shy' : null,
+ hidden ? 'hidden' : null,
+ noShadow ? 'no-shadow' : null,
+ className
+ ];
+
+ return (
+
+ {children}
+
+ );
+ }
+
+ private updateBodyClass (props: NavBarProps) {
+ const { fixed, shy } = props;
+
+ if (fixed || shy) {
+ addClassName(document.body, WITH_FIXED_NAV_BAR);
+ } else {
+ removeClassName(document.body, WITH_FIXED_NAV_BAR)
+ }
+ }
+
+ private toggleShyListeners (props: NavBarProps) {
+ const { shy } = props;
+
+ if (shy) {
+ window.addEventListener('scroll', this.hideOrShowNavBar);
+ window.addEventListener('resize', this.hideOrShowNavBar);
+ } else {
+ window.removeEventListener('scroll', this.hideOrShowNavBar);
+ window.removeEventListener('resize', this.hideOrShowNavBar);
+ }
+ }
+
+ private hideOrShowNavBar = () => {
+ const { y } = getScrollOffset();
+ const element = ReactDOM.findDOMNode(this);
+
+ if (element) {
+ const { height } = element.getBoundingClientRect();
+
+ if (y > this.previousScrollY && y > height) {
+ this.setState({
+ hidden: true,
+ });
+ } else if (y < this.previousScrollY) {
+ this.setState({
+ hidden: false,
+ });
+ }
+ }
+
+ this.previousScrollY = y;
+ }
+}
+
+export default NavBar;
diff --git a/src/ts/constants.ts b/src/ts/constants.ts
index 587afb994..242d2452c 100644
--- a/src/ts/constants.ts
+++ b/src/ts/constants.ts
@@ -1,9 +1,10 @@
export const NBSP = '\u00a0';
+export const MATCHES_WHITESPACE = /\s+/g;
export const MATCHES_INITIAL_INDENTATION = /^([^\S\n]*)\S/;
export const MATCHES_BLANK_FIRST_LINE = /^\s*\n/;
export const MATCHES_BLANK_LAST_LINE = /\n\s*$/;
-export const MATCHES_AMPERSAND = /&/gi;
+export const MATCHES_AMPERSAND = /&/g;
export const MATCHES_NON_WORD_CHARACTERS = /[\W_]+/gi;
-export const MATCHES_LEADING_AND_TRAILING_HYPHENS = /(^-+|-+$)/gi;
+export const MATCHES_LEADING_AND_TRAILING_HYPHENS = /(^-+|-+$)/g;
diff --git a/src/ts/index.ts b/src/ts/index.ts
index b2ef072da..cb237caa6 100644
--- a/src/ts/index.ts
+++ b/src/ts/index.ts
@@ -18,6 +18,7 @@ export { default as ModalCloseIcon } from './components/modals/modal-close-icon'
export { default as ModalFooter } from './components/modals/modal-footer';
export { default as ModalHeader } from './components/modals/modal-header';
export { default as ModalRenderer } from './components/modals/modal-renderer';
+export { default as NavBar } from './components/navigation/nav-bar';
export { default as Row } from './components/grid/row';
export { default as Section } from './components/content/section';
export { default as SpacedGroup } from './components/spaced-group';
diff --git a/src/ts/utils.ts b/src/ts/utils.ts
index 40f93178f..5ee51167b 100644
--- a/src/ts/utils.ts
+++ b/src/ts/utils.ts
@@ -6,6 +6,7 @@ import {
MATCHES_INITIAL_INDENTATION,
MATCHES_LEADING_AND_TRAILING_HYPHENS,
MATCHES_NON_WORD_CHARACTERS,
+ MATCHES_WHITESPACE,
} from './constants';
export const formatCode = (code: string) => {
@@ -56,3 +57,42 @@ export const shouldNotBeRendered = (children: any) => {
}
export const isValidColumnNumber = (value?: number) => typeof value === 'number' && value === +value;
+
+export const addClassName = (element: HTMLElement, className: string) => {
+ const myClassNames = element.className
+ .trim()
+ .split(MATCHES_WHITESPACE);
+
+ if (myClassNames.indexOf(className) >= 0) {
+ return;
+ }
+
+ element.className = [...myClassNames, className].join(' ');
+};
+
+export const removeClassName = (element: HTMLElement, className: string) => {
+ const myClassNames = element.className
+ .trim()
+ .split(MATCHES_WHITESPACE);
+
+ const index = myClassNames.indexOf(className);
+
+ if (index < 0) {
+ return;
+ }
+
+ myClassNames.splice(index, 1);
+
+ element.className = myClassNames.join(' ');
+};
+
+export const getScrollOffset = () => {
+ const doc = document.documentElement;
+ const left = (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0);
+ const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
+
+ return {
+ x: left,
+ y: top,
+ };
+}
diff --git a/styleguide.config.js b/styleguide.config.js
index af8e50086..2e7e5c3ec 100644
--- a/styleguide.config.js
+++ b/styleguide.config.js
@@ -25,6 +25,10 @@ var components = [
name: 'Modals',
components: 'src/ts/components/modals/**/*.tsx'
},
+ {
+ name:'Navigation',
+ components: 'src/ts/components/navigation/**/*.tsx'
+ },
{
name: 'Forms',
components: 'src/ts/components/forms/**/*.tsx'
diff --git a/tests/__snapshots__/nav-bar.tsx.snap b/tests/__snapshots__/nav-bar.tsx.snap
new file mode 100644
index 000000000..2c482f333
--- /dev/null
+++ b/tests/__snapshots__/nav-bar.tsx.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`NavBar should apply fixed class 1`] = `
+
+`;
+
+exports[`NavBar should apply no shadow class 1`] = `
+
+`;
+
+exports[`NavBar should apply shy class 1`] = `
+
+`;
+
+exports[`NavBar should apply the hidden class 1`] = `
+
+
+
+`;
+
+exports[`NavBar should match snapshot 1`] = `
+
+`;
+
+exports[`NavBar should take regular element attributes 1`] = `
+
+`;
diff --git a/tests/nav-bar.tsx b/tests/nav-bar.tsx
new file mode 100644
index 000000000..4b00a28ca
--- /dev/null
+++ b/tests/nav-bar.tsx
@@ -0,0 +1,164 @@
+import * as enzyme from 'enzyme';
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import * as renderer from 'react-test-renderer';
+import NavBar from '../src/ts/components/navigation/nav-bar';
+import * as utils from '../src/ts/utils';
+
+describe('NavBar', () => {
+
+ it('should match snapshot', () => {
+ const tree = renderer.create(
+
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should take regular element attributes', () => {
+ const tree = renderer.create(
+
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should apply fixed class', () => {
+ const tree = renderer.create(
+
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should apply shy class', () => {
+ const tree = renderer.create(
+
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should apply no shadow class', () => {
+ const tree = renderer.create(
+
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should apply the hidden class', () => {
+ const instance = enzyme.mount();
+
+ instance.setState({hidden: true});
+ instance.update();
+
+ expect(instance).toMatchSnapshot();
+ });
+
+ it('should toggle shy listeners and update the body class on mount and props change', () => {
+ jest.spyOn(window, 'addEventListener');
+ jest.spyOn(window, 'removeEventListener');
+ jest.spyOn(utils, 'addClassName').mockImplementation(jest.fn());
+ jest.spyOn(utils, 'removeClassName').mockImplementation(jest.fn());
+
+ const instance = enzyme.mount();
+
+ expect(window.removeEventListener).toHaveBeenCalledTimes(2);
+ (window.removeEventListener as jest.Mock).mockClear();
+ expect(utils.removeClassName).toHaveBeenCalledTimes(1);
+ (utils.removeClassName as jest.Mock).mockClear();
+
+ instance.setProps({shy: true});
+
+ expect(window.addEventListener).toHaveBeenCalledTimes(2);
+ (window.addEventListener as jest.Mock).mockClear();
+ expect(utils.addClassName).toHaveBeenCalledTimes(1);
+ (utils.addClassName as jest.Mock).mockClear();
+
+ instance.setProps({shy: false});
+
+ expect(window.removeEventListener).toHaveBeenCalledTimes(2);
+ (window.removeEventListener as jest.Mock).mockClear();
+ expect(utils.removeClassName).toHaveBeenCalledTimes(1);
+ (utils.removeClassName as jest.Mock).mockClear();
+ });
+
+ it('should remove listeners on unmount', () => {
+ jest.spyOn(window, 'removeEventListener');
+
+ const instance = enzyme.mount();
+
+ (window.removeEventListener as jest.Mock).mockClear();
+
+ instance.unmount();
+
+ expect(window.removeEventListener).toHaveBeenCalledTimes(2);
+ });
+
+ it('should hide or show the navbar when scrolled', () => {
+ const handlers: {[i: string]: (() => any) | undefined} = {};
+
+ jest.spyOn(window, 'addEventListener').mockImplementation((type: string, callback: () => any) => {
+ if (type === 'scroll') {
+ handlers[type] = callback;
+ jest.spyOn(handlers, type);
+ }
+ });
+ jest.spyOn(utils, 'getScrollOffset').mockReturnValue({x: 0, y: 0});
+ jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue({getBoundingClientRect: () => ({height: 20})});
+
+ const instance = enzyme.mount();
+
+ const { scroll } = handlers;
+
+ if (!scroll) {
+ throw new Error('No scroll listener attached');
+ }
+
+ // Initial position
+ scroll();
+ expect(instance.state('hidden')).toBe(false);
+
+ (utils.getScrollOffset as jest.Mock).mockReturnValue({x: 0, y: 10});
+
+ // Scrolled a little, but not farther than the NavBar height
+ scroll();
+ expect(instance.state('hidden')).toBe(false);
+
+ (utils.getScrollOffset as jest.Mock).mockReturnValue({x: 0, y: 50});
+
+ // Scrolled past NavBar height
+ scroll();
+ expect(instance.state('hidden')).toBe(true);
+
+ (utils.getScrollOffset as jest.Mock).mockReturnValue({x: 0, y: 40});
+
+ // Scrolled up
+ scroll();
+ expect(instance.state('hidden')).toBe(false);
+ });
+
+ it('should gracefully handle a missing element', () => {
+ const handlers: {[i: string]: (() => any) | undefined} = {};
+
+ jest.spyOn(window, 'addEventListener').mockImplementation((type: string, callback: () => any) => {
+ if (type === 'scroll') {
+ handlers[type] = callback;
+ jest.spyOn(handlers, type);
+ }
+ });
+ jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(null);
+
+ enzyme.mount();
+
+ const { scroll } = handlers;
+
+ if (!scroll) {
+ throw new Error('No scroll listener attached');
+ }
+
+ expect(scroll).not.toThrow();
+ });
+
+});