Home Reference Source

src/search/SearchControl.js

/**
 * @typedef {g4uControlOptions} SearchControlOptions
 * @property {number} [amountDropdownEntries=4] number of entries shown in the dropdown
 * @property {number} [autocompleteStart=2] count of letters after which the autocomplete starts.
 *    if set to -1 autocomplete is disabled
 * @property {number} [autocompleteDelay=300]
 * @property {number} [slideDuration=400] time it takes for the dropdown to slide down
 * @property {SearchConnectorOptions} connector options of the connector to use. At the moment 'nominatim' is
 *    delivered within this module.
 * @property {StyleLike} [style] of the search results
 * @property {boolean} [animated] affects the move to the search results.
 * @property {string} [placeholder] text to be seen in the input field if the user has made no input yet
 * @property {string} [ghostentry] text to be seen in the dropdown if the autocomplete or search didn't find
 * @property {string} [deactivateMobileSearch='exactResult']  other possible values are 'never' and 'anyResult'
 */
import $ from 'jquery'
import Feature from 'ol/Feature'

import { addTooltip } from '../html/html'
import { Dropdown } from '../html/Dropdown'
import { keyCodes } from '../globals'
import { html2Text } from '../utilities'
import { Control } from '../controls/Control'
import { SearchView } from './SearchView'
import { filterText } from '../xssprotection'

import '../../less/searchcontrol.less'

import 'polyfill!Element.prototype.placeholder'

const DeactivateMobileSearch = {
  NEVER: 'never',
  ANY: 'anyResult',
  EXACT: 'exactResult'
}

/**
 *
 * @fires 'searchEnd' with bool parameter `success`
 */
export class SearchControl extends Control {
  /**
   * @param {SearchModule} module
   * @param {SearchControlOptions} options
   */
  constructor (module, options) {
    options.className = options.className || 'g4u-search-control'
    options.element = $('<div>').get(0)
    options.singleButton = false

    super(options)

    /**
     * @type {SearchModule}
     * @private
     */
    this.module_ = module

    if (this.getLocaliser().isRtl()) {
      this.get$Element().prop('dir', 'rtl')
    }

    /**
     * @type {SearchView}
     * @private
     */
    this.searchView_ = new SearchView({ style: options.style })

    options.connector.localiser = this.getLocaliser()

    this.searchConnector_ = this.module_.getConnector(options.connector)

    /**
     * @type {string}
     * @private
     */
    this.classNameTextfield_ = this.className_ + '-textfield'

    /**
     * @type {string}
     * @private
     */
    this.classNameSearchbutton_ = this.className_ + '-searchbutton'

    /**
     * @type {number}
     * @private
     */
    this.amountDropdownEntries_ = (options.hasOwnProperty('amountDropdownEntries')) ? options.amountDropdownEntries : 4

    /**
     * @type {number}
     * @private
     */
    this.autocompleteStart_ = (options.hasOwnProperty('autocompleteStart')) ? options.autocompleteStart : 2
    const slideDuration = options.slideDuration || 400

    /**
     * @type {number}
     * @private
     */
    this.autocompleteDelay_ = options.hasOwnProperty('autocompleteDelay') ? options.autocompleteDelay : 300

    /**
     * @type {boolean}
     * @private
     */
    this.animated_ = options.animated

    /**
     * @type {string}
     * @private
     */
    this.deactivateMobileSearch_ = options.hasOwnProperty('deactivateMobileSearch')
      ? options.deactivateMobileSearch
      : DeactivateMobileSearch.EXACT

    /**
     * @type {string}
     * @private
     */
    this.projectionOfServer_ = options.projectionOfServer

    const placeholder = (options.hasOwnProperty('placeholder'))
      ? this.getLocaliser().selectL10N(options.placeholder)
      : this.getLocaliser().localiseUsingDictionary('SearchControl placeholder')

    /**
     * @type {jQuery}
     * @private
     */
    this.$textfield_ = $('<input autocomplete="off" type="search">')
      .prop('placeholder', placeholder)
      .addClass(this.classNameTextfield_)

    /**
     * @type {jQuery}
     * @private
     */
    this.$submitButton_ = $('<button>')
      .addClass(this.classNameSearchbutton_)
      .text('S')
      .on('click', () => this.onSubmit_())

    addTooltip(this.$submitButton_, this.getLocaliser().localiseUsingDictionary('SearchControl searchButton'))

    /**
     * @type {Dropdown}
     * @private
     */
    this.dropdown_ = new Dropdown({
      ghostentry: options.hasOwnProperty('ghostentry')
        ? options.ghostentry
        : this.getLocaliser().localiseUsingDictionary('SearchControl noSearchResults'),
      slideDuration: slideDuration
    })

    this.dropdown_.on('select', () => this.onDropdownSelect_())

    /**
     * @type {boolean}
     * @private
     */
    this.dropdownActive_ = false

    /**
     * @type {Array}
     * @private
     */
    this.dropdownData_ = []

    /**
     * @type {boolean}
     * @private
     */
    this.active_ = false

    this.$textfield_.on('input', e => {
      this.onTextInput_(e)
    })

    // Keyevents in the whole form
    this.get$Element().on('keydown', e => {
      // slide up dropdown
      if (e.which === keyCodes.ESCAPE) {
        this.dropdown_.slideUp()
        $(this.getMap().getViewport()).focus()
      } else if (e.which === keyCodes.ENTER) {
        this.onSubmit_()
      }
    })

    // Keyevents only in the textfields
    this.$textfield_.on('keydown', e => {
      if (e.which === keyCodes.ARROW_DOWN) {
        if (this.dropdown_.isSelectable()) {
          this.dropdown_.focus()
        }
      } else if ((e.which === keyCodes.TAB) && !e.shiftKey) {
        this.dropdown_.slideUp()
      }
    })

    // Keyevents only in the dropdown
    this.dropdown_.on('leave:backwards', e => {
      e.originalEvent.preventDefault()
      this.$textfield_.focus()
    })

    this.dropdown_.on('leave:forwards', e => {
      e.originalEvent.preventDefault()
      this.$submitButton_.focus()
    })

    // Assembling Element
    this.get$Element()
      .append(this.$textfield_)
      .append(this.$submitButton_)
      .append(this.dropdown_.get$Element())
  }

