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(); + }); + +});