Source: ZoomSearch.js

/**
 * @module nyc/ZoomSearch
 */

import $ from 'jquery'

import Container from 'nyc/Container'
import Locator from 'nyc/Locator'
import AutoComplete from 'nyc/AutoComplete'

/**
 * @desc Abstract class for zoom and search controls
 * @public
 * @abstract
 * @class
 * @extends module:nyc/Container~Container
 * @fires module:nyc/ZoomSearch~ZoomSearch#search
 * @fires module:nyc/ZoomSearch~ZoomSearch#geolocate
 * @fires module:nyc/ZoomSearch~ZoomSearch#disambiguated
 */
class ZoomSearch extends Container {
	/**
	 * @desc  Create an instance of ZoomSearch
	 * @access protected
	 * @constructor
	 * @param {jQuery|Element|string} target The target
	 */
	constructor(target) {
		super($(ZoomSearch.HTML))
		$(target).append(this.getContainer())
		/**
		 * @private
		 * @member {boolean}
		 */
		this.isAddrSrch = true
		/**
		 * @private
		 * @member {jQuery}
		 */
		this.input = null
		/**
		 * @private
		 * @member {jQuery}
		 */
		this.list = null
		/**
		 * @private
		 * @member {jQuery}
		 */
		this.retention = null
		/**
		 * @private
		 * @member {jQuery}
		 */
		this.clear = null
		/**
		 * @private
		 * @member {AutoComplete}
		 */
		this.autoComplete = null
		this.input = this.find('.srch input')
		this.list = this.find('.srch ul')
		this.retention = this.find('ul.retention')
		this.clear = this.find('.srch .btn-x')
		this.hookupEvents(this.input)
	}
	/**
	 * @desc Handle the zoom event triggered by user interaction
	 * @public
	 * @abstract
	 * @method
	 * @param {jQuery.Event} event The event triggered by the zoom buttons
	 */
	zoom(event) {
		throw 'Not implemented'
	}
	/**
	 * @public
	 * @abstract
	 * @method
	 * @param {Object} feature The feature object
	 * @param {module:nyc/ZoomSearch~ZoomSearch.FeatureSearchOptions} options Describes how to convert feature
	 * @return {module:nyc/Locator~Locator.Result} The location
	 */
	featureAsLocation(feature, options) {
		throw 'Not implemented'
	}
	/**
	 * @desc Set or get the value of the search field
	 * @public
	 * @method
	 * @param {string=} val The value for the search field
	 * @return {string} The value of the search field
	 */
	val(val) {
		if (typeof val === 'string') {
			this.input.val(val)
			this.clearBtn()
		}
		return this.input.val()
	}
	/**
	 * @desc Displays possible address matches
	 * @public
	 * @method
	 * @param {module:nyc/Locator~Locator.Ambiguous} ambiguous Possible locations resulting from a geocoder search to display to the user
	 */
	disambiguate(ambiguous) {
		const possible = ambiguous.possible
		if (possible.length) {
			const list = this.list
			this.emptyList()
			possible.forEach(locateResult => {
				list.append(this.listItem({layerName: 'addr'}, locateResult))
			})
			this.showList(true)
		}
	}
	/**
	 * @desc Add searchable features
	 * @public
	 * @method
	 * @param {module:nyc/ZoomSearch~ZoomSearch.FeatureSearchOptions} options The options for creating a feature search
	 */
	setFeatures(options) {
		this.autoComplete = this.autoComplete || new AutoComplete()
		options.nameField = options.nameField || 'name'
		options.displayField = options.displayField || options.nameField
		if (options.placeholder) {
			this.input.attr('placeholder', options.placeholder)
		}
		this.sortAlphapetically(options).forEach(feature => {
			const location = this.featureAsLocation(feature, options)
			const li = this.listItem(options, location)
			this.retention.append(li)
		})
		this.emptyList()
	}
	/**
	 * @desc Remove searchable features
	 * @public
	 * @method
	 * @param {string} featureTypeName The featureTypeName used when the features were set
	 */
	removeFeatures(featureTypeName) {
		this.find('li.' + featureTypeName).remove()
	}
	/**
	 * @private
	 * @method
	 * @param {module:nyc/ZoomSearch~ZoomSearch.FeatureSearchOptions} options
	 * @return {Array<Object>} features
	 */
	sortAlphapetically(options) {
		const features = []
		options.features.forEach(feature => {
			if (feature.get) {
				features.push($.extend({}, feature))
			} else {
				features.push($.extend({
					get(prop) {
						return this.properties[prop]
					}
				}, feature))
			}
		})
		features.sort((a, b) => {
			const nameField = options.nameField
			if (a.get(nameField) < b.get(nameField)) return -1
			if (a.get(nameField) > b.get(nameField)) return 1
			return 0
		})
		return features
	}
	/**
	 * @private
	 * @method
	 * @param {module:nyc/ZoomSearch~ZoomSearch.FeatureSearchOptions} options
	 * @param {module:nyc/Locator~Locator.Result} data
	 * @return {jQuery}
	 */
	listItem(options, data) {
		const li = $('<li></li>')
		const displayField = options.displayField
		const name = `${data.data[displayField] || data.name}`
		li.addClass(options.layerName)
		if (options.layerName !== 'addr') {
			li.addClass('feature')
		}
		return li.addClass('notranslate')
			.attr({translate: 'no', title: name})
			.html(`<a href="#">${name}</a>`)
			.data('nameField', options.nameField)
			.data('displayField', displayField)
			.data('location', data)
			.click($.proxy(this.disambiguated, this))
	}
	/**
	 * @private
	 * @method
	 */
	emptyList() {
		const retention = this.retention
		this.find('.srch li').each((i, item) => {
			if (retention.find(`li[title="${item.title}"]`).length === 0) {
				retention.append(item)
			}
		})
		this.list.empty()
	}
	/**
	 * @private
	 * @method
	 * @param {jQuery.Event} event
	 */
	disambiguated(event) {
		let li = $(event.target)
		if (li.get(0).tagName.toUpperCase() !== 'LI') {
			li = li.parent()
		}
		const data = li.data('location')
		this.val(data.name)
		data.isFeature = li.hasClass('feature')
		this.trigger('disambiguated', data)
		li.parent().slideUp()
		this.emptyList()
	}
	/**
	 * @private
	 * @method
	 * @param {jQuery.Event} event
	 */
	 listClick(event) {
		 event = event.originalEvent || event
		 if (this.list.css('display') === 'block') {
			 const target = $(event.target)
			 if ($.contains(this.list.get(0), target.get(0))) {
				 if (this.autoComplete) {
					 target.trigger('click')
				 }
			 } else {
				 this.list.slideUp()
			 }
		 }
	 }
	/**
	 * @private
   * @method
	 * @param {jQuery} input
	 */
	hookupEvents(input) {
		input.on('keyup change', $.proxy(this.key, this))
		input.focus(() => input.select())
		this.find('.btn-z-in, .btn-z-out').click($.proxy(this.zoom, this))
		this.find('.btn-geo').click($.proxy(this.geolocate, this))
		this.clear.click($.proxy(this.clearTxt, this))
		$(document).mouseup($.proxy(this.listClick, this))
	}
	/**
	 * @private
	 * @method
	 * @param {jQuery.Event} event
	 */
	key(event) {
		if (event.keyCode === 13 && this.isAddrSrch) {
			this.triggerSearch()
			this.list.slideUp()
		} else {
			this.filterList()
		}
		this.clearBtn()
	}
	/**
	 * @private
	 * @method
	 */
	clearTxt() {
		this.val('')
		this.clearBtn()
	}
	/**
	 * @private
	 * @method
	 */
	clearBtn() {
		this.clear[this.val() ? 'show' : 'hide']()
	}
	/**
	 * @private
	 * @method
	 */
	filterList() {
		const typed = this.val().trim()
		if (this.autoComplete && typed) {
			this.autoComplete.filter(this.retention, this.list, typed)
		} else {
			this.emptyList()
		}
		this.showList(false)
	}
	/**
	 * @private
	 * @method
	 * @param {boolean}
	 */
	showList(focus) {
		this.list.slideDown(() => {
			if (focus) {
				this.list.children().first().find('a').attr('tabindex', 0).focus()
			}
		})
	}
	/**
	 * @private
	 * @method
	 */
	geolocate() {
		this.val('')
		this.trigger('geolocate')
	}
	/**
	 * @private
	 * @method
	 */
	triggerSearch() {
		const input = this.val().trim()
		if (input.length) {
			this.input.blur()
			this.trigger('search', input)
		}
	}
}