  /**
   * @param {?G4UMap} map
   */
  setMap (map) {
    if (map) {
      // search view
      this.searchView_.setMap(map)

      // search connector
      this.searchConnector_.setMap(map)

      // slide up the dropdown if clicked outside of the searchControl, slide it down if clicked inside
      let slideUp

      document.addEventListener('click', () => {
        slideUp = !map.get('mobile')
      }, true)

      $(map.getViewport()).find('.ol-overlaycontainer-stopevent')
        .add(document)
        .on('click', () => {
          if (slideUp) {
            this.setActive(false)
          }
        })

      this.$textfield_.on('click', () => {
        slideUp = false
        this.setActive(true)
      })
    }
    super.setMap(map)
  }

  /**
   * @param {boolean} active
   */
  setActive (active) {
    const oldValue = this.active_
    if (oldValue !== active) {
      if (active) {
        if (this.dropdownActive_) {
          this.dropdown_.slideDown()
        }
        setTimeout(() => this.$textfield_.focus(), 0)
      } else {
        if (this.dropdownActive_) {
          this.dropdown_.slideUp()
        }
      }

      this.active_ = active
      this.dispatchEvent({
        type: 'change:active',
        oldValue
      })
    }
  }

  getActive () {
    return this.active_
  }

  /**
   * @private
   */
  updateDropdown_ (dropdownTexts, data) {
    const dropdownContainsOnlyInput = this.dropdown_.getValue() &&
      dropdownTexts.length === 1 && this.dropdown_.getText() === html2Text(dropdownTexts[0])

    if (dropdownContainsOnlyInput || dropdownTexts.length === 0) {
      this.dropdown_.setLength(0)
      this.dropdownActive_ = false
      return this.dropdown_.slideUp().then(() => this.changed())
    } else {
      const length = Math.min(this.amountDropdownEntries_, dropdownTexts.length)
      this.dropdown_.setEntries(data.slice(0, length), dropdownTexts.slice(0, length))
      this.dropdownActive_ = true
      return this.dropdown_.slideDown().then(() => this.changed())
    }
  }

  /**
   * @private
   */
  onDropdownSelect_ () {
    const dropdownData = this.dropdown_.getValue()

    this.$textfield_.val(html2Text(this.dropdown_.getText()))

    this.updateDropdown_([], [])

    if (dropdownData instanceof Feature) {
      this.onSearchEnd_([dropdownData])
    } else {
      this.searchConnector_.getByHandle(dropdownData)
        .then(feature => {
          this.onSearchEnd_([feature])
        })
    }
  }

  /**
   * @private
   */
  onTextInput_ () {
    this.searchView_.hideSearchResults()

    clearTimeout(this.autocompleteTimeout_)
    this.autocompleteTimeout_ = setTimeout(() => {
      // checking if autocomplete search should be performed and perform it
      const searchtext = this.$textfield_.val()
      if (this.autocompleteStart_ >= 0 && searchtext.length >= this.autocompleteStart_) {
        this.searchConnector_.getAutoComplete(searchtext)
          .then(([dropdownTexts, data]) => {
            this.updateDropdown_(dropdownTexts, data)
          })
      } else if (this.autocompleteStart_ >= 0) {
        this.dropdown_.slideUp()
      }
    }, this.autocompleteDelay_)
  }

  /**
    * @private
   */
  onSubmit_ () {
    const searchstring = this.$textfield_.val()

    if (!this.dropdown_.getValue() || searchstring !== html2Text(this.dropdown_.getText())) {
      this.searchView_.hideSearchResults()

      if (searchstring !== '') {
        this.searchConnector_.getSearchResult(searchstring)
          .then(([dropdownTexts, data]) => {
            this.updateDropdown_(dropdownTexts, data)
            this.onSearchEnd_(data)
          })
      } else {
        this.updateDropdown_([], [])
      }
    }
  }

  onSearchEnd_ (features) {
    if (features.length > 0) {
      this.searchView_.showSearchResults(features)

      const isExact = features.length === 1

      if (!this.getMap().get('mobile') && isExact) {
        // exact search result desktop
        const featurePopup = this.getMap().get('featurePopup')
        featurePopup.setFeature(features[0], null, features[0].getStyle() || this.searchView_.getStyle())
        featurePopup.setVisible(true, false)
        // featurePopup.update(false)
        featurePopup.centerMapOnPopup()
      } else {
        this.searchView_.centerOnSearchlayer()

        if ((isExact && this.deactivateMobileSearch_ === DeactivateMobileSearch.EXACT) ||
          this.deactivateMobileSearch_ === DeactivateMobileSearch.ANY) {
          this.setActive(false)
        }
      }

      this.dispatchEvent({
        type: 'searchEnd',
        success: true,
        searchTerm: this.getSanitizedSearchValue()
      })
    } else {
      this.dropdown_.showGhostEntry()

      this.dispatchEvent({
        type: 'searchEnd',
        success: false,
        searchTerm: this.getSanitizedSearchValue()
      })
    }
  }

  /**
   * @returns {string}
   */
  getSanitizedSearchValue () {
    return filterText(this.$textfield_.val())
  }

  /**
   * @returns {SearchView}
   */
  getSearchView () {
    return this.searchView_
  }
}