diff --git a/docs/src/app/app-routes.jsx b/docs/src/app/app-routes.jsx index 21fb52b16302d1..dff25ca006a208 100644 --- a/docs/src/app/app-routes.jsx +++ b/docs/src/app/app-routes.jsx @@ -36,6 +36,7 @@ let Paper = require('./components/pages/components/paper'); let Progress = require('./components/pages/components/progress'); let RefreshIndicator = require('./components/pages/components/refresh-indicator'); let Sliders = require('./components/pages/components/sliders'); +let SideNav = require('./components/pages/components/side-nav'); let Snackbar = require('./components/pages/components/snackbar'); let Switches = require('./components/pages/components/switches'); let Table = require('./components/pages/components/table'); @@ -89,6 +90,7 @@ let AppRoutes = ( + diff --git a/docs/src/app/components/pages/components.jsx b/docs/src/app/components/pages/components.jsx index 6fce441c4e70d9..26f821b19c4cd5 100644 --- a/docs/src/app/components/pages/components.jsx +++ b/docs/src/app/components/pages/components.jsx @@ -22,6 +22,7 @@ class Components extends React.Component { { route: 'progress', text: 'Progress'}, { route: 'refresh-indicator', text: 'Refresh Indicator'}, { route: 'sliders', text: 'Sliders'}, + { route: 'side-nav', text: 'Side Nav'}, { route: 'switches', text: 'Switches'}, { route: 'snackbar', text: 'Snackbar'}, { route: 'table', text: 'Table'}, diff --git a/docs/src/app/components/pages/components/side-nav.jsx b/docs/src/app/components/pages/components/side-nav.jsx new file mode 100644 index 00000000000000..b2d965b964cb8e --- /dev/null +++ b/docs/src/app/components/pages/components/side-nav.jsx @@ -0,0 +1,241 @@ +let React = require('react'); +let { Avatar, + SideNav, + SideNavItem, + SideNavHeader, + SideNavDivider, + SideNavSubheader, + ListItem, + MenuItem, + Styles, + RaisedButton, + FlatButton, + FontIcon, +} = require('material-ui'); +let ComponentDoc = require('../../component-doc'); + +let ActionAssignment = require('svg-icons/action/assignment'); +let ArrowDropRight = require('svg-icons/navigation-arrow-drop-right'); +let ContentInbox = require('svg-icons/content/inbox'); +let ActionInfo = require('svg-icons/action/info'); + + +class SideNavPage extends React.Component { + + render() { + let code = '\n' + +'\n ' + +'\n HEADER {/*same as primaryText="HEADER"*/}' + +'\n ' + +'\n ' + +'\n ' + +'\n ' + +'\n ' + +'\n ' + +'\n AWESOME SUBHEADER' + +'\n ' + +'\n } />' + +'\n } />' + +'\n }' + +'\n primaryText="And" />' + +'\n ' + +'\n } />' + +'\n } backgroundColor="#3f51b5"/>}/>' + +'\n ' + +'\n ' + +'\n
GitHub
' + +'\n
' + +'\n
\n' + + +'\n' + +'\n ' + +'\n material ui' + +'\n ' + +'\n ' + +'\n ' + +'\n ' + +'\n Components' + +'\n ' + +'\n ' + +'\n ' + +'\n Resources' + +'\n ' + +'\n ' + +'\n ' + +'\n ' + +'\n' + + let componentInfo = [ + { + name: 'Props', + infoArray: [ + { + name: 'disableSwipeToOpen', + type: 'bool', + header: 'default: false', + desc: 'Indicates whether swiping sideways when the nav is closed ' + + 'should open the nav.' + }, + { + name: 'docked', + type: 'bool', + header: 'default: true', + desc: 'Indicates that the left nav should be docked. In this state, the ' + + 'overlay won\'t show and clicking on a menu item will not close the left nav.' + }, + { + name: 'header', + type: 'element', + header: 'optional', + desc: 'A react component that will be displayed above all the menu items. ' + + 'Usually, this is used for a logo or a profile image.' + }, + { + name: 'openRight', + type: 'boole', + header: 'default: false', + desc: 'Positions the SideNav to open from the right side.' + }, + { + name: 'style', + type: 'object', + header: 'optional', + desc: 'Override the inline-styles of SideNav\'s root element.' + } + ] + }, + { + name: 'Methods', + infoArray: [ + { + name: 'close', + header: 'SideNav.close()', + desc: 'Closes the component, hiding it from view.' + }, + { + name: 'toggle', + header: 'SideNav.toggle()', + desc: 'Toggles between the open and closed states.' + } + ] + }, + { + name: 'Events', + infoArray: [ + { + name: 'onChange', + header: 'function(e, selectedIndex, menuItem)', + desc: 'Fired when a menu item is clicked that is not the one currently ' + + 'selected. Note that this requires the injectTapEventPlugin component. ' + + 'See the "Get Started" section for more detail.' + }, + { + name: 'onNavOpen', + header: 'function()', + desc: 'Fired when the component is opened' + }, + { + name: 'onNavClose', + header: 'function()', + desc: 'Fired when the component is closed' + } + ] + } + ]; + + const exampleFlatButtonIcon = { + height: '100%', + display: 'inline-block', + verticalAlign: 'middle', + float: 'left', + paddingLeft: '12px', + lineHeight: '36px', + color: Styles.Colors.cyan500 + }; + return ( + + +
+ +

+ + + + + HEADER {/*same as primaryText="HEADER"*/} + + + + + + + AWESOME SUBHEADER + + } /> + } /> + } + primaryText="And" /> + + } /> + } backgroundColor="#3f51b5"/>}/> + + +
GitHub
+
+ +
+ + + material ui + + + + + Components + + + + Resources + + + + + +
+ +
+ ); + } + + _showOverlaySideNavClick() { + this.refs.sideNav.toggle(); + } + + _toggleDockedSideNavClick() { + this.refs.dockedSideNav.toggle(); + } + +} + +module.exports = SideNavPage; diff --git a/src/index.js b/src/index.js index c8783205d1c4c7..bc3f7aa872c74d 100644 --- a/src/index.js +++ b/src/index.js @@ -41,6 +41,11 @@ module.exports = { Ripples: require('./ripples/'), SelectField: require('./select-field'), Slider: require('./slider'), + SideNav: require('./side-nav/side-nav'), + SideNavDivider: require('./side-nav/side-nav-divider'), + SideNavItem: require('./side-nav/side-nav-item'), + SideNavHeader: require('./side-nav/side-nav-header'), + SideNavSubheader: require('./side-nav/side-nav-subheader'), SvgIcon: require('./svg-icon'), Icons: { NavigationMenu: require('./svg-icons/navigation/menu'), diff --git a/src/side-nav/index.js b/src/side-nav/index.js new file mode 100644 index 00000000000000..cd4036e2bbff0b --- /dev/null +++ b/src/side-nav/index.js @@ -0,0 +1,7 @@ +module.exports = { + SideNav: require('./side-nav'), + SideNavDivider: require('./side-nav-divider'), + SideNavItem: require('./side-nav-item'), + SideNavHeader: require('./side-nav-header'), + SideNavSubheader: require('./side-nav-subheader'), +}; diff --git a/src/side-nav/side-nav-divider.jsx b/src/side-nav/side-nav-divider.jsx new file mode 100644 index 00000000000000..22a9293ec63733 --- /dev/null +++ b/src/side-nav/side-nav-divider.jsx @@ -0,0 +1,29 @@ +let React = require('react/addons'); +let StylePropable = require('../mixins/style-propable'); +let MenuDivider = require('../menus/menu-divider'); + +let SideNavDivider = React.createClass({ + + mixins: [StylePropable], + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + render() { + let { + style, + ...other, + } = this.props; + + let mergedStyles = this.mergeAndPrefix({ + /*TODO*/ + }, style); + + return ( + + ); + }, +}); + +module.exports = SideNavDivider; diff --git a/src/side-nav/side-nav-header.jsx b/src/side-nav/side-nav-header.jsx new file mode 100644 index 00000000000000..3830b35855d91e --- /dev/null +++ b/src/side-nav/side-nav-header.jsx @@ -0,0 +1,67 @@ +let React = require('react/addons'); +let StylePropable = require('../mixins/style-propable'); +let MenuItem = require('../lists/list-item'); + +let SideNavHeader = React.createClass({ + + mixins: [StylePropable], + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + propTypes: { + disabled: React.PropTypes.bool, + lineHeight: React.PropTypes.string, + innerDivStyle: React.PropTypes.object, + insetChildren: React.PropTypes.bool, + }, + + getTheme() { + if(this.context.muiTheme.component.sideNav) + return this.context.muiTheme.component.sideNav; + else + return { + headerItemBackgroundColor: '#2196f3', + headerItemTextColor: '#000000', + }; + }, + + getDefaultProps() { + return { + disabled: true, + }; + }, + + render() { + let { + disabled, + innerDivStyle, + style, + lineHeight, + ...other, + } = this.props; + + let mergedStyles = this.mergeAndPrefix({ + color: this.getTheme().headerItemTextColor, + backgroundColor: this.getTheme().headerItemBackgroundColor, + fontSize: '22px', + lineHeight: lineHeight? lineHeight : '32px', + }, style); + + let mergedInnerDivStyles = this.mergeAndPrefix({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, mergedStyles, innerDivStyle); + + return ( + + {this.props.children} + + ); + }, +}); + +module.exports = SideNavHeader; diff --git a/src/side-nav/side-nav-item.jsx b/src/side-nav/side-nav-item.jsx new file mode 100644 index 00000000000000..350318d23d2a0f --- /dev/null +++ b/src/side-nav/side-nav-item.jsx @@ -0,0 +1,68 @@ +let React = require('react/addons'); +let StylePropable = require('../mixins/style-propable'); +let MenuItem = require('../menus/menu-item'); + +let SideNavItem = React.createClass({ + + mixins: [StylePropable], + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + propTypes: { + disabled: React.PropTypes.bool, + active: React.PropTypes.bool, + innerDivStyle: React.PropTypes.object, + insetChildren: React.PropTypes.bool, + }, + + getTheme() { + if(this.context.muiTheme.component.sideNav) + return this.context.muiTheme.component.sideNav; + else + return { + navItemBackgroundColor: '#FFFFFF', + navItemTextColor: '#000000', + navItemActiveTextColor: '#e91e63', + }; + }, + + getDefaultProps() { + return { + disabled: false, + active: false, + }; + }, + + render() { + let { + disabled, + innerDivStyle, + style, + active, + ...other, + } = this.props; + + let mergedStyles = this.mergeAndPrefix({ + color: active? this.getTheme().navItemActiveTextColor: this.getTheme().navItemTextColor, + fontSize: '13px', + }, style); + + let mergedInnerDivStyles = this.mergeAndPrefix({ + display: 'flex', + alignItems: 'center', + }, mergedStyles, innerDivStyle); + + return ( + + {this.props.children} + + ); + }, +}); + +module.exports = SideNavItem; diff --git a/src/side-nav/side-nav-subheader.jsx b/src/side-nav/side-nav-subheader.jsx new file mode 100644 index 00000000000000..9f8d46c4853406 --- /dev/null +++ b/src/side-nav/side-nav-subheader.jsx @@ -0,0 +1,64 @@ +let React = require('react/addons'); +let StylePropable = require('../mixins/style-propable'); +let MenuItem = require('../lists/list-item'); + +let SideNavSubheader = React.createClass({ + + mixins: [StylePropable], + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + propTypes: { + disabled: React.PropTypes.bool, + lineHeight: React.PropTypes.string, + innerDivStyle: React.PropTypes.object, + insetChildren: React.PropTypes.bool, + }, + + getTheme() { + if(this.context.muiTheme.component.sideNav) + return this.context.muiTheme.component.sideNav; + else + return { + subheaderItemBackgroundColor: '#FFFFFF', + subheaderItemTextColor: '#000000', + }; + }, + + getDefaultProps() { + return { + disabled: true, + }; + }, + + render() { + let { + disabled, + innerDivStyle, + style, + ...other, + } = this.props; + + let mergedStyles = this.mergeAndPrefix({ + color: this.getTheme().subheaderItemTextColor, + backgroundColor: this.getTheme().subheaderItemBackgroundColor, + fontSize: '13px', + fontWeight: 'bold', + }, style); + + let mergedInnerDivStyles = this.mergeAndPrefix({ + display: 'flex', + alignItems: 'center', + }, mergedStyles, innerDivStyle); + + return ( + + {this.props.children} + + ); + }, +}); + +module.exports = SideNavSubheader; diff --git a/src/side-nav/side-nav.jsx b/src/side-nav/side-nav.jsx new file mode 100644 index 00000000000000..5a362dad1cb763 --- /dev/null +++ b/src/side-nav/side-nav.jsx @@ -0,0 +1,370 @@ +import React from 'react'; +import { AutoPrefix, Transitions } from '../styles'; +import Overlay from '../overlay'; +import Menu from '../menus/menu'; +import Paper from '../paper'; +import KeyCode from '../utils/key-code'; +import StylePropable from '../mixins/style-propable'; +import WindowListenable from '../mixins/window-listenable'; + +let openNavEventHandler = null; + +let SideNav = React.createClass({ + + mixins: [StylePropable, WindowListenable], + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + propTypes: { + className: React.PropTypes.string, + /*open type currently supports: docked/overlay */ + openType: React.PropTypes.oneOf([ + 'none', + 'docked', + 'overlay', + ]), + defaultOpen: React.PropTypes.bool, + header: React.PropTypes.element, + onChange: React.PropTypes.func, + onNavOpen: React.PropTypes.func, + onNavClose: React.PropTypes.func, + openRight: React.PropTypes.bool, + menuStyle: React.PropTypes.object, + listStyle: React.PropTypes.object, + disableSwipeToOpen: React.PropTypes.bool, + }, + + windowListeners: { + 'keyup': '_onWindowKeyUp', + 'resize': '_onWindowResize', + }, + + getDefaultProps() { + return { + openType:'docked', + defaultOpen: false, + disableSwipeToOpen: false, + zDepth: 0, + }; + }, + + getInitialState() { + this._maybeSwiping = false; + this._touchStartX = null; + this._touchStartY = null; + this._swipeStartX = null; + + return { + open: this.props.defaultOpen, + swiping: null, + }; + }, + + componentDidMount() { + this._updateMenuHeight(); + this._enableSwipeHandling(); + }, + + componentDidUpdate() { + this._updateMenuHeight(); + this._enableSwipeHandling(); + }, + + componentWillUnmount() { + this._disableSwipeHandling(); + }, + + toggle() { + this.setState({ open: !this.state.open }); + return this; + }, + + close() { + this.setState({ open: false }); + if (this.props.onNavClose) this.props.onNavClose(); + return this; + }, + + open() { + this.setState({ open: true }); + if (this.props.onNavOpen) this.props.onNavOpen(); + return this; + }, + + getThemePalette() { + return this.context.muiTheme.palette; + }, + + getTheme() { + if(this.context.muiTheme.component.sideNav) + return this.context.muiTheme.component.sideNav; + else + return { + width: 256, + backgroundColor: '#FFFFFF', + }; + }, + + getStyles() { + let x = this._getTranslateMultiplier() * (this.state.open ? 0 : this._getMaxTranslateX()); + let styles = { + root: { + height: '100%', + width: this.props.width? this.props.width : this.getTheme().width, + backgroundColor: this.props.backgroundColor? this.props.backgroundColor : this.getTheme().backgroundColor, + position: 'fixed', + zIndex: 10, + left: 0, + top: 0, + transform: 'translate3d(' + x + 'px, 0, 0)', + transition: !this.state.swiping && Transitions.easeOut(), + overflow: 'hidden', + }, + menu: { + backgroundColor: this.props.backgroundColor? this.props.backgroundColor : this.getTheme().backgroundColor, + overflowY: 'auto', + overflowX: 'hidden', + height: '100%', + width: this.props.width? this.props.width : this.getTheme().width, + borderRadius: '0', + }, + list: { + width: this.props.width? this.props.width : this.getTheme().width, + paddingTop: 0, + }, + rootWhenOpenRight: { + left: 'auto', + right: 0, + }, + }; + + return styles; + }, + + render() { + let overlay; + + let { + className, + openType, + defaultOpen, + header, + onChange, + onNavOpen, + onNavClose, + openRight, + disableSwipeToOpen, + style, + listStyle, + menuStyle, + zDepth, + ...other, + } = this.props; + + let styles = this.getStyles(); + if (openType==='overlay') { + overlay = ( + + ); + } + + return ( +
+ {overlay} + + {header} + + {this.props.children} + + +
+ ); + }, + + _updateMenuHeight() { + if (this.props.header) { + let container = React.findDOMNode(this.refs.container); + let menu = React.findDOMNode(this.refs.menu); + let menuHeight = container.clientHeight - menu.offsetTop; + menu.style.height = menuHeight + 'px'; + } + }, + + _onMenuItemClick(e, item) { + if (this.props.onChange) { + this.props.onChange(e, item); + } + if (this.props.openType==='overlay') this.close(); + }, + + _onOverlayTouchTap() { + this.close(); + }, + + _onWindowKeyUp(e) { + if (e.keyCode === KeyCode.ESC && + (this.props.openType==='overlay') && + this.state.open) { + this.close(); + } + }, + + _onWindowResize() { + this._updateMenuHeight(); + }, + + _getMaxTranslateX() { + return this.getTheme().width + 10; + }, + + _getTranslateMultiplier() { + return this.props.openRight ? 1 : -1; + }, + + _enableSwipeHandling() { + if (this.props.openType==='overlay') { + document.body.addEventListener('touchstart', this._onBodyTouchStart); + if (!openNavEventHandler) { + openNavEventHandler = this._onBodyTouchStart; + } + } else { + this._disableSwipeHandling(); + } + }, + + _disableSwipeHandling() { + document.body.removeEventListener('touchstart', this._onBodyTouchStart); + if (openNavEventHandler === this._onBodyTouchStart) { + openNavEventHandler = null; + } + }, + + _onBodyTouchStart(e) { + if (!this.state.open && openNavEventHandler !== this._onBodyTouchStart) { + return; + } + + let touchStartX = e.touches[0].pageX; + let touchStartY = e.touches[0].pageY; + + this._maybeSwiping = true; + this._touchStartX = touchStartX; + this._touchStartY = touchStartY; + + document.body.addEventListener('touchmove', this._onBodyTouchMove); + document.body.addEventListener('touchend', this._onBodyTouchEnd); + document.body.addEventListener('touchcancel', this._onBodyTouchEnd); + }, + + _setPosition(translateX) { + let leftNav = React.findDOMNode(this.refs.container); + leftNav.style[AutoPrefix.single('transform')] = + 'translate3d(' + (this._getTranslateMultiplier() * translateX) + 'px, 0, 0)'; + this.refs.overlay.setOpacity(1 - translateX / this._getMaxTranslateX()); + }, + + _getTranslateX(currentX) { + return Math.min( + Math.max( + this.state.swiping === 'closing' ? + this._getTranslateMultiplier() * (currentX - this._swipeStartX) : + this._getMaxTranslateX() - this._getTranslateMultiplier() * (this._swipeStartX - currentX), + 0 + ), + this._getMaxTranslateX() + ); + }, + + _onBodyTouchMove(e) { + let currentX = e.touches[0].pageX; + let currentY = e.touches[0].pageY; + + if (this.state.swiping) { + e.preventDefault(); + this._setPosition(this._getTranslateX(currentX)); + } + else if (this._maybeSwiping) { + let dXAbs = Math.abs(currentX - this._touchStartX); + let dYAbs = Math.abs(currentY - this._touchStartY); + // If the user has moved his thumb ten pixels in either direction, + // we can safely make an assumption about whether he was intending + // to swipe or scroll. + let threshold = 10; + + if (dXAbs > threshold && dYAbs <= threshold) { + this._swipeStartX = currentX; + this.setState({ + swiping: this.state.open ? 'closing' : 'opening', + }); + this._setPosition(this._getTranslateX(currentX)); + } + else if (dXAbs <= threshold && dYAbs > threshold) { + this._onBodyTouchEnd(); + } + } + }, + + _onBodyTouchEnd(e) { + if (this.state.swiping) { + let currentX = e.changedTouches[0].pageX; + let translateRatio = this._getTranslateX(currentX) / this._getMaxTranslateX(); + + this._maybeSwiping = false; + let swiping = this.state.swiping; + this.setState({ + swiping: null, + }); + + // We have to open or close after setting swiping to null, + // because only then CSS transition is enabled. + if (translateRatio > 0.5) { + if (swiping === 'opening') { + this._setPosition(this._getMaxTranslateX()); + } else { + this.close(); + } + } + else { + if (swiping === 'opening') { + this.open(); + } else { + this._setPosition(0); + } + } + } + else { + this._maybeSwiping = false; + } + + document.body.removeEventListener('touchmove', this._onBodyTouchMove); + document.body.removeEventListener('touchend', this._onBodyTouchEnd); + document.body.removeEventListener('touchcancel', this._onBodyTouchEnd); + }, + +}); + + +export default SideNav; diff --git a/src/styles/themes/light-theme.js b/src/styles/themes/light-theme.js index 614c297d4b6202..cb8ceb9247baba 100644 --- a/src/styles/themes/light-theme.js +++ b/src/styles/themes/light-theme.js @@ -137,6 +137,16 @@ let LightTheme = { selectionColor: palette.primary3Color, rippleColor: palette.primary1Color, }, + sideNav: { + width: spacing.desktopKeylineIncrement * 4, + backgroundColor: Colors.white, + headerItemBackgroundColor: palette.primary1Color, + headerItemTextColor: Colors.white, + subheaderItemTextColor: palette.primary2Color, + subheaderItemBackgroundColor: Colors.white, + navItemTextColor: Colors.black, + navItemActiveTextColor: palette.accent1Color, + }, snackbar: { textColor: Colors.white, backgroundColor: '#323232',