instacart/Snacks

View on GitHub
src/components/Menus/DropdownMenu.js

Summary

Maintainability
A
3 hrs
Test Coverage
import React from 'react'
import { findDOMNode } from 'react-dom'
import PropTypes from 'prop-types'
import Radium from '@instacart/radium'
import Menu from './Menu'
import Slide from '../Transitions/Slide'
import Fade from '../Transitions/Fade'

const styles = {
  menuContainer: {
    position: 'absolute',
    width: '100%',
    display: 'block',
    pointerEvents: 'none',
  },
  menuContainerClosed: {
    zIndex: -9000,
    pointerEvents: 'none',
  },
  menuContainerOpen: {
    zIndex: 9000,
    pointerEvents: 'inherit',
  },
  transitionContainer: {
    paddingTop: '2px',
    paddingRight: '5px',
    paddingBottom: '5px',
    paddingLeft: '5px',
    width: '100%',
    transform: 'translateX(-5px)',
  },
}

@Radium
class DropdownMenu extends React.Component {
  static propTypes = {
    /** MenuItems or Divider */
    children: PropTypes.node.isRequired,
    /** React component or HTML that will be used as the dropdown trigger. ie. Button or Icon */
    triggerElement: PropTypes.element,
    /** Open and close menu manually */
    open: PropTypes.bool,
    /** Callback fired on dropdown close */
    onClose: PropTypes.func,
    /** Callback fired on click */
    onClick: PropTypes.func,
    /** Callback fired on dropdown open */
    onOpen: PropTypes.func,
    /** Callback function fired on key down */
    onKeyDown: PropTypes.func,
    /** Requesting to open/close for controlled open component. */
    onRequestChange: PropTypes.func,
    /** Callback function fired when a MenuItem is selected */
    onSelect: PropTypes.func,
    /** Customize style root element */
    style: PropTypes.shape({}),
    /** Customize style of menu parent */
    menuContainerStyle: PropTypes.shape({}),
    /** Customize style of menu parent when dropdown is open */
    menuContainerOpenStyle: PropTypes.shape({}),
    /** Props passed to Menu component */
    menuProps: PropTypes.shape({}),
  }

  static defaultProps = {
    open: null,
  }

  state = {
    open: false,
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.open !== null && this.props.open !== nextProps.open) {
      if (nextProps.open) {
        this.open()
      } else {
        this.close()
      }
    }
  }

  handleSelect = (e, option) => {
    const { onSelect, onRequestChange } = this.props

    if (this.controlledOpen()) {
      onRequestChange && onRequestChange(false)
    } else {
      // if multi then do not close
      this.close()
    }

    findDOMNode(this.trigger).focus()
    onSelect && onSelect(e, option)
  }

  handleClick = event => {
    event.preventDefault()
    const { open, onClick, onRequestChange } = this.props

    if (this.controlledOpen()) {
      onRequestChange && onRequestChange(!open)
      return
    }

    if (!this.state.open) {
      this.open()
    } else {
      this.close()
    }
    onClick && onClick(event)
  }

  handleMenuBlur = () => {
    const { onRequestChange } = this.props
    if (this.controlledOpen()) {
      onRequestChange && onRequestChange(false)
    } else {
      this.close()
    }
  }

  handleKeyDown = event => {
    const { onRequestChange } = this.props
    switch (event.key) {
      case 'Enter':
        if (!event.defaultPrevented && this.controlledOpen()) {
          event.preventDefault()
          onRequestChange && onRequestChange(true)
        } else {
          event.preventDefault()
          if (this.state.open) {
            this.close()
          } else {
            this.open()
          }
        }
        break
      case 'Escape':
        event.preventDefault()

        if (this.controlledOpen()) {
          onRequestChange && onRequestChange(false)
        } else {
          this.state.open && this.close()
        }
        break
    }
  }

  controlledOpen() {
    return this.props.open !== null
  }

  open() {
    const { onOpen } = this.props
    this.setState({ open: true }, () => {
      this.menu.focus()
      onOpen && onOpen()
    })
  }

  close() {
    const { onClose } = this.props
    this.setState({ open: false }, () => {
      this.menu.blur()
      onClose && onClose()
    })
  }

  renderTriggerElement() {
    const { triggerElement } = this.props
    const { open } = this.state
    if (triggerElement) {
      return React.cloneElement(triggerElement, {
        ref: node => {
          this.trigger = node
          const { ref } = triggerElement
          if (typeof ref === 'function') {
            ref(node)
          }
        },
        onMouseDown: this.handleClick,
        elementAttributes: {
          'aria-haspopup': true,
          'aria-expanded': open,
        },
        'aria-haspopup': true,
        'aria-expanded': open,
      })
    }
  }

  render() {
    const { style, children, menuProps, menuContainerStyle, menuContainerOpenStyle } = this.props

    const isOpen = this.state.open

    return (
      <div onKeyDown={this.handleKeyDown} style={style}>
        <div style={{ position: 'relative' }}>{this.renderTriggerElement()}</div>
        <div
          style={[
            styles.menuContainer,
            menuContainerStyle,
            isOpen && [styles.menuContainerOpen, menuContainerOpenStyle],
            !isOpen && styles.menuContainerClosed,
          ]}
          data-testid="menu-container"
        >
          <Slide in={isOpen} axis="y" style={styles.transitionContainer} offset={30}>
            <Fade in={isOpen} transitionTime={200}>
              <Menu
                ref={node => (this.menu = node)}
                ariaHidden={!isOpen}
                onBlur={this.handleMenuBlur}
                onSelect={this.handleSelect}
                {...menuProps}
              >
                {children}
              </Menu>
            </Fade>
          </Slide>
        </div>
      </div>
    )
  }
}

export default DropdownMenu