/**
* @module nyc/Search
*/
import $ from 'jquery'
import Container from 'nyc/Container'
import AutoComplete from 'nyc/AutoComplete'
import Dialog from 'nyc/Dialog'
/**
* @desc Abstract class for zoom and search controls
* @public
* @abstract
* @class
* @extends module:nyc/Container~Container
* @fires module:nyc/Search~Search#search
* @fires module:nyc/Search~Search#disambiguated
*/
class Search extends Container {
/**
* @desc Create an instance of Search
* @access protected
* @constructor
* @param {jQuery|Element|string} target The target
*/
constructor(target) {
super($(Search.HTML))
let input
target = $(target).get(0)
if (target.tagName === 'INPUT') {
input = target
target = $('<div class="map-srch"></div>')
target.insertAfter(input)
this.find('.srch').addClass('input-group')
}
$(target).append(this.getContainer())
/**
* @private
* @member {jQuery}
*/
this.input = this.find('input')
/**
* @private
* @member {jQuery}
*/
this.clear = this.find('.btn-x')
if (input) {
this.input.attr('id', $(input).attr('id'))
.attr('placeholder', $(input).attr('placeholder'))
.addClass($(input).attr('class'))
$(input).remove()
this.clear.remove()
}
/**
* @private
* @member {boolean}
*/
this.isAddrSrch = true
/**
* @private
* @member {jQuery}
*/
this.list = this.find('ul.rad-all')
/**
* @private
* @member {jQuery}
*/
this.retention = this.find('ul.retention')
/**
* @private
* @member {AutoComplete}
*/
this.autoComplete = null
this.hookupEvents(this.input)
}
/**
* @public
* @abstract
* @method
* @param {Object} feature The feature object
* @param {module:nyc/Search~Search.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/Search~Search.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/Search~Search.FeatureSearchOptions} options 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/Search~Search.FeatureSearchOptions} options Options
* @param {module:nyc/Locator~Locator.Result} data Location data
* @return {jQuery} list item
*/
listItem(options, data) {
const li = $('<li></li>')
const displayField = options.displayField
let name
if (data.data) {
name = data.data[displayField]
}
if (!name) {
name = data.name
}
li.addClass(options.layerName)
if (options.layerName !== 'addr') {
li.addClass('feature')
}
return li.addClass('notranslate')
.attr({translate: 'no', 'data-id': encodeURIComponent(name)})
.html(`<a href="#">${name}</a>`)
.data('nameField', options.nameField)
.data('displayField', displayField)
.data('location', data)
.on('click', this.disambiguated.bind(this))
.on('keydown', this.listKey.bind(this))
}
/**
* @private
* @method
*/
emptyList() {
const retention = this.retention
this.find('li').each((i, item) => {
if (retention.find(`li[data-id="${$(item).attr('data-id')}"]`).length === 0) {
retention.append(item)
}
})
this.list.empty()
}
/**
* @private
* @method
* @param {jQuery.Event} event Event object
*/
listKey(event) {
const keyCode = event.keyCode
if (keyCode === 13 || keyCode === 32) {
this.disambiguated(event)
} else {
const target = event.currentTarget
const first = this.list.children().first().get(0)
const last = this.list.children().last().get(0)
if (target !== first && keyCode === 38) { // up arrow
$($(target).prev()).find('a').trigger('focus')
} else if (target !== last && keyCode === 40) { // down arrow
$($(target).next()).find('a').trigger('focus')
}
}
}
/**
* @private
* @method
* @param {jQuery.Event} event Event object
*/
disambiguated(event) {
const li = $(event.currentTarget)
const data = li.data('location')
if (data) {
this.val(data.name)
data.isFeature = li.hasClass('feature')
this.trigger('disambiguated', data)
li.parent().slideUp()
this.emptyList()
}
}
/**
* @private
* @method
* @param {jQuery.Event} event Event object
*/
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 if (this.getContainer().css('display') === 'block') {
this.list.slideUp()
}
}
}
/**
* @private
* @method
* @param {jQuery} input Input element
*/
hookupEvents(input) {
input.on('keyup change', $.proxy(this.key, this))
input.focus(() => input.select())
this.clear.click($.proxy(this.clearTxt, this))
$(document).mouseup($.proxy(this.listClick, this))
this.find('.btn-srch').click($.proxy(this.triggerSearch, this))
}
/**
* @private
* @method
* @param {jQuery.Event} event Event object
*/
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()
this.input.focus()
}
/**
* @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} focus Should we focus?
*/
showList(focus) {
if (this.getContainer().css('display') === 'block') {
this.list.slideDown(() => {
if (focus) {
this.list.children().first().find('a').attr('tabindex', 0).focus()
}
})
} else {
const msg = $('<div><strong>Possible matches:</strong></div>')
msg.append(this.list)
this.dialog = this.dialog || new Dialog({target: $('.nyc-map'), css: 'posbl'})
this.list.one('click', $.proxy(this.dialog.hide, this.dialog))
this.dialog.ok({
buttonText: ['Cancel'],
message: msg
})
}
}
/**
* @public
* @method
* @param {string=} input The search input
*/
triggerSearch(input) {
input = 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
*/
Search.FeatureSearchOptions
/**
* @desc The user has requested a search based on their text input
* @event module:nyc/Search~Search#search
* @type {string}
*/
/**
* @desc The user has chosen a location from a list of possible locations
* @event module:nyc/Search~Search#disambiguated
* @type {module:nyc/Locate~Locate.Result}
*/
/**
* @private
* @const
* @type {string}
*/
Search.HTML = '<div class="srch-ctl">' +
'<div class="srch" role="search">' +
'<input class="rad-all" type="text" data-msg-key="msg-srch" data-msg-attr="placeholder" placeholder="Search for an address...">' +
'<button class="btn btn-rnd btn-x">' +
'<span class="screen-reader-only msg-clr">Clear</span>' +
'<span class="fas fa-times" role="img"></span>' +
'</button>' +
'<button class="btn btn-srch btn-primary btn-lg">Search</button>' +
'</div>' +
'<ul class="rad-all" role="application" label="Possible matches for your search"></ul>' +
'<ul class="retention"></ul>' +
'</div>'
export default Search