Home Reference Source

src/html/Dropdown.js

import $ from 'jquery'

import BaseObject from 'ol/Object'

import 'polyfill!Array.prototype.findIndex,Array.prototype.find'

import { cssClasses, keyCodes } from '../globals'
import { ListenerOrganizerMixin } from '../ListenerOrganizerMixin'
import { mixin } from '../utilities'

import '../../less/dropdown.less'

/**
 * @typedef {object} DropdownOptions
 * @property {string} [className='g4u-dropdown']
 * @property {string} [ghostentry='no entries'] This text is shown if the dropdown has no entries
 * @property {number} [slideDuration=400] standard slideDuration
 */

/**
 * @typedef {Object} Entry
 * @property {jQuery} $element
 * @property {*} value
 */

$.extend($.easing, {
  easeOutCirc: function (x, t, b, c, d) {
    return c * Math.sqrt(1 - (t = t / d - 1) * t) + b
  },
  easeInCirc: function (x, t, b, c, d) {
    return -c * (Math.sqrt(1 - (t /= d) * t) - 1) + b
  }
})

/**
 * A HTML Dropdown select.
 * The text entries in the list can be setted and changed and given a click handler.
 * @fires 'leave:backwards' This event is raised if the dropdown is left via the up arrow or shift+tab
 * @fires 'leave:forwards' This event is raised if the dropdown is left via the down arrow or tab
 * @fires 'close' dropdown closing without select
 * @fires 'select' dropdown closing with select
 */
export class Dropdown extends mixin(BaseObject, ListenerOrganizerMixin) {
  /**
   * @param {DropdownOptions} [options={}]
   */
  constructor (options = {}) {
    super()

    /**
     * @type {string}
     * @private
     */
    this.className_ = options.className || 'g4u-dropdown'

    /**
     * @type {Object.<string, string>}
     * @private
     */
    this.classNames_ = {
      entry: this.className_ + '-entry',
      selected: this.className_ + '-selected',
      ghost: this.className_ + '-ghostentry'
    }

    /**
     * @type {jQuery}
     * @private
     */
    this.$element_ = $('<div>')
      .addClass(this.className_)

    /**
     * @type {jQuery}
     */
    this.$ghostentry = $('<button tabindex="-1">')
      .addClass(this.classNames_.ghost)
      .html(options.ghostentry || 'no entries')

    /**
     * @type {number}
     * @private
     */
    this.slideDuration_ = options.slideDuration || 400

    /**
     * @type {Entry[]}
     * @private
     */
    this.entriesArray_ = []

    /**
     * @type {number}
     * @private
     */
    this.selectedIndex_ = -1

    // key handling

    this.setUpKeyboardHandling_()

    this.listenAt(this.$element_).on('focus', () => {
      if (this.selectedIndex_ > -1) {
        this.entriesArray_[this.selectedIndex_].$element.focus()
      }
    })

    this.listenAt(this.$element_).on('mousemove', e => {
      e.stopPropagation()
    })

    this.slideUp(true)

    let skipCollapse

    this.listenAt(this.$element_).on('click', () => {
      skipCollapse = true
    })

    this.listenAt([
      // $(this.getMap().getViewport()).find('.ol-overlaycontainer-stopevent'),
      document
    ]).on('click', () => {
      if (skipCollapse) {
        skipCollapse = false
      } else if (this.collapse_) {
        this.slideUp()
      }
    })
  }

  detach () {
    this.detachAllListeners()
  }

  /**
   * returns the value of the selected list element
   * @returns {*}
   */
  getValue () {
    if (this.selectedIndex_ >= 0) {
      return this.entriesArray_[this.selectedIndex_].value
    }
  }

  /**
   * returns the text of the current selected list element
   */
  getText () {
    if (this.selectedIndex_ >= 0) {
      return this.entriesArray_[this.selectedIndex_].text
    }
  }

  /**
   * @private
   */
  setUpKeyboardHandling_ () {
    this.listenAt(this.$element_).on('keydown', e => {
      switch (e.which) {
        case keyCodes.ARROW_DOWN:
          e.preventDefault()
          e.stopPropagation()
          if (this.selectedIndex_ < this.entriesArray_.length - 1) {
            this.entriesArray_[this.selectedIndex_ + 1].$element.addClass(this.classNames_.selected)
            this.entriesArray_[this.selectedIndex_ + 1].$element.focus()
            this.entriesArray_[this.selectedIndex_].$element.removeClass(this.classNames_.selected)
            this.selectedIndex_ += 1
          }
          break
        case keyCodes.ARROW_UP:
          e.preventDefault()
          e.stopPropagation()
          if (this.selectedIndex_ > 0) {
            this.entriesArray_[this.selectedIndex_ - 1].$element.addClass(this.classNames_.selected)
            this.entriesArray_[this.selectedIndex_ - 1].$element.focus()
            this.entriesArray_[this.selectedIndex_].$element.removeClass(this.classNames_.selected)
            this.selectedIndex_ -= 1
          } else {
            this.entriesArray_[this.selectedIndex_].$element.removeClass(this.classNames_.selected)
            this.selectedIndex_ = -1
            this.dispatchEvent({
              type: 'leave:backwards',
              originalEvent: e
            })
          }
          break
        case keyCodes.TAB:
          if (!e.shiftKey) {
            this.dispatchEvent({
              type: 'leave:forwards',
              originalEvent: e
            })
          } else {
            this.dispatchEvent({
              type: 'leave:backwards',
              originalEvent: e
            })
          }
          break
        case keyCodes.ENTER:
          e.stopPropagation()
          e.preventDefault()
          this.select$Entry_(this.entriesArray_[this.selectedIndex_].$element)
      }
    })
    this.listenAt(window).on('keydown', e => {
      if (this.collapse_ && e.which === keyCodes.ESCAPE) {
        this.slideUp()
        this.dispatchEvent('close')
      }
    })
  }

