Spaces:
Runtime error
Runtime error
| import PropTypes from 'prop-types'; | |
| import React from 'react'; | |
| import classNames from 'classnames'; | |
| import bindAll from 'lodash.bindall'; | |
| import ReactTooltip from 'react-tooltip'; | |
| import styles from './action-menu.css'; | |
| const CLOSE_DELAY = 300; // ms | |
| class ActionMenu extends React.Component { | |
| constructor (props) { | |
| super(props); | |
| bindAll(this, [ | |
| 'clickDelayer', | |
| 'handleClosePopover', | |
| 'handleToggleOpenState', | |
| 'handleTouchStart', | |
| 'handleTouchOutside', | |
| 'setButtonRef', | |
| 'setContainerRef' | |
| ]); | |
| this.state = { | |
| isOpen: false, | |
| forceHide: false | |
| }; | |
| this.mainTooltipId = `tooltip-${Math.random()}`; | |
| } | |
| componentDidMount () { | |
| // Touch start on the main button is caught to trigger open and not click | |
| this.buttonRef.addEventListener('touchstart', this.handleTouchStart); | |
| // Touch start on document is used to trigger close if it is outside | |
| document.addEventListener('touchstart', this.handleTouchOutside); | |
| } | |
| shouldComponentUpdate (newProps, newState) { | |
| // This check prevents re-rendering while the project is updating. | |
| // @todo check only the state and the title because it is enough to know | |
| // if anything substantial has changed | |
| // This is needed because of the sloppy way the props are passed as a new object, | |
| // which should be refactored. | |
| return newState.isOpen !== this.state.isOpen || | |
| newState.forceHide !== this.state.forceHide || | |
| newProps.title !== this.props.title; | |
| } | |
| componentWillUnmount () { | |
| this.buttonRef.removeEventListener('touchstart', this.handleTouchStart); | |
| document.removeEventListener('touchstart', this.handleTouchOutside); | |
| } | |
| handleClosePopover () { | |
| this.closeTimeoutId = setTimeout(() => { | |
| this.setState({isOpen: false}); | |
| this.closeTimeoutId = null; | |
| }, CLOSE_DELAY); | |
| } | |
| handleToggleOpenState () { | |
| // Mouse enter back in after timeout was started prevents it from closing. | |
| if (this.closeTimeoutId) { | |
| clearTimeout(this.closeTimeoutId); | |
| this.closeTimeoutId = null; | |
| } else if (!this.state.isOpen) { | |
| this.setState({ | |
| isOpen: true, | |
| forceHide: false | |
| }); | |
| } | |
| } | |
| handleTouchOutside (e) { | |
| if (this.state.isOpen && !this.containerRef.contains(e.target)) { | |
| this.setState({isOpen: false}); | |
| ReactTooltip.hide(); | |
| } | |
| } | |
| clickDelayer (fn) { | |
| // Return a wrapped action that manages the menu closing. | |
| // @todo we may be able to use react-transition for this in the future | |
| // for now all this work is to ensure the menu closes BEFORE the | |
| // (possibly slow) action is started. | |
| return event => { | |
| ReactTooltip.hide(); | |
| if (fn) fn(event); | |
| // Blur the button so it does not keep focus after being clicked | |
| // This prevents keyboard events from triggering the button | |
| this.buttonRef.blur(); | |
| this.setState({forceHide: true, isOpen: false}, () => { | |
| setTimeout(() => this.setState({forceHide: false})); | |
| }); | |
| }; | |
| } | |
| handleTouchStart (e) { | |
| // Prevent this touch from becoming a click if menu is closed | |
| if (!this.state.isOpen) { | |
| e.preventDefault(); | |
| this.handleToggleOpenState(); | |
| } | |
| } | |
| setButtonRef (ref) { | |
| this.buttonRef = ref; | |
| } | |
| setContainerRef (ref) { | |
| this.containerRef = ref; | |
| } | |
| render () { | |
| const { | |
| className, | |
| img: mainImg, | |
| title: mainTitle, | |
| moreButtons, | |
| tooltipPlace, | |
| onClick | |
| } = this.props; | |
| return ( | |
| <div | |
| className={classNames(styles.menuContainer, className, { | |
| [styles.expanded]: this.state.isOpen, | |
| [styles.forceHidden]: this.state.forceHide | |
| })} | |
| ref={this.setContainerRef} | |
| onMouseEnter={this.handleToggleOpenState} | |
| onMouseLeave={this.handleClosePopover} | |
| > | |
| <button | |
| aria-label={mainTitle} | |
| className={classNames(styles.button, styles.mainButton)} | |
| data-for={this.mainTooltipId} | |
| data-tip={mainTitle} | |
| ref={this.setButtonRef} | |
| onClick={this.clickDelayer(onClick)} | |
| > | |
| <img | |
| className={styles.mainIcon} | |
| draggable={false} | |
| src={mainImg} | |
| /> | |
| </button> | |
| <ReactTooltip | |
| className={styles.tooltip} | |
| effect="solid" | |
| id={this.mainTooltipId} | |
| place={tooltipPlace || 'left'} | |
| /> | |
| <div className={styles.moreButtonsOuter}> | |
| <div className={styles.moreButtons}> | |
| {(moreButtons || []).map(({img, title, onClick: handleClick, | |
| fileAccept, fileChange, fileInput, fileMultiple}, keyId) => { | |
| const isComingSoon = !handleClick; | |
| const hasFileInput = fileInput; | |
| const tooltipId = `${this.mainTooltipId}-${title}`; | |
| return ( | |
| <div key={`${tooltipId}-${keyId}`}> | |
| <button | |
| aria-label={title} | |
| className={classNames(styles.button, styles.moreButton, { | |
| [styles.comingSoon]: isComingSoon | |
| })} | |
| data-for={tooltipId} | |
| data-tip={title} | |
| onClick={hasFileInput ? handleClick : this.clickDelayer(handleClick)} | |
| > | |
| <img | |
| className={styles.moreIcon} | |
| draggable={false} | |
| src={img} | |
| /> | |
| {hasFileInput ? ( | |
| <input | |
| accept={fileAccept} | |
| className={styles.fileInput} | |
| multiple={fileMultiple} | |
| ref={fileInput} | |
| type="file" | |
| onChange={fileChange} | |
| />) : null} | |
| </button> | |
| <ReactTooltip | |
| className={classNames(styles.tooltip, { | |
| [styles.comingSoonTooltip]: isComingSoon | |
| })} | |
| effect="solid" | |
| id={tooltipId} | |
| place={tooltipPlace || 'left'} | |
| /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| } | |
| ActionMenu.propTypes = { | |
| className: PropTypes.string, | |
| img: PropTypes.string, | |
| moreButtons: PropTypes.arrayOf(PropTypes.shape({ | |
| img: PropTypes.string, | |
| title: PropTypes.node.isRequired, | |
| onClick: PropTypes.func, // Optional, "coming soon" if no callback provided | |
| fileAccept: PropTypes.string, // Optional, only for file upload | |
| fileChange: PropTypes.func, // Optional, only for file upload | |
| fileInput: PropTypes.func, // Optional, only for file upload | |
| fileMultiple: PropTypes.bool // Optional, only for file upload | |
| })), | |
| onClick: PropTypes.func.isRequired, | |
| title: PropTypes.node.isRequired, | |
| tooltipPlace: PropTypes.string | |
| }; | |
| export default ActionMenu; | |