/** * @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 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', 'data-id': encodeURIComponent(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('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 */ disambiguated(event) { const li = $(event.currentTarget) 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 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 }) } } /** * @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 */ 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" placeholder="Search for an address...">' + '<button class="btn btn-rnd btn-x">' + '<span class="screen-reader-only">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="region" label="Possible matches for your search"></ul>' + '<ul class="retention"></ul>' + '</div>' export default Search