  /**
   * return the whole element
   * @returns {jQuery}
   */
  get$Element () {
    return this.$element_
  }

  /**
   * Returns the amount of dropdown entries
   * @returns {Number}
   */
  getLength () {
    return this.entriesArray_.length
  }

  /**
   * Adds an entry to the end of the dropdown list
   * @param {*} value
   * @param {string} [text=value]
   * @param {boolean} [optSelected=false]
   */
  addEntry (value, text, optSelected = false) {
    text = text || value

    const index = this.getLength()
    this.setLength(index + 1)

    const entry = this.entriesArray_[index]
    entry.text = text
    entry.$element.html(text)
    entry.value = value

    if (optSelected) {
      entry.$element.addClass(this.classNames_.selected)
      this.selectedIndex_ = index
    }
  }

  setActivated (value, active) {
    this.entriesArray_.find(o => o.value === value).$element.toggleClass(cssClasses.active, active)
  }

  setFastMode (value) {
    this.fastMode_ = value
  }

  /**
   * This function takes an array of entries (strings).
   * The length of the dropdown is set to the length of the arrays (they have to have the same length).
   * @param {any[]} values
   * @param {string[]} [texts=values]
   */
  setEntries (values, texts) {
    texts = texts || values
    this.setLength(values.length)

    for (let i = 0, ii = values.length; i < ii; i++) {
      this.entriesArray_[i].text = texts[i]
      this.entriesArray_[i].$element.html(texts[i])
      this.entriesArray_[i].value = values[i]
    }
  }

  select$Entry_ ($entry) {
    if (!$entry.hasClass(this.classNames_.selected)) {
      $entry.addClass(this.classNames_.selected)
      if (this.selectedIndex_ > -1) {
        this.entriesArray_[this.selectedIndex_].$element.removeClass(this.classNames_.selected)
      }
    }
    this.selectedIndex_ = this.entriesArray_.findIndex(el => el.$element === $entry)
    this.slideUp()
    this.dispatchEvent('select')
  }

  /**
   * This function corrects the number of entries in the dropdown. The content of the entries is not respected.
   * New entries are slided down, to be removed entries are slided up then removed.
   * @param {number} length
   */
  setLength (length) {
    if (this.selectedIndex_ > -1) {
      this.entriesArray_[this.selectedIndex_].$element.removeClass(this.classNames_.selected)
      this.selectedIndex_ = -1
    }

    let i, ii

    if (this.entriesArray_.length === 0) { // removing ghost entry
      this.$element_.empty()
    }

    if (length > this.entriesArray_.length) { // adding entries and dropdown handlers
      for (i = this.entriesArray_.length, ii = length; i < ii; i++) {
        const $entry = $('<button tabindex="-1">')
          .addClass(this.classNames_.entry)
          .hide()

        $entry.on('click', () => this.select$Entry_($entry))
        $entry.focus()

        this.$element_.append($entry)

        this.entriesArray_.push({
          $element: $entry
        })

        $entry.slideDown({ duration: this.slideDuration_ })
      }
    } else if (length < this.entriesArray_.length) { // removing entries
      for (i = this.entriesArray_.length - 1, ii = length; i >= ii; i--) {
        this.entriesArray_[i].$element.slideUp({
          duration: this.slideDuration_,
          complete: () => {
            this.entriesArray_.pop()
            this.$element_.children().last().remove()
          }
        })
      }
      if (this.selectedIndex_ >= length) { // correcting selected element
        this.selectedIndex_ = length - 1
      }
    }
  }

  showGhostEntry () {
    this.setLength(0)
    this.$element_.append(this.$ghostentry)
    this.slideDown()
  }

  focus () {
    if (this.entriesArray_.length >= 0) {
      if (this.selectedIndex_ < 0) {
        this.selectedIndex_ = 0
        this.entriesArray_[0].$element.addClass(this.classNames_.selected)
      }
      this.entriesArray_[this.selectedIndex_].$element.focus()
    }
  }

  /**
   * @param {boolean} [immediately=false] if setted to true the animation is skipped
   * @returns {Promise}
   */
  slideUp (immediately = false) {
    return new Promise(resolve => {
      this.collapse_ = false
      let duration = this.slideDuration_
      if (immediately || this.fastMode_) {
        duration = 0
      }
      this.$element_.slideUp({
        duration: duration,
        complete: () => {
          this.$element_.addClass(cssClasses.hidden)
          resolve()
        },
        easing: 'easeInCirc'
      })
    })
  }

  /**
   * @param {boolean} [immediately=false] if setted to true the animation is skipped
   * @returns {Promise}
   */
  slideDown (immediately = false) {
    return new Promise(resolve => {
      if (this.$element_.children().length > 0) {
        let duration = this.slideDuration_
        if (immediately || this.fastMode_) {
          duration = 0
        }
        this.$element_.removeClass(cssClasses.hidden)
        this.$element_.slideDown({
          easing: 'easeOutCirc',
          complete: () => {
            this.collapse_ = true
            resolve()
          },
          duration: duration
        })
      }
    })
  }

  /**
   * Removes all entries
   */
  clear () {
    this.slideUp()
    this.$element_.empty()
    this.entriesArray_ = []
    this.selectedIndex_ = -1
  }

  /**
   * Returns true if the dropdown has selectable elements
   * @returns {boolean}
   */
  isSelectable () {
    return (this.entriesArray_.length > 0)
  }
}