itowns
Version:
A JS/WebGL framework for 3D geospatial data visualization
234 lines (219 loc) • 12.1 kB
JavaScript
import { Fetcher } from 'itowns';
import Widget from "./Widget.js";
const DEFAULT_OPTIONS = {
width: 300,
height: 38,
position: 'top',
maxSuggestionNumber: 10,
fontSize: 16,
placeholder: 'Search location'
};
function addActive(htmlElements, index) {
if (!htmlElements) {
return index;
}
removeAllActives(htmlElements);
if (index >= htmlElements.length) {
index = 0;
} else if (index < 0) {
index = htmlElements.length - 1;
}
htmlElements[index]?.classList.add('active');
return index;
}
function removeAllActives(htmlElements) {
for (let i = 0; i < htmlElements.length; i++) {
htmlElements[i].classList.remove('active');
}
}
function eraseSuggestionList(form) {
while (form.children.length > 1) {
form.removeChild(form.lastChild);
}
}
/**
* A widget for searchbar
*
* To use it, you need to link the widgets' stylesheet to your html webpage. This stylesheet is included in
* [itowns bundles](https://github.com/iTowns/itowns/releases) if you downloaded them, or it can be found in
* `node_modules/itowns/examples/css` if you installed iTowns with npm. Otherwise, it can be found at
* [this link](https://raw.githubusercontent.com/iTowns/itowns/master/examples/css/widgets.css). See
* [this example](http://www.itowns-project.org/itowns/examples/#widgets_searchbar) for more details.
*
* @extends Widget
*
* @property {HTMLElement} domElement An html div containing the searchbar.
* @property {HTMLElement} parentElement The parent HTML container of `this.domElement`.
*/
class Searchbar extends Widget {
#_onSelected;
/**
* @param {View} view The iTowns view the searchbar should be linked
* to.
*
* @param {Object} geocodingOptions Configuration for geocoding.
* @param {URL} geocodingOptions.url The URL of a geocoding service that should be
* used to build suggestions.
* @param {function} geocodingOptions.parser A method to parse fetched results from geocoding
* url into a Map object. For each entry of this
* Map, the key must be a string that will be
* displayed as the html content of each
* suggestion bellow the searchbar. The value
* associated to the key is whatever the user
* wants. The value is the parameter that is
* passed to the `onSelected` method (specified
* in another `geocodingOptions` parameter).
* @param {function} [geocodingOptions.onSelected] A method which describes what should be done
* when user selects a location (by clicking or
* hitting enter on a suggestion). The only
* parameter of this method is the value mapped
* with `geocodingOptions.parser` method.
*
* @param {Object} [options] The searchbar optional configuration.
* @param {HTMLElement} [options.parentElement=view.domElement] The parent HTML container of the div which
* contains searchbar widgets.
* @param {number} [options.size] The size of the searchbar. It is a number that
* describes both width and height in pixels of
* the searchbar.
* @param {number} [options.width=300] The width in pixels of the searchbar.
* @param {number} [options.height=38] The height in pixels of the searchbar.
* @param {string} [options.position='top'] Defines which position within the
* `parentElement` the searchbar should be
* displayed to. Possible values are `top`,
* `bottom`, `left`, `right`, `top-left`,
* `top-right`, `bottom-left` and `bottom-right`.
* If the input value does not match one of
* these, it will be defaulted to `top`.
* @param {Object} [options.translate] An optional translation of the searchbar.
* @param {number} [options.translate.x=0] The searchbar translation along the page x-axis.
* @param {number} [options.translate.y=0] The searchbar translation along the page y-axis.
* @param {number} [options.fontSize=16] The font size in pixel of the searchbar content.
* @param {number} [options.maxSuggestionNumber=10] The maximum number of suggestions that should
* appear under the searchbar.
* @param {string} [options.placeholder='Search location'] The placeholder that appears in the searchbar
* when nothing has yet been typed.
*/
constructor(view, geocodingOptions) {
let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
// ---------- BUILD PROPERTIES ACCORDING TO DEFAULT OPTIONS AND OPTIONS PASSED IN PARAMETERS : ----------
super(view, options, DEFAULT_OPTIONS);
// Check if `geocodingOptions` parameter was correctly specified.
if (!geocodingOptions || !geocodingOptions.url || !geocodingOptions.parser || typeof geocodingOptions.parser !== 'function') {
throw new Error('\'geocodingOptions\' parameter for \'Searchbar\' constructor is not a valid option. Please refer to ' + 'the documentation.');
}
this.#_onSelected = geocodingOptions.onSelected ?? (() => {});
// ---------- this.domElement SETTINGS SPECIFIC TO SEARCHBAR : ----------
this.domElement.id = 'widgets-searchbar';
this.domElement.style.height = 'auto';
const form = document.createElement('form');
form.setAttribute('autocomplete', 'off');
form.id = 'searchbar-autocompletion-form';
this.domElement.appendChild(form);
const input = document.createElement('input');
input.setAttribute('type', 'text');
input.setAttribute('name', 'mySearch');
input.setAttribute('placeholder', options.placeholder || DEFAULT_OPTIONS.placeholder);
input.style.height = `${options.height || options.size || DEFAULT_OPTIONS.height}px`;
input.style.fontSize = `${options.fontSize || DEFAULT_OPTIONS.fontSize}px`;
form.appendChild(input);
// currentFocus variable stores the index of the suggestions that is focused by user, either with mouse or arrow
// keys.
let currentFocus;
// ---------- BUILD AUTOCOMPLETION SUGGESTIONS LIST WHEN TYPING THE SEARCHBAR INPUT : ----------
input.addEventListener('input', () => {
const value = input.value;
// Close any already opened list of autocompleted values
eraseSuggestionList(form);
currentFocus = -1;
if (!value) {
return false;
}
geocodingOptions.url.searchParams.set('text', value);
Fetcher.json(geocodingOptions.url).then(geocodingResult => {
const result = geocodingOptions.parser(geocodingResult);
let i = 0;
result.forEach((info, location) => {
// Stop looping through the map if enough location have been proceeded.
if (i === Math.min(result.size, options.maxSuggestionNumber || DEFAULT_OPTIONS.maxSuggestionNumber)) {
return;
}
const mapIndex = i;
i++;
const index = location.toUpperCase().indexOf(value.toUpperCase());
if (index > -1) {
const autocompleteItem = document.createElement('div');
autocompleteItem.style.minHeight = input.style.height;
autocompleteItem.style.fontSize = `${options.fontSize || DEFAULT_OPTIONS.fontSize}px`;
// Make the matching letters bold.
const start = location.slice(0, index);
const bold = location.slice(index, index + value.length);
const end = location.slice(index + value.length, location.length);
autocompleteItem.innerHTML = `<p>${start}<strong>${bold}</strong>${end}</p>`;
// Store the current location value as an attribute of autocompleteItem div.
autocompleteItem.setAttribute('location', location);
form.appendChild(autocompleteItem);
// eslint-disable-next-line no-loop-func
autocompleteItem.addEventListener('mouseover', () => {
removeAllActives(form.children);
currentFocus = mapIndex;
autocompleteItem.classList.add('active');
});
autocompleteItem.addEventListener('click', () => {
this.#_onSelected(info);
input.value = autocompleteItem.getAttribute('location');
eraseSuggestionList(form);
});
}
});
});
});
// ---------- MANAGE KEYBOARD INTERACTIONS ON AUTOCOMPLETION SUGGESTIONS : ----------
// When searchbar is positioned at the bottom of the screen (therefore is a flex with `column-reverse`
// direction, we must exchange up and down arrow actions.
const topOrBottom = (options.position || DEFAULT_OPTIONS.position).includes('top') ? 1 : -1;
input.addEventListener('keydown', event => {
event.stopPropagation();
const completionSuggestions = form.getElementsByTagName('div');
switch (event.code) {
case 'Escape':
eraseSuggestionList(form);
input.value = '';
view.domElement.focus();
break;
case 'ArrowDown':
event.preventDefault();
currentFocus = addActive(completionSuggestions, currentFocus + topOrBottom);
break;
case 'ArrowUp':
event.preventDefault();
currentFocus = addActive(completionSuggestions, currentFocus - topOrBottom);
break;
case 'Enter':
event.preventDefault();
if (completionSuggestions[Math.max(currentFocus, 0)]) {
completionSuggestions[Math.max(currentFocus, 0)].click();
view.domElement.focus();
}
break;
default:
break;
}
});
// ---------- MANAGE FOCUS AND ACTIVE SUGGESTION WHEN USER ENTERS OR LEAVES THE SEARCHBAR : ----------
// User clicks the searchbar.
input.addEventListener('focus', () => {
form.classList.add('focus');
});
// User clicks out of the searchbar.
input.addEventListener('blur', () => {
form.classList.remove('focus');
removeAllActives(form.children);
});
// Cursor leaves the searchbar.
form.addEventListener('mouseleave', () => {
removeAllActives(form.children);
currentFocus = -1;
});
}
}
export default Searchbar;