HBM/md-components

View on GitHub
src/js/select/index.js

Summary

Maintainability
D
2 days
Test Coverage

import React from 'react'
import PropTypes from 'prop-types'
import keycode from 'keycode'
import classnames from 'classnames'

const PADDING_LEFT = 16
let LIST_ITEM_HEIGHT = 48

// list length
const MAX_LIST_LENGTH = 5

const defaultFindIndex = (options, filterValue, startIndex) => {
  filterValue = filterValue.toLowerCase()
  let findIndex = options.findIndex(({label}, i) => (i > startIndex && label.toLowerCase().startsWith(filterValue)))
  if (findIndex === -1) {
    findIndex = options.findIndex(({label}) => (label.toLowerCase().startsWith(filterValue)))
  }
  return findIndex
}

export default class Select extends React.Component {
  static propTypes = {
    disabled: PropTypes.bool,
    label: PropTypes.string,
    options: PropTypes.array,
    placeholder: PropTypes.string,
    onChange: PropTypes.func.isRequired,
    name: PropTypes.string,
    findIndex: PropTypes.func,
    dense: PropTypes.bool
  }

  static defaultProps = {
    options: [
      {value: 'one', label: 'one'},
      {value: 'two', label: 'two'},
      {value: 'three', label: 'three'}
    ],
    placeholder: 'Placeholder',
    value: '',
    findIndex: defaultFindIndex,
    dense: false
  }

  state = {
    open: false
  }

  onKeyDown = event => {
    if (event.which === keycode('enter') || event.which === keycode('space')) {
      event.preventDefault()
      this.open()
      return
    }
    const {options, value} = this.props
    const index = options.findIndex(option => option.value === value)
    if (event.which === keycode('up') || event.which === keycode('left')) {
      event.preventDefault()
      if (index > 0) {
        const prev = options[index - 1]
        return this.props.onChange({
          target: {
            name: this.props.name,
            ...prev
          }
        })
      }
    }
    if (event.which === keycode('down') || event.which === keycode('right')) {
      event.preventDefault()
      if (index < options.length - 1) {
        const next = options[index + 1]
        return this.props.onChange({
          target: {
            name: this.props.name,
            ...next
          }
        })
      }
    }

    if (event.key) {
      const findIndex = this.props.findIndex(options, event.key, index)
      if (findIndex !== -1) {
        event.preventDefault()
        const next = options[findIndex]
        return this.props.onChange({
          target: {
            name: this.props.name,
            ...next
          }
        })
      }
    }
  }

  open = () => {
    if (this.props.disabled) { return }
    this.setState({
      open: true
    })
    document.addEventListener('click', this.close)
  }

  close = () => {
    this.setState({
      open: false
    })
    document.removeEventListener('click', this.close)
  }

  findTableTag = (node) => {
    while (node.parentNode) {
      node = node.parentNode
      if (node.tagName === 'TABLE') {
        return true
      }
    }
    return false
  }

  componentDidMount () {
    const isInsideTable = this.findTableTag(this.refWrapper)
    this.setState({
      isInsideTable
    })
  }

  onChange = (item) => {
    this.props.onChange({
      target: {
        name: this.props.name,
        ...item
      }
    })
  }

  onEnter = item => {
    this.props.onChange({
      target: {
        name: this.props.name,
        ...item
      }
    })
    this.onEscape()
  }

  onEscape = () => {
    this.close()
    this.refInput.focus()
  }

  shouldComponentUpdate (nextProps, nextState) {
    if (this.state.open && nextState.open) {
      return false
    }
    return true
  }

  render () {
    const selectedIndex = this.props.options.findIndex(option => option.value === this.props.value)
    const empty = selectedIndex === -1
    const text = empty ? this.props.placeholder : this.props.options[selectedIndex].label

    return (
      <div
        className={classnames('mdc-Select', {
          'mdc-Select--dense': this.props.dense,
          'is-insideTable': this.state.isInsideTable
        })}
        ref={(c) => { this.refWrapper = c }}
      >
        <input
          className='mdc-Select-input'
          name={this.props.name}
          value={this.props.value}
          disabled={this.props.disabled}
          onKeyDown={this.onKeyDown}
          ref={elem => { this.refInput = elem }}
          readOnly
        />
        {
          this.props.label && <span className='mdc-Select-label'>{this.props.label}</span>
        }
        <div
          className='mdc-Select-body'
          onClick={this.open}
        >
          <span className={empty ? 'mdc-Select-placeholder' : ''}>
            {text}
          </span>
          <span className='mdc-Select-caret' />
        </div>
        {
          this.state.open &&
          <List
            options={this.props.options}
            selectedIndex={selectedIndex}
            onClick={this.onChange}
            onEnter={this.onEnter}
            refWrapper={this.refWrapper}
            isInsideTable={this.state.isInsideTable}
            onEscape={this.onEscape}
            findIndex={this.props.findIndex}
            dense={this.props.dense}
          />
        }
        <div className={classnames('mdc-Select-helper', {
          'mdc-Select-helper--error': this.props.error,
          'mdc-Select-helper--dense': this.props.dense
        })}>
          {this.props.error || this.props.helper}
        </div>
      </div>
    )
  }
}

export class List extends React.Component {
  static propTypes = {
    options: PropTypes.array.isRequired,
    isInsideTable: PropTypes.bool,
    selectedIndex: PropTypes.number.isRequired,
    onClick: PropTypes.func.isRequired,
    refWrapper: PropTypes.object.isRequired,
    findIndex: PropTypes.func
  }

  state = {
    left: this.props.refWrapper.getBoundingClientRect().left,
    top: this.props.refWrapper.getBoundingClientRect().top,
    width: this.props.refWrapper.getBoundingClientRect().width
  }

