UNPKG

geopf-extensions-openlayers

Version:

French Geoportal Extensions for OpenLayers libraries

704 lines (642 loc) 27.9 kB
// import CSS import "../../CSS/Controls/SearchEngine/GPFsearchEngine.css"; import Control from "../Control"; import Logger from "../../Utils/LoggerByDefault"; import DefaultSearchService from "../../Services/DefaultSearchService"; import Helper from "../../Utils/Helper"; // Voir les typedefs partagés dans ./typedefs.js (SearchEngineBaseOptions, SearchServiceOptions, ...) const history = "fr-icon-history-line"; const defaultIcon = "fr-icon-map-pin-2-line"; const typeClasses = { "StreetAddress" : "fr-icon-map-pin-2-line", "PositionOfInterest" : { "administratif" : "fr-icon-france-line", "hydrographie" : "fr-icon-ign-mer", "transport" : { "télécabine, téléphérique transport par câble" : "fr-icon-subway-line", "gare voyageurs uniquement" : "fr-icon-subway-line", "voyageurs et fret" : "fr-icon-subway-line", "station de métro" : "fr-icon-subway-line", "gare fret uniquement" : "fr-icon-subway-line", "gare routière" : "fr-icon-subway-line", "station de tramway" : "fr-icon-subway-line", "arrêt voyageurs" : "fr-icon-subway-line", "aérodrome" : "fr-icon-plane-line", "héliport" : "fr-icon-plane-line", "altiport" : "fr-icon-plane-line", "aérogare" : "fr-icon-plane-line", "port" : "fr-icon-ship-2-line", "gare maritime" : "fr-icon-ship-2-line", "parking" : "fr-icon-parking-box-line", "aire de repos ou de service" : "fr-icon-parking-box-line", "service dédié aux vélos" : "fr-icon-bike-line", } } }; var logger = Logger.getLogger("searchengine"); /** * @classdesc * Contrôle de base pour la recherche (barre de recherche, autocomplétion, historique). * * @alias ol.control.SearchEngineBase * @module SearchEngine */ class SearchEngineBase extends Control { /** * Constructeur du contrôle SearchEngineBase. * @constructor * @param {SearchEngineBaseOptions} options Options du constructeur * @fires autocomplete * @fires search * @fires select * @example * const search = new ol.control.SearchEngineBase({ * placeholder: "Rechercher une adresse...", * minChars: 3, * maximumEntries: 10, * historic: "mesRecherches", * searchService: new CustomSearchService() * }); * map.addControl(search) */ constructor (options) { options = options || {}; // call ol.control.Control constructor super(options); /** * Nom de la classe (heritage) * @private */ this.CLASSNAME = "SearchEngineBase"; // initialisation du composant this.initialize(options); this.searchService = options.searchService; // Permet l'autocomplétion if (this.searchService.get("autocomplete") !== false) { this.searchService.on("autocomplete", function (e) { this.onAutocomplete(e); }.bind(this)); } this.searchService.on("search", function (e) { this.onSearch(e); }.bind(this)); // Widget main DOM container this._initContainer(options); this._initEvents(options); // Get historic in localStorage this._historic = false; this._historicName = "GPsearch-" + options.historic; if (options.historic !== false && this.searchService.get("autocomplete") !== false) { this._historic = []; try { const stor = window.localStorage.getItem(this._historicName); if (stor) { this._historic = JSON.parse(stor); } } catch (e) { // logger.warn("LocalStorage not available"); } } this.showHistoric(); } /** * Initialise le contrôle SearchEngineBase (appelé par le constructeur). * @protected * @param {SearchEngineBaseOptions} options Options du constructeur */ initialize (options) { // Valeurs par défaut des options options.minChars = options.minChars ? options.minChars : 3; options.maximumEntries = (typeof options.maximumEntries === "number") ? options.maximumEntries : 5; options.historic = (typeof options.historic === "string" ? options.historic : this.CLASSNAME); options.title = options.title ? options.title : "Rechercher"; options.ariaLabel = options.ariaLabel ? options.ariaLabel : "Rechercher"; options.placeholder = options.placeholder ? options.placeholder : ""; options.searchService = options.searchService ? options.searchService : new DefaultSearchService(); options.label = options.label ? options.label : ""; options.hint = options.hint ? options.hint : ""; options.search = options.search === true ? true : false; options.searchButton = options.searchButton === true ? true : false; options.collapsible = options.collapsible === true ? true : false; this.set("maximumEntries", options.maximumEntries); } /** * Ajoute les écouteurs d'événements sur les éléments du contrôle. * @protected * @param {SearchEngineBaseOptions} options Options du constructeur */ _initEvents (options) { if (this.searchService.get("autocomplete") !== false) { // Empty input this.input.addEventListener("input", function (e) { if (e.target.value.length > 0) { e.target.dataset.erase = true; } else { delete e.target.dataset.erase; } console.log("input", e, e.target.value); if (!e.target.value) { this.showHistoric(); } }.bind(this)); // Prevent cursor to go to the end of input on keydown this.input.addEventListener("keydown", function (e) { if (/ArrowDown|ArrowUp/.test(e.key)) { e.preventDefault(); } }.bind(this)); // Keyboard navigation this.input.addEventListener("keyup", function (e) { // autocomplete list const list = Array.from(this.autocompleteList.querySelectorAll("li")); let idx = list.findIndex(li => li.classList.contains("active")); if (idx === -1) { // Ancienne valeur this._previousValue = e.target.value; } switch (e.key) { case "ArrowDown": case "ArrowUp": e.preventDefault(); // Navigation in autocomplete list if (list.length === 0) { return; } list.forEach(li => li.classList.remove("active")); if (e.key === "ArrowDown") { idx++; if (idx >= list.length) { idx = -1; } } else if (e.key === "ArrowUp") { idx--; if (idx < -1) { idx = list.length - 1; } } if (idx !== -1) { // Set active const current = list[idx]; current.classList.add("active"); this.input.value = current.innerText; this.input.setAttribute("aria-activedescendant", current.id); this.input.setAttribute("data-active-option", current.id); } else { // Réaffiche la valeur précédente de l'utilisateur e.target.value = this._previousValue; } // Envoie un événement de type input pour notifier le changement this.input.dispatchEvent(new Event("input")); break; default: if (e.target.value.length && e.target.value.length >= options.minChars && e.target.value !== this._currentValue) { this.autocomplete(e.target.value); } break; } this._currentValue = e.target.value; }.bind(this), false); } // Événement d'envoi du formulaire this.container.addEventListener("submit", function (e) { e.preventDefault(); const list = Array.from(this.autocompleteList.querySelectorAll("li")); if (e && e.submitter && e.submitter.type === "submit") { // Si on appuie sur le bouton, on vérifie que l'input ne soit pas vide let input = e.target.querySelector("input"); const value = input.value; if (value.length < options.minChars) { return false; } } let idx = list.findIndex(li => li.classList.contains("active")); let item = list[idx]; if (idx < 0) { // Pas d'item sélectionné : on prend le premier de la liste item = list[0]; } if (item) { item.click(); } }.bind(this)); } /** * Initialise le conteneur DOM principal du contrôle. * @private * @param {SearchEngineBaseOptions} options Options du constructeur * @returns {void} */ _initContainer (options) { const element = this.element = document.createElement("div"); element.className = "GPwidget gpf-widget"; element.id = Helper.getUid("GPsearchEngine-"); // Main container const container = this.container = document.createElement("form"); // container.className = options.search ? "GPSearchBar fr-search-bar" : ""; container.className = options.search ? "GPSearchBar" : ""; // container.className = "fr-search-bar"; container.id = Helper.getUid("GPsearchInput-Base-"); // Création du bouton if (!options.target && options.collapsible) { this.button = document.createElement("button"); this.button.id = Helper.getUid("GPshowSearchEnginePicto-"); this.button.className = "GPshowOpen GPshowAdvancedToolPicto GPshowSearchEnginePicto gpf-btn fr-icon-search-line fr-btn fr-btn--lg"; this.button.setAttribute("aria-pressed", "true"); // this.button.setAttribute("type", "submit"); // this.button.setAttribute("form", container.id); if (options.title) { this.button.setAttribute("title", options.title); } if (options.collapsible) { this.button.addEventListener("click", function () { element.classList.toggle("ol-collapsed"); const pressed = this.button.getAttribute("aria-pressed") === "true"; this.button.setAttribute("aria-pressed", !pressed); if (!pressed) { input.focus(); } else { input.blur(); } }.bind(this)); } element.appendChild(this.button); } element.appendChild(container); const search = document.createElement("div"); // search.className = "GPInputGroup fr-input"; search.className = "GPInputGroup"; search.classList.add(options.search ? "fr-input" : "fr-input-group"); container.appendChild(search); // Input const input = this.input = document.createElement("input"); input.type = "text"; input.className = "GPsearchInputText fr-input"; input.id = Helper.getUid("GPsearchInputText-"); input.placeholder = options.placeholder; input.autocomplete = "off"; input.setAttribute("aria-label", options.ariaLabel); if (options.label) { const label = document.createElement("label"); label.className = "GPLabel fr-label"; label.textContent = options.label; label.htmlFor = input.id; if (options.hint) { const hint = document.createElement("span"); hint.className = "GPLabelHint fr-hint-text"; hint.textContent = options.hint; label.appendChild(hint); } search.appendChild(label); } search.appendChild(input); const messages = document.createElement("div"); messages.className = "GPMessagesGroup fr-messages-group"; messages.ariaLive = "polite"; messages.id = Helper.getUid("GPMessagesGroup-"); input.setAttribute("aria-describedby", messages.id); search.appendChild(messages); // Options container this.optionscontainer = document.createElement("div"); this.optionscontainer.className = "GPOptionsContainer"; search.appendChild(this.optionscontainer); // Submit button if (options.searchButton) { const submit = this.subimtBt = document.createElement("button"); submit.className = "GPsearchInputSubmit gpf-btn fr-icon-search-line fr-btn"; submit.id = Helper.getUid("GPshowSearchEnginePicto-"); submit.type = "submit"; if (options.title) { submit.setAttribute("title", options.title); } container.appendChild(submit); } // Autocomplete container const acContainer = this.acContainer = document.createElement("div"); acContainer.id = Helper.getUid("GPautoCompleteContainer-"); acContainer.className = "GPautoCompleteContainer GPelementHidden gpf-hidden"; element.appendChild(acContainer); // element.appendChild(acContainer); // Autocomplete list const autocompleteHeader = this.autocompleteHeader = document.createElement("div"); autocompleteHeader.className = "GPautoCompleteHeader"; acContainer.appendChild(autocompleteHeader); const autocompleteList = this.autocompleteList = document.createElement("ul"); autocompleteList.className = "GPautoCompleteList"; autocompleteList.id = Helper.getUid("GPautoCompleteList-"); autocompleteList.setAttribute("role", "listbox"); autocompleteList.setAttribute("tabindex", "-1"); autocompleteList.setAttribute("aria-label", "Propositions"); acContainer.appendChild(autocompleteList); const autocompleteFooter = this.autocompleteFooter = document.createElement("div"); autocompleteFooter.className = "GPautoCompleteFooter"; acContainer.appendChild(autocompleteFooter); // Input controller for accessibility input.setAttribute("role", "combobox"); input.setAttribute("aria-controls", acContainer.id); input.setAttribute("aria-expanded", "false"); input.setAttribute("aria-autocomplete", "list"); input.setAttribute("aria-haspopup", "listbox"); if (this.searchService.get("autocomplete") !== false) { input.addEventListener("focus", () => { input.setAttribute("aria-expanded", "true"); acContainer.classList.add("gpf-visible"); acContainer.classList.remove("gpf-hidden"); acContainer.classList.add("GPelementVisible"); acContainer.classList.remove("GPelementHidden"); }); // Gère le focus pour la sélection d'éléments dans la liste input.addEventListener("blur", (e) => { // N'agit que si le focus est hors de l'élément if (e.relatedTarget && acContainer.contains(e.relatedTarget)) { // N'empêche pas le focus sur un bouton if (!(e.relatedTarget.tagName === "BUTTON")) { input.focus(); } else { // Ajout d'un event listener pour retourner sur l'input en cas de besoin e.relatedTarget.addEventListener("blur", (/** @type {FocusEvent}*/ e) => { if (e.relatedTarget && acContainer.contains(e.relatedTarget) || e.relatedTarget === input) { input.focus(); } else { // On doit aller sur le bouton recherche avancée if (input.value.length === 0) { delete input.dataset.erase; e.relatedTarget.parentNode.querySelector("button")?.focus(); } setTimeout(() => { input.setAttribute("aria-expanded", "false"); acContainer.classList.remove("gpf-visible"); acContainer.classList.add("gpf-hidden"); acContainer.classList.remove("GPelementVisible"); acContainer.classList.add("GPelementHidden"); }, 50); } }, { once : true }); } } else { input.value.length === 0 && delete input.dataset.erase; setTimeout(() => { input.setAttribute("aria-expanded", "false"); acContainer.classList.remove("gpf-visible"); acContainer.classList.add("gpf-hidden"); acContainer.classList.remove("GPelementVisible"); acContainer.classList.add("GPelementHidden"); }, 50); } }); } } /** * Active ou désactive le contrôle (désactive l'input / bouton). * @param {Boolean} active Indique si le contrôle doit être désactivé * @returns {void} */ setActive (active) { this.input.disabled = !!active; this.subimtBt.disabled = !!active; } /** * Lance l'autocomplétion et met à jour la liste. * @param {String} [value] Valeur de l'input * @api */ autocomplete (value) { clearTimeout(this._completeDelay); this._completeDelay = setTimeout(function () { this.searchService.autocomplete(value); }.bind(this), this.get("triggerDelay") || 100); } /** * Callback sur événement d'autocomplétion. * @param {Object} e Événement d'autocomplétion * @private */ onAutocomplete (e) { clearTimeout(this._completeDelay); // Update list} this._updateList(e.result); this.dispatchEvent(e); } /** * Lance la recherche de géocodage. * @param {IGNSearchObject} item Valeur ou objet à rechercher * @api */ search (item) { clearTimeout(this._completeDelay); this._completeDelay = setTimeout(function () { this.searchService.search(item); }.bind(this), this.get("triggerDelay") || 100); } /** * Callback sur événement de recherche. * @param {Object} e Événement de recherche * @api */ onSearch (e) { clearTimeout(this._completeDelay); // Update list} this.dispatchEvent(e); } /** * Callback sur sélection d'un item. * @param {Object} item Élément sélectionné * @api */ select (item) { clearTimeout(this._completeDelay); const title = this.getItemTitle(item); this.input.value = title; this.input.dispatchEvent(new Event("input")); this._currentValue = title; this._updateHistoric(item); this._updateList(); this.dispatchEvent({ type : "select", title : this.getItemTitle(item), item : item }); } /** * Affiche la liste de l'historique. * @api */ showHistoric () { clearTimeout(this._completeDelay); if (this._historic) { this._updateList(this._historic.length ? this._historic : [], "history"); } } /** * Met à jour la liste d'autocomplétion. * @private * @param {Array<Object>} tab Liste des items d'autocomplétion * @param {String} [type="search"] Type d'affichage ("history" ou "search") */ _updateList (tab, type = "search") { this.autocompleteList.parentNode.dataset.type = type; // tab = (tab || []).slice(0, this.get("maximumEntries")); // Accessibility this.autocompleteList.querySelectorAll("li").forEach(li => li.classList.remove("active")); this.input.setAttribute("aria-activedescendant", ""); this.input.setAttribute("data-active-option", ""); // Update list this.autocompleteList.innerHTML = ""; tab.forEach((item, idx) => { const li = document.createElement("li"); const iconClass = this.getIconClass(item, type); li.id = Helper.getUid("GPsearchResult-"); li.className = `GPsearchResult gpf-panel__item gpf-panel__item-searchengine ${iconClass} fr-icon--sm`; li.setAttribute("role", "option"); li.setAttribute("data-idx", idx); li.innerHTML = li.title = this.getItemTitle(item); if (type === "history") { li.classList.add("GPsearchHistoric"); const span = document.createElement("span"); span.ariaHidden = "true"; span.className = `${history} fr-icon--sm`; li.append(span); } this.autocompleteList.appendChild(li); li.addEventListener("click", function (/** @type {PointerEvent} */ e) { const idx = Number(e.target.getAttribute("data-idx")); // Sélectionne l'item this.select(tab[idx]); // Lance la recherche pour cet item this.search({ location : tab[idx] }); }.bind(this)); }); } /** * Retourne la classe à ajouter pour un résultat d'autocomplétion * @param {AutocompleteResult} item Résultat de l'autocomplétion (ou historique) * @param {String} type Type de la recherche ("history" ou "search") * @returns {String} classe à ajouter */ getIconClass (item, type) { // let iconClass = typeClasses[type]; let iconClass = typeClasses[item.type]; // Cas où l'on a d'autres éléments if (typeof iconClass === "object") { // Cherche les types de POI for (let i = 0; i < item.poiType.length; i++) { const poiType = item.poiType[i]; if (Object.hasOwn(iconClass, poiType)) { iconClass = iconClass[poiType]; break; } } // TODO : améliorer la fonction (faire récursif ?) if (typeof iconClass === "object") { // Cherche les types de POI for (let i = 0; i < item.poiType.length; i++) { const poiType = item.poiType[i]; if (Object.hasOwn(iconClass, poiType)) { iconClass = iconClass[poiType]; break; } } } iconClass = typeof iconClass === "object" ? defaultIcon : iconClass; } return iconClass; } /** * Retourne le titre à afficher pour un item. * @param {Object} item Élément à afficher * @returns {String} Titre * @api */ getItemTitle (item) { return this.searchService.getItemTitle(item); } /** * Ajoute ou remplace une valeur dans l'historique. * @private * @param {Object} value Valeur à ajouter */ _updateHistoric (value) { if (this._historic) { let index = -1; for (let i = 0; i < this._historic.length; i++) { const elem = this._historic[i]; if (this._isEqual(elem, value)) { // L'élément est déjà dans l'historique index = i; break; } } if (index !== -1) { // Enlève de l'historique pour le remettre en première position; this._historic.splice(index, 1); } const length = this._historic.unshift(value); // Retire le dernier élément si le nombre maximal est atteint if (length > (this.get("maximumEntries"))) { this._historic.pop(); } // Enregistre dans le localStorage localStorage.setItem(this._historicName, JSON.stringify(this._historic)); } } /** * Vérifie si deux éléments (objets) sont égaux. * @private * @param {Object} a Premier objet * @param {Object} b Objet de comparaison * @returns {Boolean} true si égal, false sinon */ _isEqual (a, b) { // TODO : Améliorer comparaison ? const jsonA = JSON.stringify(a); const jsonB = JSON.stringify(b); return jsonA === jsonB; } /** * Ajoute un message à un champ de saisie. * @param {String} message Message à afficher * @param {String} [type="error"] Type du message ("error" ou "valid") * @api */ addMessage (message, type = "error") { let messageElement = this.input.ariaDescribedByElements[0]; if (messageElement) { const p = document.createElement("p"); const messageType = type === "error" ? "error" : "valid"; p.className = `GPMessage GPMessage--${messageType} fr-message fr-message--${messageType}`; p.id = Helper.getUid("GPMessage-"); p.textContent = message; // Enlève la classe du type de message à l'élément parent messageElement.parentElement?.classList.forEach(c => { if (/^fr-.*-group$/.test(c)) { messageElement.parentElement?.classList.add(`${c}--${messageType}`); } }); messageElement.replaceChildren(p); } } /** * Enlève les messages d'erreur du champ de saisie. * @param {HTMLInputElement|HTMLSelectElement} input Champ de saisie * @api */ removeMessages () { let messageElement = this.input.ariaDescribedByElements[0]; if (messageElement) { messageElement.replaceChildren(); // Enlève la classe du type de message à l'élément parent messageElement.parentElement?.classList.forEach(c => { if (/^fr-.*-group--/.test(c)) { messageElement.parentElement?.classList.remove(c); } }); } } } export default SearchEngineBase; // Expose SearchEngine as ol.control.SearchEngine (for a build bundle) if (window.ol && window.ol.control) { window.ol.control.SearchEngineBase = SearchEngineBase; }