/**
 * @desc Object type to hold data about how to search features
 * @public
 * @typedef {Object}
 * @property {Array<Object|ol.Feature>} features The features to be searched
 * @property {string} layerName The name of the layer or feature type the features are from
 * @property {string} [nameField="name"] The name attribute field of the feature
 * @property {string=} displayField The name attribute field of the feature
 * @property {string=} placeholder A placeholder for the search field
 */
ZoomSearch.FeatureSearchOptions

/**
 * @desc The user has requested a search based on their text input
 * @event module:nyc/ZoomSearch~ZoomSearch#search
 * @type {string}
 */

/**
 * @desc The user has requested their geolcation
 * @event module:nyc/ZoomSearch~ZoomSearch#geolocate
 */

/**
 * @desc The user has chosen a location from a list of possible locations
 * @event module:nyc/ZoomSearch~ZoomSearch#disambiguated
 * @type {module:nyc/Locate~Locate.Result}
 */

/**
 * @private
 * @const
 * @type {string}
 */
ZoomSearch.HTML = '<div class="z-srch" role="toolbar">' +
	'<div class="srch" role="search">' +
		'<input class="rad-all" placeholder="Search for an address...">' +
		'<button class="btn-rnd btn-x"><span class="screen-reader-only">Clear</span></button>' +
		'<ul class="rad-all" role="region" label="Possible matches for your search"></ul>' +
	'</div>' +
	'<button class="btn-z-in btn-sq rad-all" data-zoom-incr="1" title="Zoom in">' +
		'<span class="screen-reader-only">Zoom in</span>' +
	'</button>' +
	'<button class="btn-z-out btn-sq rad-all" data-zoom-incr="-1" title="Zoom out">' +
		'<span class="screen-reader-only">Zoom out</span>' +
	'</button>' +
	'<button class="btn-geo btn-sq rad-all" title="Current location">' +
		'<span class="screen-reader-only">Current location</span>' +
	'</button>' +
	'<ul class="retention"></ul>' +
'</div>'

export default ZoomSearch