  componentDidMount () {
    LIST_ITEM_HEIGHT = this.props.dense ? 36 : LIST_ITEM_HEIGHT
    const index = this.props.selectedIndex

    // create boolean helper variables
    const scrollable = this.props.options.length > MAX_LIST_LENGTH
    const indexWithinFirstTwoItems = index < 2

    if (scrollable && !indexWithinFirstTwoItems) {
      const scrollTop = (LIST_ITEM_HEIGHT * (index - 2))
      this.ref.scrollTop = scrollTop
    }

    const focus = index >= 0 ? index : 0
    this[`li${focus}`].focus()

    window.addEventListener('resize', this.resize)
  }

  componentWillUnmount () {
    window.removeEventListener('resize', this.resize)
  }

  resize = () => {
    const {left, top, width} = this.props.refWrapper.getBoundingClientRect()
    this.setState({
      left,
      top,
      width
    })
  }

  onClick = event => {
    const index = parseInt(event.currentTarget.getAttribute('data-id'))
    this.props.onClick(this.props.options[index])
  }

  onBlur = event => {
    // close list when focus is outside of list
    if (event.relatedTarget && !event.relatedTarget.classList.contains('mdc-Select-listItem')) {
      this.props.onEscape()
    }
  }

  onKeyDown = event => {
    const index = parseInt(event.currentTarget.getAttribute('data-id'))
    if (event.which === keycode('enter')) {
      return this.props.onEnter(this.props.options[index])
    }
    if (event.which === keycode('escape')) {
      return this.props.onEscape()
    }
    if (event.which === keycode('down')) {
      event.preventDefault()
      if (event.target.nextSibling) {
        return event.target.nextSibling.focus()
      }
    }
    if (event.which === keycode('up')) {
      event.preventDefault()
      if (event.target.previousSibling) {
        return event.target.previousSibling.focus()
      }
    }

    const findIndex = this.props.findIndex(this.props.options, event.key, index)
    if (findIndex !== -1) {
      event.preventDefault()
      this[`li${findIndex}`].focus()
    }
  }

  render () {
    LIST_ITEM_HEIGHT = this.props.dense ? 36 : LIST_ITEM_HEIGHT
    // CSS space
    let PADDING_TOP = -20
    if (this.props.isInsideTable) {
      // when select component is inside a table cell we have to remove
      // border bottom, padding top and padding bottom.
      // this changes the location of the text.
      // that leads to the list menu / overlay no being directly on top of the text.
      // we therefore have to modify the padding top to fix the overlay position.
      PADDING_TOP = 24
    }

    if (this.props.dense) {
      PADDING_TOP = -13
    }

    const {options, selectedIndex} = this.props

    // handle list absolute position top
    const paddingTop = PADDING_TOP

    let top
    if (selectedIndex === -1) {
      // set position to first element, i.e. selectedIndex = 0
      top = -1 * paddingTop
    } else {
      top = -1 * (paddingTop + (LIST_ITEM_HEIGHT * selectedIndex))
    }

    // handle scrollable lists with more than 5 list items
    if (options.length > MAX_LIST_LENGTH) {
      if (selectedIndex >= 2 && selectedIndex <= options.length - 3) {
        // handle "center" items => always set to third position
        top = -1 * (paddingTop + (LIST_ITEM_HEIGHT * 2))
      } else if (selectedIndex === options.length - 2) {
        // handle second last item
        top = -1 * (paddingTop + (LIST_ITEM_HEIGHT * 3))
      } else if (selectedIndex === options.length - 1) {
        // handle last item
        top = -1 * (paddingTop + (LIST_ITEM_HEIGHT * 4))
      }
    }

    let width = this.state.width + (2 * PADDING_LEFT)
    let left = this.props.isInsideTable ? -17 : -16
    let padding = 16

    // check if select overlay is wider than window which would cause horizontal overflow
    // if so decrease padding left and right from 16px to 8px
    // adjust absolute position left and inner link padding accordingly
    if (width > document.body.clientWidth) {
      width = this.state.width + PADDING_LEFT
      left = -8
      padding = 8
    }

    // select component is position fixed
    // add top and left from select component
    top = top + this.state.top
    left = left + this.state.left

    // Checks that the attempted overlay position will fit within the viewport.
    // If it will not fit, tries to adjust the position 'top' so the panel can open fully on-screen.
    // If the select panel is very bottom, tries to adjust the position 'top' so the panel can open fully on-screen.
    // If it still won't fit, sets the offset back to 0 to allow the fallback position to take over.
    const innerHeight = window.innerHeight
    let listHeight = LIST_ITEM_HEIGHT * Math.min(MAX_LIST_LENGTH, options.length)
    if (this.props.isInsideTable) {
      listHeight += PADDING_TOP
    } else {
      listHeight -= PADDING_TOP
    }
    if (top < 0) {
      top = 0
    } else if (top + listHeight > innerHeight) {
      top = Math.max(0, innerHeight - listHeight)
    }
    const style = {top, width, left}

    return (
      <ul
        ref={(c) => { this.ref = c }}
        className='mdc-Select-list'
        style={style}
      >
        {options.map((item, i) =>
          <li key={i}
            tabIndex='0'
            className='mdc-Select-listItem'
            data-id={i}
            onClick={this.onClick}
            style={{padding: `0 ${padding}px`}}
            onKeyDown={this.onKeyDown}
            onBlur={this.onBlur}
            ref={elem => { this[`li${i}`] = elem }}
          >
            {item.label}
          </li>
        )}
      </ul>
    )
  }
}