UNPKG

d2-ui

Version:
565 lines (485 loc) 16.4 kB
import React from 'react'; import ReactDOM from 'react-dom'; import KeyCode from './utils/key-code'; import StylePropable from './mixins/style-propable'; import autoPrefix from './styles/auto-prefix'; import Transitions from './styles/transitions'; import WindowListenable from './mixins/window-listenable'; import Overlay from './overlay'; import Paper from './paper'; import Menu from './menu/menu'; import getMuiTheme from './styles/getMuiTheme'; import warning from 'warning'; import deprecated from './utils/deprecatedPropType'; let openNavEventHandler = null; const LeftNav = React.createClass({ propTypes: { /** * The contents of the `LeftNav` */ children: React.PropTypes.node, /** * The css class name of the root element. */ className: React.PropTypes.string, /** * Indicates whether swiping sideways when the `LeftNav` is closed should open it. */ disableSwipeToOpen: React.PropTypes.bool, /** * Indicates that the `LeftNav` should be docked. In this state, the overlay won't * show and clicking on a menu item will not close the `LeftNav`. */ docked: React.PropTypes.bool, /** * A react component that will be displayed above all the menu items. * Usually, this is used for a logo or a profile image. */ header: deprecated(React.PropTypes.element, 'Instead, use composability.'), /** * Class name for the menuItem. */ menuItemClassName: deprecated(React.PropTypes.string, 'It will be removed with menuItems.'), /** * Class name for the link menuItem. */ menuItemClassNameLink: deprecated(React.PropTypes.string, 'It will be removed with menuItems.'), /** * Class name for the subheader menuItem. */ menuItemClassNameSubheader: deprecated(React.PropTypes.string, 'It will be removed with menuItems.'), /** * JSON data representing all menu items to render. */ menuItems: deprecated(React.PropTypes.array, 'Instead, use composability.'), /** * 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. */ onChange: deprecated(React.PropTypes.func, 'It will be removed with menuItems.'), /** * Fired when the component is opened. */ onNavClose: deprecated(React.PropTypes.func, 'Instead, use onRequestChange.'), /** * Fired when the component is closed. */ onNavOpen: deprecated(React.PropTypes.func, 'Instead, use onRequestChange.'), /** * Callback function that is fired when the open state of the `LeftNav` is * requested to be changed. The provided open argument determines whether * the `LeftNav` is requested to be opened or closed. Also, the reason * argument states why the `LeftNav` got closed or opend. It can be either * `'clickaway'` for menuItem and overlay clicks, `'escape'` for pressing the * escape key and 'swipe' for swiping. For opening the reason is always `'swipe'`. */ onRequestChange: React.PropTypes.func, /** * Indicates that the `LeftNav` should be opened, closed or uncontrolled. * Providing a boolean will turn the `LeftNav` into a controlled component. */ open: React.PropTypes.bool, /** * Positions the `LeftNav` to open from the right side. */ openRight: React.PropTypes.bool, /** * The `className` to add to the `Overlay` component that is rendered behind the `LeftNav`. */ overlayClassName: React.PropTypes.string, /** * Overrides the inline-styles of the `Overlay` component that is rendered behind the `LeftNav`. */ overlayStyle: React.PropTypes.object, /** * Indicates the particular item in the menuItems array that is currently selected. */ selectedIndex: deprecated(React.PropTypes.number, 'It will be removed with menuItems.'), /** * Override the inline-styles of the root element. */ style: React.PropTypes.object, /** * The width of the left most (or right most) area in pixels where the `LeftNav` can be * swiped open from. Setting this to `null` spans that area to the entire page * (**CAUTION!** Setting this property to `null` might cause issues with sliders and * swipeable `Tabs`, use at your own risk). */ swipeAreaWidth: React.PropTypes.number, /** * The width of the `LeftNav` in pixels. Defaults to using the values from theme. */ width: React.PropTypes.number, }, contextTypes: { muiTheme: React.PropTypes.object, }, //for passing default theme context to children childContextTypes: { muiTheme: React.PropTypes.object, }, mixins: [ StylePropable, WindowListenable, ], getDefaultProps() { return { disableSwipeToOpen: false, docked: true, open: null, openRight: false, swipeAreaWidth: 30, width: null, }; }, getInitialState() { this._maybeSwiping = false; this._touchStartX = null; this._touchStartY = null; this._swipeStartX = null; return { open: (this.props.open !== null ) ? this.props.open : this.props.docked, swiping: null, muiTheme: this.context.muiTheme || getMuiTheme(), }; }, getChildContext() { return { muiTheme: this.state.muiTheme, }; }, componentDidMount() { this._updateMenuHeight(); this._enableSwipeHandling(); }, //to update theme inside state whenever a new theme is passed down //from the parent / owner using context componentWillReceiveProps(nextProps, nextContext) { const newMuiTheme = nextContext.muiTheme ? nextContext.muiTheme : this.state.muiTheme; const newState = {muiTheme: newMuiTheme}; // If docked is changed, change the open state for when uncontrolled. if (this.props.docked !== nextProps.docked) newState.open = nextProps.docked; // If controlled then the open prop takes precedence. if (nextProps.open !== null) newState.open = nextProps.open; this.setState(newState); }, componentDidUpdate() { this._updateMenuHeight(); this._enableSwipeHandling(); }, componentWillUnmount() { this._disableSwipeHandling(); }, windowListeners: { keyup: '_onWindowKeyUp', resize: '_onWindowResize', }, toggle() { warning(false, 'using methods on left nav has been deprecated. Please refer to documentations.'); if (this.state.open) this.close(); else this.open(); return this; }, close() { warning(false, 'using methods on left nav has been deprecated. Please refer to documentations.'); this.setState({open: false}); if (this.props.onNavClose) this.props.onNavClose(); return this; }, open() { warning(false, 'using methods on left nav has been deprecated. Please refer to documentations.'); this.setState({open: true}); if (this.props.onNavOpen) this.props.onNavOpen(); return this; }, getStyles() { const muiTheme = this.state.muiTheme; const theme = muiTheme.leftNav; const rawTheme = muiTheme.rawTheme; const x = this._getTranslateMultiplier() * (this.state.open ? 0 : this._getMaxTranslateX()); const styles = { root: { height: '100%', width: this.props.width || theme.width, position: 'fixed', zIndex: muiTheme.zIndex.leftNav, left: 0, top: 0, transform: `translate3d(${x}px, 0, 0)`, transition: !this.state.swiping && Transitions.easeOut(null, 'transform', null), backgroundColor: theme.color, overflow: 'auto', }, menu: { overflowY: 'auto', overflowX: 'hidden', height: '100%', borderRadius: '0', }, overlay: { zIndex: muiTheme.zIndex.leftNavOverlay, pointerEvents: this.state.open ? 'auto' : 'none', // Bypass mouse events when left nav is closing. }, menuItem: { height: rawTheme.spacing.desktopLeftNavMenuItemHeight, lineHeight: `${rawTheme.spacing.desktopLeftNavMenuItemHeight}px`, }, rootWhenOpenRight: { left: 'auto', right: 0, }, }; styles.menuItemLink = this.mergeStyles(styles.menuItem, { display: 'block', textDecoration: 'none', color: rawTheme.palette.textColor, }); styles.menuItemSubheader = this.mergeStyles(styles.menuItem, { overflow: 'hidden', }); return styles; }, _shouldShow() { return this.state.open || !!this.state.swiping; // component is swiping }, _close(reason) { if (this.props.open === null) this.setState({open: false}); if (this.props.onRequestChange) this.props.onRequestChange(false, reason); return this; }, _open(reason) { if (this.props.open === null) this.setState({open: true}); if (this.props.onRequestChange) this.props.onRequestChange(true, reason); return this; }, _updateMenuHeight() { if (this.props.header) { const menu = ReactDOM.findDOMNode(this.refs.menuItems); if (menu) { const container = ReactDOM.findDOMNode(this.refs.clickAwayableElement); const menuHeight = container.clientHeight - menu.offsetTop; menu.style.height = menuHeight + 'px'; } } }, _onMenuItemClick(e, key, payload) { if (this.props.onChange && this.props.selectedIndex !== key) { this.props.onChange(e, key, payload); } if (!this.props.docked) this._close('clickaway'); }, _onOverlayTouchTap(event) { event.preventDefault(); this._close('clickaway'); }, _onWindowKeyUp(e) { if (e.keyCode === KeyCode.ESC && !this.props.docked && this.state.open) { this._close('escape'); } }, _onWindowResize() { this._updateMenuHeight(); }, _getMaxTranslateX() { const width = this.props.width || this.state.muiTheme.leftNav.width; return width + 10; }, _getTranslateMultiplier() { return this.props.openRight ? 1 : -1; }, _enableSwipeHandling() { if (!this.props.docked) { 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) { const swipeAreaWidth = this.props.swipeAreaWidth; const touchStartX = e.touches[0].pageX; const touchStartY = e.touches[0].pageY; // Open only if swiping from far left (or right) while closed if (swipeAreaWidth !== null && !this.state.open) { if (this.props.openRight) { // If openRight is true calculate from the far right if (touchStartX < document.body.offsetWidth - swipeAreaWidth) return; } else { // If openRight is false calculate from the far left if (touchStartX > swipeAreaWidth) return; } } if (!this.state.open && (openNavEventHandler !== this._onBodyTouchStart || this.props.disableSwipeToOpen) ) { return; } 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) { const leftNav = ReactDOM.findDOMNode(this.refs.clickAwayableElement); const transformCSS = 'translate3d(' + (this._getTranslateMultiplier() * translateX) + 'px, 0, 0)'; this.refs.overlay.setOpacity(1 - translateX / this._getMaxTranslateX()); autoPrefix.set(leftNav.style, 'transform', transformCSS, this.state.muiTheme); }, _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) { const currentX = e.touches[0].pageX; const currentY = e.touches[0].pageY; if (this.state.swiping) { e.preventDefault(); this._setPosition(this._getTranslateX(currentX)); } else if (this._maybeSwiping) { const dXAbs = Math.abs(currentX - this._touchStartX); const 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. const 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) { const currentX = e.changedTouches[0].pageX; const translateRatio = this._getTranslateX(currentX) / this._getMaxTranslateX(); this._maybeSwiping = false; const 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('swipe'); } } else { if (swiping === 'opening') { this._open('swipe'); } 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); }, render() { const { className, docked, header, menuItemClassName, menuItemClassNameSubheader, menuItemClassNameLink, menuItems, openRight, overlayClassName, overlayStyle, selectedIndex, style, } = this.props; const styles = this.getStyles(); let overlay; if (!docked) { overlay = ( <Overlay ref="overlay" show={this._shouldShow()} className={overlayClassName} style={this.mergeStyles(styles.overlay, overlayStyle)} transitionEnabled={!this.state.swiping} onTouchTap={this._onOverlayTouchTap} /> ); } let children; if (menuItems === undefined) { children = this.props.children; } else { children = ( <Menu ref="menuItems" style={this.mergeStyles(styles.menu)} zDepth={0} menuItems={menuItems} menuItemStyle={this.mergeStyles(styles.menuItem)} menuItemStyleLink={this.mergeStyles(styles.menuItemLink)} menuItemStyleSubheader={this.mergeStyles(styles.menuItemSubheader)} menuItemClassName={menuItemClassName} menuItemClassNameSubheader={menuItemClassNameSubheader} menuItemClassNameLink={menuItemClassNameLink} selectedIndex={selectedIndex} onItemTap={this._onMenuItemClick} /> ); } return ( <div> {overlay} <Paper ref="clickAwayableElement" zDepth={2} rounded={false} transitionEnabled={!this.state.swiping} className={className} style={this.mergeStyles(styles.root, openRight && styles.rootWhenOpenRight, style)} > {header} {children} </Paper> </div> ); }, }); export default LeftNav;