/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { internalSymbol } from "../../constants.mjs"; import { buildMap } from "../../data/buildmap.mjs"; import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { positionPopper } from "./util/floating-ui.mjs"; import { addAttributeToken, findClosestByAttribute, removeAttributeToken, } from "../../dom/attributes.mjs"; import { ATTRIBUTE_PREFIX, ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { CustomControl } from "../../dom/customcontrol.mjs"; import { assembleMethodSymbol, getSlottedElements, registerCustomElement, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent, fireCustomEvent, fireEvent, } from "../../dom/events.mjs"; import { getDocument } from "../../dom/util.mjs"; import { Formatter } from "../../text/formatter.mjs"; import { getGlobal } from "../../types/global.mjs"; import { ID } from "../../types/id.mjs"; import { isArray, isFunction, isInteger, isIterable, isObject, isPrimitive, isString, } from "../../types/is.mjs"; import { Observer } from "../../types/observer.mjs"; import { ProxyObserver } from "../../types/proxyobserver.mjs"; import { validateArray, validateString } from "../../types/validate.mjs"; import { Processing } from "../../util/processing.mjs"; import { STYLE_DISPLAY_MODE_BLOCK } from "./constants.mjs"; import { SelectStyleSheet } from "./stylesheet/select.mjs"; import { getDocumentTranslations, Translations, } from "../../i18n/translations.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { addErrorAttribute, removeErrorAttribute } from "../../dom/error.mjs"; export { Select, popperElementSymbol, getSummaryTemplate, getSelectionTemplate, }; /** * @private * @type {Symbol} */ const timerCallbackSymbol = Symbol("timerCallback"); /** * @private * @type {Symbol} */ const keyFilterEventSymbol = Symbol("keyFilterEvent"); /** * @private * @type {Symbol} */ const lazyLoadDoneSymbol = Symbol("lazyLoadDone"); /** * @private * @type {Symbol} */ const isLoadingSymbol = Symbol("isLoading"); /** * local symbol * @private * @type {Symbol} */ const closeEventHandler = Symbol("closeEventHandler"); /** * local symbol * @private * @type {Symbol} */ const clearOptionEventHandler = Symbol("clearOptionEventHandler"); /** * local symbol * @private * @type {Symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** * local symbol * @private * @type {Symbol} */ const keyEventHandler = Symbol("keyEventHandler"); /** * local symbol * @private * @type {Symbol} */ const lastFetchedDataSymbol = Symbol("lastFetchedData"); /** * local symbol * @private * @type {Symbol} */ const inputEventHandler = Symbol("inputEventHandler"); /** * local symbol * @private * @type {Symbol} */ const changeEventHandler = Symbol("changeEventHandler"); /** * local symbol * @private * @type {Symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * local symbol * @private * @type {Symbol} */ const selectionElementSymbol = Symbol("selectionElement"); /** * local symbol * @private * @type {Symbol} */ const containerElementSymbol = Symbol("containerElement"); /** * local symbol * @private * @type {Symbol} */ const popperElementSymbol = Symbol("popperElement"); /** * local symbol * @private * @type {Symbol} */ const inlineFilterElementSymbol = Symbol("inlineFilterElement"); /** * local symbol * @private * @type {Symbol} */ const popperFilterElementSymbol = Symbol("popperFilterElement"); /** * local symbol * @private * @type {Symbol} */ const popperFilterContainerElementSymbol = Symbol( "popperFilterContainerElement", ); /** * local symbol * @private * @type {Symbol} */ const optionsElementSymbol = Symbol("optionsElement"); /** * local symbol * @private * @type {Symbol} */ const noOptionsAvailableElementSymbol = Symbol("noOptionsAvailableElement"); /** * local symbol * @private * @type {Symbol} */ const statusOrRemoveBadgesElementSymbol = Symbol("statusOrRemoveBadgesElement"); /** * @private * @type {Symbol} */ const areOptionsAvailableAndInitSymbol = Symbol("@@areOptionsAvailableAndInit"); /** * @private * @type {symbol} */ const disabledRequestMarker = Symbol("@@disabledRequestMarker"); /** * @private * @type {number} */ const FOCUS_DIRECTION_UP = 1; /** * @private * @type {number} */ const FOCUS_DIRECTION_DOWN = 2; /** * @private * @type {string} */ const FILTER_MODE_REMOTE = "remote"; /** * @private * @type {string} */ const FILTER_MODE_OPTIONS = "options"; /** * @private * @type {string} */ const FILTER_MODE_DISABLED = "disabled"; /** * @private * @type {string} */ const FILTER_POSITION_POPPER = "popper"; /** * @private * @type {string} */ const FILTER_POSITION_INLINE = "inline"; /** * A select control that can be used to select one or more options from a list. * * @issue @issue https://localhost.alvine.dev:8444/development/issues/closed/280.html * * @fragments /fragments/components/form/select/ * * @example /examples/components/form/select-with-options Select with options * @example /examples/components/form/select-with-html-options Select with HTML options * @example /examples/components/form/select-multiple Multiple selection * @example /examples/components/form/select-filter Filter * @example /examples/components/form/select-fetch Fetch options * @example /examples/components/form/select-lazy Lazy load * @example /examples/components/form/select-remote-filter Remote filter * * @copyright schukai GmbH * @summary A beautiful select control that can make your life easier and also looks good. * @fires monster-change * @fires monster-changed */ class Select extends CustomControl { /** * */ constructor() { super(); initOptionObserver.call(this); } /** * This method is called by the `instanceof` operator. * @return {Symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/form/select@@instance"); } /** * The current selection of the Select * * ``` * e = document.querySelector('monster-select'); * console.log(e.value) * // ↦ 1 * // ↦ ['1','2'] * ``` * * @return {string} */ get value() { return convertSelectionToValue.call(this, this.getOption("selection")); } /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/attachInternals} * @return {boolean} */ static get formAssociated() { return true; } /** * Set selection * * ``` * e = document.querySelector('monster-select'); * e.value=1 * ``` * * @property {string|array} value * @throws {Error} unsupported type * @fires monster-selected this event is fired when the selection is set */ set value(value) { const result = convertValueToSelection.call(this, value); setSelection .call(this, result.selection) .then(() => {}) .catch((e) => { addErrorAttribute(this, e); }); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} toggleEventType=click,touch List of event types to be observed for opening the dropdown * @property {boolean} delegatesFocus=false lorem [see mozilla.org](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/delegatesFocus) * @property {Object[]} options Selection of key identifier pairs available for selection and displayed in the dropdown. * @property {string} options[].label * @property {string} options[].value * @property {string} options[].visibility hidden or visible * @property {Array} selection Selected options * @property {Integer} showMaxOptions=10 Maximum number of visible options before a scroll bar should be displayed. * @property {string} type=radio Multiple (checkbox) or single selection (radio) * @property {string} name=(random id) Name of the form field * @property {string} url Load options from server per url * @property {object} lookup Load options from server per url * @property {string} lookup.url=null Load options from server per url * @property {boolean} lookup.grouping=false Load all selected options from server per url at once (true) or one by one (false) * @property {Object} fetch Fetch [see Using Fetch mozilla.org](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) * @property {String} fetch.redirect=error * @property {String} fetch.method=GET * @property {String} fetch.mode=same-origin * @property {String} fetch.credentials=same-origin * @property {Object} fetch.headers={"accept":"application/json"}} * @property {Object} labels * @property {string} labels.cannot-be-loaded cannot be loaded * @property {string} labels.no-options-available no options available * @property {string} labels.select-an-option select an option * @property {string} labels.no-option no option in the list, maybe you have to change the filter * @property {Object} features List with features * @property {Boolean} features.clearAll=true Display of a delete button to delete the entire selection * @property {Boolean} features.clear=true Display of a delete key for deleting the specific selection * @property {Boolean} features.lazyLoad=false Load options when first opening the dropdown. (Hint; lazylLoad is not supported with remote filter) * @property {Boolean} features.closeOnSelect=false Close the dropdown when an option is selected (since 3.54.0) * @property {Boolean} features.emptyValueIfNoOptions=false If no options are available, the selection is set to an empty array * @property {Boolean} features.storeFetchedData=false Store fetched data in the object * @property {Boolean} features.useStrictValueComparison=true Use strict value comparison for the selection * @property {string} filter.defaultValue=null Default filter value, if the filter is empty, if the default value is null, then no request is made * @property {Boolean} filter.mode=options Filter mode, values: options, remote, disabled (Hint; lazylLoad is not supported with remote filter, if you use remote filter, the lazyLoad is disabled) * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {string} templateMapping Mapping of the template placeholders * @property {string} templateMapping.selected Selected Template * @property {Object} popper [PopperJS Options](https://popper.js.org/docs/v2/) * @property {string} popper.placement=bottom PopperJS placement * @property {Object[]} modifiers={name:offset} PopperJS placement * @property {Object} mapping * @property {String} mapping.selector=* Path to select the appropriate entries * @property {String} mapping.labelTemplate="" template with the label placeholders in the form ${name}, where name is the key (**) * @property {String} mapping.valueTemplate="" template with the value placeholders in the form ${name}, where name is the key * @property {Monster.Components.Form~exampleFilterCallback|undefined} mapping.filter Filtering of values via a function * @property {Object} formatter * @property {Monster.Components.Form~formatterSelectionCallback|undefined} formatter.selection format selection label */ get defaults() { return Object.assign( {}, super.defaults, { toggleEventType: ["click", "touch"], delegatesFocus: false, options: [], selection: [], showMaxOptions: 10, type: "radio", name: new ID("s").toString(), features: { clearAll: true, clear: true, lazyLoad: false, closeOnSelect: false, emptyValueIfNoOptions: false, storeFetchedData: false, useStrictValueComparison: false, }, url: null, lookup: { url: null, grouping: false, }, labels: getTranslations(), messages: { control: null, selected: null, emptyOptions: null, }, fetch: { redirect: "error", method: "GET", mode: "same-origin", credentials: "same-origin", headers: { accept: "application/json", }, }, filter: { defaultValue: null, mode: FILTER_MODE_DISABLED, position: FILTER_POSITION_INLINE, marker: { open: "{", close: "}", }, }, classes: { badge: "monster-badge-primary", statusOrRemoveBadge: "empty", }, mapping: { selector: "*", labelTemplate: "", valueTemplate: "", filter: null, }, formatter: { selection: buildSelectionLabel, }, templates: { main: getTemplate(), }, templateMapping: { /** with the attribute `data-monster-selected-template` the template for the selected options can be defined. */ selected: getSelectionTemplate(), }, popper: { placement: "bottom", middleware: ["flip", "offset:1"], }, }, initOptionsFromArguments.call(this), ); } /** * @return {Select} */ [assembleMethodSymbol]() { const self = this; super[assembleMethodSymbol](); initControlReferences.call(self); initEventHandler.call(self); let lazyLoadFlag = self.getOption("features.lazyLoad", false); let remoteFilterFlag = getFilterMode.call(this) === FILTER_MODE_REMOTE; if (getFilterMode.call(this) === FILTER_MODE_REMOTE) { self.getOption("features.lazyLoad", false); if (lazyLoadFlag === true) { addErrorAttribute(this, "lazyLoad is not supported with remote filter"); lazyLoadFlag = false; } } if (self.hasAttribute("value")) { new Processing(10, () => { this.value = this.getAttribute("value"); }) .run() .catch((e) => { addErrorAttribute(this, e); }); } if (self.getOption("url") !== null) { if (lazyLoadFlag || remoteFilterFlag) { lookupSelection.call(self); } else { self.fetch().catch((e) => { addErrorAttribute(self, e); }); } } let lastValue = self.value; self[internalSymbol].attachObserver( new Observer(function () { if (isObject(this) && this instanceof ProxyObserver) { const n = this.getSubject()?.options?.value; if (lastValue !== n) { lastValue = n; setSelection .call(self, n) .then(() => {}) .catch((e) => { addErrorAttribute(self, e); }); } } }), ); areOptionsAvailableAndInit.call(self); return this; } /** * * @return {*} * @throws {Error} storeFetchedData is not enabled * @since 3.66.0 */ getLastFetchedData() { if (this.getOption("features.storeFetchedData") === false) { throw new Error("storeFetchedData is not enabled"); } return this?.[lastFetchedDataSymbol]; } /** * The Button.click() method simulates a click on the internal button element. * * @since 3.27.0 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click} */ click() { if (this.getOption("disabled") === true) { return; } toggle.call(this); } /** * The Button.focus() method sets focus on the internal button element. * * @since 3.27.0 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus} */ focus(options) { if (this.getOption("disabled") === true) { return; } new Processing(() => { gatherState.call(this); focusFilter.call(this, options); }) .run() .catch((e) => { addErrorAttribute(this, e); }); } /** * The Button.blur() method removes focus from the internal button element. * @link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/blur */ blur() { new Processing(() => { gatherState.call(this); blurFilter.call(this); }) .run() .catch((e) => { addErrorAttribute(this, e); }); } /** * If no url is specified, the options are taken from the Component itself. * * @param {string|URL} url URL to fetch the options * @return {Promise} */ fetch(url) { return fetchIt.call(this, url); } /** * @return {void} */ connectedCallback() { super.connectedCallback(); const document = getDocument(); for (const [, type] of Object.entries(["click", "touch"])) { // close on outside ui-events document.addEventListener(type, this[closeEventHandler]); } parseSlotsToOptions.call(this); attachResizeObserver.call(this); updatePopper.call(this); new Processing(() => { gatherState.call(this); focusFilter.call(this); }) .run() .catch((e) => { addErrorAttribute(this, e); }); } /** * @return {void} */ disconnectedCallback() { super.disconnectedCallback(); const document = getDocument(); // close on outside ui-events for (const [, type] of Object.entries(["click", "touch"])) { document.removeEventListener(type, this[closeEventHandler]); } disconnectResizeObserver.call(this); } /** * Import Select Options from dataset * Not to be confused with the control defaults/options * * @param {array|object|Map|Set} data * @return {Select} * @throws {Error} map is not iterable * @throws {Error} missing label configuration * @fires monster-options-set this event is fired when the options are set */ importOptions(data) { const mappingOptions = this.getOption("mapping", {}); const selector = mappingOptions?.["selector"]; const labelTemplate = mappingOptions?.["labelTemplate"]; const valueTemplate = mappingOptions?.["valueTemplate"]; const filter = mappingOptions?.["filter"]; let flag = false; if (labelTemplate === "") { addErrorAttribute(this, "empty label template"); flag = true; } if (valueTemplate === "") { addErrorAttribute(this, "empty value template"); flag = true; } if (flag === true) { throw new Error("missing label configuration"); } const map = buildMap(data, selector, labelTemplate, valueTemplate, filter); const options = []; if (!isIterable(map)) { throw new Error("map is not iterable"); } const visibility = "visible"; map.forEach((label, value) => { options.push({ value, label, visibility, data: map.get(value), }); }); runAsOptionLengthChanged.call(this, map.size); this.setOption("options", options); fireCustomEvent(this, "monster-options-set", { options, }); setTimeout(() => { setSelection .call(this, this.getOption("selection")) .then(() => {}) .catch((e) => { addErrorAttribute(this, e); }); }, 10); return this; } /** * @private * @return {Select} */ calcAndSetOptionsDimension() { calcAndSetOptionsDimension.call(this); return this; } /** * * @return {string} */ static getTag() { return "monster-select"; } /** * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [SelectStyleSheet]; } } /** * @private * @returns {object} */ function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { "cannot-be-loaded": "Kann nicht geladen werden", "no-options-available": "Keine Optionen verfügbar.", "click-to-load-options": "Klicken, um Optionen zu laden.", "select-an-option": "Wähle eine Option", "summary-text": { zero: "Keine Einträge ausgewählt", one: '<span class="monster-badge-primary-pill">1</span> Eintrag ausgewählt', other: '<span class="monster-badge-primary-pill">${count}</span> Einträge ausgewählt', }, "no-options": "Leider gibt es keine Optionen in der Liste.", "no-options-found": "Keine Optionen in der Liste verfügbar. Bitte ändern Sie den Filter.", }; case "fr": return { "cannot-be-loaded": "Impossible de charger", "no-options-available": "Aucune option disponible.", "click-to-load-options": "Cliquez pour charger les options.", "select-an-option": "Sélectionnez une option", "summary-text": { zero: "Aucune entrée sélectionnée", one: '<span class="monster-badge-primary-pill">1</span> entrée sélectionnée', other: '<span class="monster-badge-primary-pill">${count}</span> entrées sélectionnées', }, "no-options": "Malheureusement, il n'y a pas d'options disponibles dans la liste.", "no-options-found": "Aucune option disponible dans la liste. Veuillez modifier le filtre.", }; case "sp": return { "cannot-be-loaded": "No se puede cargar", "no-options-available": "No hay opciones disponibles.", "click-to-load-options": "Haga clic para cargar opciones.", "select-an-option": "Seleccione una opción", "summary-text": { zero: "No se seleccionaron entradas", one: '<span class="monster-badge-primary-pill">1</span> entrada seleccionada', other: '<span class="monster-badge-primary-pill">${count}</span> entradas seleccionadas', }, "no-options": "Desafortunadamente, no hay opciones disponibles en la lista.", "no-options-found": "No hay opciones disponibles en la lista. Considere modificar el filtro.", }; case "it": return { "cannot-be-loaded": "Non può essere caricato", "no-options-available": "Nessuna opzione disponibile.", "click-to-load-options": "Clicca per caricare le opzioni.", "select-an-option": "Seleziona un'opzione", "summary-text": { zero: "Nessuna voce selezionata", one: '<span class="monster-badge-primary-pill">1</span> voce selezionata', other: '<span class="monster-badge-primary-pill">${count}</span> voci selezionate', }, "no-options": "Purtroppo, non ci sono opzioni disponibili nella lista.", "no-options-found": "Nessuna opzione disponibile nella lista. Si prega di modificare il filtro.", }; case "pl": return { "cannot-be-loaded": "Nie można załadować", "no-options-available": "Brak dostępnych opcji.", "click-to-load-options": "Kliknij, aby załadować opcje.", "select-an-option": "Wybierz opcję", "summary-text": { zero: "Nie wybrano żadnych wpisów", one: '<span class="monster-badge-primary-pill">1</span> wpis został wybrany', other: '<span class="monster-badge-primary-pill">${count}</span> wpisy zostały wybrane', }, "no-options": "Niestety, nie ma dostępnych opcji na liście.", "no-options-found": "Brak dostępnych opcji na liście. Rozważ zmianę filtra.", }; case "no": return { "cannot-be-loaded": "Kan ikke lastes", "no-options-available": "Ingen alternativer tilgjengelig.", "click-to-load-options": "Klikk for å laste alternativer.", "select-an-option": "Velg et alternativ", "summary-text": { zero: "Ingen oppføringer ble valgt", one: '<span class="monster-badge-primary-pill">1</span> oppføring valgt', other: '<span class="monster-badge-primary-pill">${count}</span> oppføringer valgt', }, "no-options": "Dessverre er det ingen alternativer tilgjengelig i listen.", "no-options-found": "Ingen alternativer tilgjengelig på listen. Vurder å endre filteret.", }; case "dk": return { "cannot-be-loaded": "Kan ikke indlæses", "no-options-available": "Ingen muligheder tilgængelige.", "click-to-load-options": "Klik for at indlæse muligheder.", "select-an-option": "Vælg en mulighed", "summary-text": { zero: "Ingen indlæg blev valgt", one: '<span class="monster-badge-primary-pill">1</span> indlæg blev valgt', other: '<span class="monster-badge-primary-pill">${count}</span> indlæg blev valgt', }, "no-options": "Desværre er der ingen muligheder tilgængelige på listen.", "no-options-found": "Ingen muligheder tilgængelige på listen. Overvej at ændre filteret.", }; case "sw": return { "cannot-be-loaded": "Kan inte laddas", "no-options-available": "Inga alternativ tillgängliga.", "click-to-load-options": "Klicka för att ladda alternativ.", "select-an-option": "Välj ett alternativ", "summary-text": { zero: "Inga poster valdes", one: '<span class="monster-badge-primary-pill">1</span> post valdes', other: '<span class="monster-badge-primary-pill">${count}</span> poster valdes', }, "no-options": "Tyvärr finns det inga alternativ tillgängliga i listan.", "no-options-found": "Inga alternativ finns tillgängliga i listan. Överväg att modifiera filtret.", }; default: case "en": return { "cannot-be-loaded": "Cannot be loaded", "no-options-available": "No options available.", "click-to-load-options": "Click to load options.", "select-an-option": "Select an option", "summary-text": { zero: "No entries were selected", one: '<span class="monster-badge-primary-pill">1</span> entry was selected', other: '<span class="monster-badge-primary-pill">${count}</span> entries were selected', }, "no-options": "Unfortunately, there are no options available in the list.", "no-options-found": "No options are available in the list. Please consider modifying the filter.", }; } } /** * @private */ function lookupSelection() { const self = this; setTimeout(() => { const selection = self.getOption("selection"); if (selection.length === 0) { return; } if (self[isLoadingSymbol] === true) { return; } if (self[lazyLoadDoneSymbol] === true) { return; } let url = self.getOption("url"); let lookupUrl = self.getOption("lookup.url"); if (lookupUrl !== null) { url = lookupUrl; } if (this.getOption("lookup.grouping") === true) { filterFromRemoteByValue .call( self, url, selection.map((s) => s?.["value"]), ) .catch((e) => { addErrorAttribute(self, e); }); return; } for (const s of selection) { if (s?.["value"]) { filterFromRemoteByValue.call(self, url, s?.["value"]).catch((e) => { addErrorAttribute(self, e); }); } } }, 100); } function fetchIt(url, controlOptions) { if (url instanceof URL) { url = url.toString(); } if (url !== undefined && url !== null) { url = validateString(url); } else { url = this.getOption("url"); if (url === null) { return Promise.reject(new Error("No url defined")); } } return new Promise((resolve, reject) => { setStatusOrRemoveBadges.call(this, "loading"); new Processing(10, () => { fetchData .call(this, url) .then((map) => { if ( isObject(map) || isArray(map) || map instanceof Set || map instanceof Map ) { try { this.importOptions(map); } catch (e) { setStatusOrRemoveBadges.call(this, "error"); reject(e); return; } this[lastFetchedDataSymbol] = map; let result; const selection = this.getOption("selection"); let newValue = []; if (selection) { newValue = selection; } else if (this.hasAttribute("value")) { newValue = this.getAttribute("value"); } result = setSelection.call(this, newValue); requestAnimationFrame(() => { checkOptionState.call(this); setStatusOrRemoveBadges.call(this, "closed"); updatePopper.call(this); resolve(result); }); return; } setStatusOrRemoveBadges.call(this, "error"); reject(new Error("invalid response")); }) .catch((e) => { setStatusOrRemoveBadges.call(this, "error"); reject(e); }); }) .run() .catch((e) => { setStatusOrRemoveBadges.call(this, "error"); addErrorAttribute(this, e); reject(e); }); }); } /** * This attribute can be used to pass a URL to this select. * * ``` * <monster-select data-monster-url="https://example.com/"></monster-select> * ``` * * @private * @deprecated 2024-01-21 (you should use data-monster-option-...) * @return {object} */ function initOptionsFromArguments() { const options = {}; const template = this.getAttribute("data-monster-selected-template"); if (isString(template)) { if (!options["templateMapping"]) options["templateMapping"] = {}; switch (template) { case "summary": case "default": options["templateMapping"]["selected"] = getSummaryTemplate(); break; case "selected": options["templateMapping"]["selected"] = getSelectionTemplate(); break; default: addErrorAttribute(this, "invalid template, use summary or selected"); } } return options; } /** * @private */ function attachResizeObserver() { // against flickering this[resizeObserverSymbol] = new ResizeObserver((entries) => { if (this[timerCallbackSymbol] instanceof DeadMansSwitch) { try { this[timerCallbackSymbol].touch(); return; } catch (e) { delete this[timerCallbackSymbol]; } } this[timerCallbackSymbol] = new DeadMansSwitch(200, () => { updatePopper.call(this); delete this[timerCallbackSymbol]; }); }); this[resizeObserverSymbol].observe(this.parentElement); } /** * @private */ function disconnectResizeObserver() { if (this[resizeObserverSymbol] instanceof ResizeObserver) { this[resizeObserverSymbol].disconnect(); } } /** * @private * @returns {string} */ function getSelectionTemplate() { return `<div data-monster-role="selection" part="selection" data-monster-insert="selection path:selection" role="search" ><input type="text" role="searchbox" part="inline-filter" name="inline-filter" data-monster-role="filter" autocomplete="off" tabindex="0" ><div data-monster-replace="path:messages.control"></div> </div>`; } /** * @private * @returns {string} */ function getSummaryTemplate() { return `<div data-monster-role="selection" role="search" part="summary"> <input type="text" role="searchbox" part="inline-filter" name="inline-filter" data-monster-role="filter" autocomplete="off" tabindex="0" > <div data-monster-replace="path:messages.selected"></div> </div>`; } /** * @return {void} * @private */ function parseSlotsToOptions() { let options = this.getOption("options"); if (!isIterable(options)) { options = []; } let counter = 1; getSlottedElements.call(this, "div").forEach((node) => { let value = (counter++).toString(); let visibility = "visible"; if (node.hasAttribute("data-monster-value")) { value = node.getAttribute("data-monster-value"); } let label = node.outerHTML; if (node.style.display === "none") { visibility = "hidden"; } options.push({ value, label, visibility, }); }); runAsOptionLengthChanged.call(this, options.length); this.setOption("options", options); } /** * wait until all options are finished rendering * * @private * @param {int} targetLength */ function runAsOptionLengthChanged(targetLength) { const self = this; if (!self[optionsElementSymbol]) { return; } const callback = function (mutationsList, observer) { const run = false; for (const mutation of mutationsList) { if (mutation.type === "childList") { const run = true; break; } } if (run === true) { const nodes = self[optionsElementSymbol].querySelectorAll( `div[${ATTRIBUTE_ROLE}=option]`, ); if (nodes.length === targetLength) { checkOptionState.call(self); observer.disconnect(); } } }; const observer = new MutationObserver(callback); observer.observe(self[optionsElementSymbol], { attributes: false, childList: true, subtree: true, }); } /** * @private * @param {*} value * @return {*} */ function buildSelectionLabel(value) { const options = this.getOption("options"); for (let i = 0; i < options.length; i++) { let o = options?.[i]; let l, v, v2; if (this.getOption("features.useStrictValueComparison") === true) { v = value; } else { v = `${value}`; } if (isPrimitive(o) && o === value) { return o; } else if (!isObject(o)) { continue; } if (this.getOption("features.useStrictValueComparison") === true) { l = o?.["label"]; v2 = o?.["value"]; } else { l = `${o?.["label"]}`; v2 = `${o?.["value"]}`; } if (v2 === v) { return l; } } return undefined; } /** * @private * @param {*} value * @return {string} * @throws {Error} no value found */ function getSelectionLabel(value) { const callback = this.getOption("formatter.selection"); if (isFunction(callback)) { const label = callback.call(this, value); if (isString(label)) return label; } if (isString(value) || isInteger(value)) { return `${value}`; } return this.getOption("labels.cannot-be-loaded", value); } /** * @private * @param {Event} event */ function handleToggleKeyboardEvents(event) { switch (event?.["code"]) { case "Escape": toggle.call(this); event.preventDefault(); break; case "Space": toggle.call(this); event.preventDefault(); break; case "ArrowDown": show.call(this); activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN); event.preventDefault(); break; case "ArrowUp": hide.call(this); event.preventDefault(); break; } } /** * @license AGPLv3 * @since 1.15.0 * @private * @this CustomElement */ function initOptionObserver() { const self = this; self.attachObserver( new Observer(function () { new Processing(() => { try { self.updateI18n(); } catch (e) { addErrorAttribute(self, e); requestAnimationFrame(() => { setStatusOrRemoveBadges.call(self, "error"); }); } try { areOptionsAvailableAndInit.call(self); } catch (e) { addErrorAttribute(self, e); requestAnimationFrame(() => { setStatusOrRemoveBadges.call(self, "error"); }); } setSummaryAndControlText.call(self); }).run(); }), ); } /** * @private * @returns {Translations} */ function getDefaultTranslation() { const translation = new Translations("en").assignTranslations( this.getOption("labels", {}), ); try { const doc = getDocumentTranslations(); translation.locale = doc.locale; } catch (e) {} return translation; } /** * @private * @return {string|*} */ function setSummaryAndControlText() { const translations = getDefaultTranslation.call(this); const selections = this.getOption("selection"); const text = translations.getPluralRuleText( "summary-text", selections.length, "", ); const selectedText = new Formatter({ count: String(selections.length), }).format(text); this.setOption("messages.selected", selectedText); const current = this.getOption("messages.control"); const msg = this.getOption("labels.select-an-option"); if ( current === "" || current === undefined || current === msg || current === null ) { if (selections.length === 0) { this.setOption("messages.control", msg); } else { this.setOption("messages.control", ""); } } } /** * @private * @return {NodeList} */ function getOptionElements() { return this[optionsElementSymbol].querySelectorAll( `[${ATTRIBUTE_ROLE}=option]`, ); } /** * With the help of this filter callback, values can be filtered out. Only if the filter function returns true, the value is taken for the map. * * @callback Monster.Components.Form~exampleFilterCallback * @param {*} value Value * @param {string} key Key * @see Monster.Data.buildMap */ /** * * @callback Monster.Components.Form~formatterSelectionCallback * @param {*} value Value * @return {string|undefined} * @see Monster.Data.buildMap */ /** * @private */ function calcAndSetOptionsDimension() { const options = getOptionElements.call(this); const container = this[optionsElementSymbol]; if (!(container instanceof HTMLElement && options instanceof NodeList)) { return; } let visible = 0; let optionHeight = 0; const max = this.getOption("showMaxOptions", 10); let scrollFlag = false; for (const [, option] of Object.entries(options)) { const computedStyle = getGlobal().getComputedStyle(option); if (computedStyle.display === "none") continue; let h = option.getBoundingClientRect().height; h += parseInt(computedStyle.getPropertyValue("margin-top"), 10); h += parseInt(computedStyle.getPropertyValue("margin-bottom"), 10); optionHeight += h; visible++; if (visible > max) { break; } } if (visible > max) { visible = max; scrollFlag = true; } if (visible === 0) { if (getFilterMode.call(this) === FILTER_MODE_DISABLED) { this.setOption( "messages.emptyOptions", this.getOption("labels.no-options-available"), ); } else { this.setOption( "messages.emptyOptions", this.getOption("labels.no-options-found"), ); } this[noOptionsAvailableElementSymbol].classList.remove("d-none"); } else { this[noOptionsAvailableElementSymbol].classList.add("d-none"); } const styles = getGlobal().getComputedStyle(this[optionsElementSymbol]); let padding = parseInt(styles.getPropertyValue("padding-top"), 10); padding += parseInt(styles.getPropertyValue("padding-bottom"), 10); let margin = parseInt(styles.getPropertyValue("margin-top"), 10); margin += parseInt(styles.getPropertyValue("margin-bottom"), 10); const containerHeight = optionHeight + padding + margin; container.style.height = `${containerHeight}px`; if (scrollFlag === true) { container.style.overflowY = "scroll"; } else { container.style.overflowY = "auto"; } const domRect = this[controlElementSymbol].getBoundingClientRect(); this[popperElementSymbol].style.width = `${domRect.width}px`; container.style.overflowX = "auto"; } /** * @private * @param {number} direction * @throws {Error} no shadow-root is defined */ function activateCurrentOption(direction) { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } let focused = this.shadowRoot.querySelector(`[${ATTRIBUTE_PREFIX}focused]`); if ( !(focused instanceof HTMLElement) || focused.matches("[data-monster-visibility=hidden]") ) { for (const [, e] of Object.entries( this.shadowRoot.querySelectorAll(`[${ATTRIBUTE_ROLE}=option]`), )) { if (e.matches("[data-monster-visibility=visible]")) { focused = e; break; } } } else { if (direction === FOCUS_DIRECTION_DOWN) { while (focused.nextSibling) { focused = focused.nextSibling; if ( focused instanceof HTMLElement && focused.hasAttribute(ATTRIBUTE_ROLE) && focused.getAttribute(ATTRIBUTE_ROLE) === "option" && focused.matches("[data-monster-visibility=visible]") && focused.matches(":not([data-monster-filtered=true])") ) { break; } } } else { let found = false; while (focused.previousSibling) { focused = focused.previousSibling; if ( focused instanceof HTMLElement && focused.hasAttribute(ATTRIBUTE_ROLE) && focused.getAttribute(ATTRIBUTE_ROLE) === "option" && focused.matches("[data-monster-visibility=visible]") && focused.matches(":not([data-monster-filtered=true])") ) { found = true; break; } } if (found === false) { focusFilter.call(this); } } } new Processing(() => { if (focused instanceof HTMLElement) { this.shadowRoot .querySelectorAll(`[${ATTRIBUTE_PREFIX}focused]`) .forEach((e) => { e.removeAttribute(`${ATTRIBUTE_PREFIX}focused`); }); focused.focus(); focused.setAttribute(`${ATTRIBUTE_PREFIX}focused`, true); } }) .run() .catch((e) => { addErrorAttribute(this, e); }); } /** * @private */ function filterOptions() { new Processing(() => { let filterValue; switch (this.getOption("filter.position")) { case FILTER_POSITION_INLINE: if (this[inlineFilterElementSymbol] instanceof HTMLElement) { filterValue = this[inlineFilterElementSymbol].value.toLowerCase(); } else { return; } break; case FILTER_POSITION_POPPER: default: if (this[popperFilterElementSymbol] instanceof HTMLInputElement) { filterValue = this[popperFilterElementSymbol].value.toLowerCase(); } else { return; } } const options = this.getOption("options"); for (const [i, option] of Object.entries(options)) { if (option.label.toLowerCase().indexOf(filterValue) === -1) { this.setOption(`options.${i}.filtered`, "true"); } else { this.setOption(`options.${i}.filtered`, undefined); } } }) .run() .then(() => { new Processing(100, () => { calcAndSetOptionsDimension.call(this); focusFilter.call(this); }) .run() .catch((e) => { addErrorAttribute(this, e); }); }) .catch((e) => { addErrorAttribute(this, e); }); } /** * @private * @param {Event} event */ function handleFilterKeyboardEvents(event) { const shiftKey = event?.["shiftKey"]; switch (event?.["code"]) { case "Tab": activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN); event.preventDefault(); break; case "Escape": toggle.call(this); event.preventDefault(); break; case "Tab" && shiftKey === true: case "ArrowUp": activateCurrentOption.call(this, FOCUS_DIRECTION_UP); event.preventDefault(); break; case "Tab" && !shiftKey: case "ArrowDown": activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN); event.preventDefault(); break; default: if ( this.getOption("features.lazyLoad") === true && this[lazyLoadDoneSymbol] !== true ) { this.click(); } handleFilterKeyEvents.call(this); } } /** * Method handleFilterKeyEvents is used to handle filter key events. * Debounce is used to prevent multiple calls. * * @function * @name handleFilterKeyEvents * * @private * @return {void} This method does not return anything. */ function handleFilterKeyEvents() { if (this[keyFilterEventSymbol] instanceof DeadMansSwitch) { try { this[keyFilterEventSymbol].touch(); return; } catch (e) { delete this[keyFilterEventSymbol]; } } this[keyFilterEventSymbol] = new DeadMansSwitch(200, () => { if (getFilterMode.call(this) !== FILTER_MODE_REMOTE) { filterOptions.call(this); } else { filterFromRemote.call(this).catch((e) => { addErrorAttribute(this, e); }); } delete this[keyFilterEventSymbol]; }); } /** * @private */ function filterFromRemote() { if ( !(this[inlineFilterElementSymbol] instanceof HTMLElement) && !(this[popperFilterElementSymbol] instanceof HTMLElement) ) { return; } show.call(this); const url = this.getOption("url"); if (!url) { addErrorAttribute(this, "Missing URL for Remote Filter."); return; } let filterValue; switch (this.getOption("filter.position")) { case FILTER_POSITION_INLINE: if (this[inlineFilterElementSymbol] instanceof HTMLElement) { filterValue = this[inlineFilterElementSymbol].value.toLowerCase(); } break; case FILTER_POSITION_POPPER: default: if (this[popperFilterElementSymbol] instanceof HTMLInputElement) { filterValue = this[popperFilterElementSymbol].value.toLowerCase(); } } return filterFromRemoteByValue.call(this, url, filterValue); } function formatURL(url, value) { if (value === undefined || value === null || value === "") { value = this.getOption("filter.defaultValue"); if (value === undefined || value === null || value === "") { value = disabledRequestMarker.toString(); } } const formatter = new Formatter({ filter: encodeURI(value) }); const openMarker = this.getOption("filter.marker.open"); let closeMarker = this.getOption("filter.marker.close"); if (!closeMarker) { closeMarker = openMarker; } if (openMarker && closeMarker) { formatter.setMarker(openMarker, closeMarker); } return formatter.format(url); } /** * @private * @param optionUrl * @param value * @returns {Promise<unknown>} */ function filterFromRemoteByValue(optionUrl, value) { return new Processing(() => { let url = formatURL.call(this, optionUrl, value); if (url.indexOf(disabledRequestMarker.toString()) !== -1) { return; } fetchIt .call(this, url, { disableHiding: true, }) .then(() => { checkOptionState.call(this); show.call(this); }) .catch((e) => { throw e; }); }) .run() .catch((e) => { throw e; }); } /** * @param {Event} event * @private */ function handleOptionKeyboardEvents(event) { const shiftKey = event?.["shiftKey"]; switch (event?.["code"]) { case "Escape": toggle.call(this); event.preventDefault(); break; case "Enter": case "Space": const path = event.composedPath(); const element = path?.[0]; if (element instanceof HTMLElement) { const input = element.getElementsByTagName("input"); if (!input) { return; } fireEvent(input, "click"); } event.preventDefault(); break; case "Tab" && shiftKey === true: case "ArrowUp": activateCurrentOption.call(this, FOCUS_DIRECTION_UP); event.preventDefault(); break; case "Tab" && !shiftKey: case "ArrowLeft": case "ArrowRight": // handled by tree select break; case "ArrowDown": activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN); event.preventDefault(); break; default: const p = event.composedPath(); if (p?.[0] instanceof HTMLInputElement) { return; } focusFilter.call(this); break; } } /** * @private * @return {string} */ function getFilterMode() { switch (this.getOption("filter.mode")) { case FILTER_MODE_OPTIONS: return FILTER_MODE_OPTIONS; case FILTER_MODE_REMOTE: return FILTER_MODE_REMOTE; default: return FILTER_MODE_DISABLED; } } /** * @private */ function blurFilter() { if (!(this[inlineFilterElementSymbol] instanceof HTMLElement)) { return; } if (getFilterMode.call(this) === FILTER_MODE_DISABLED) { return; } this[popperFilterContainerElementSymbol].classList.remove("active"); this[popperFilterContainerElementSymbol].blur(); this[inlineFilterElementSymbol].classList.remove("active"); this[inlineFilterElementSymbol].blur(); } /** * @private * @param focusOptions */ function focusPopperFilter(focusOptions) { this[popperFilterContainerElementSymbol].classList.remove("d-none"); this[popperFilterElementSymbol].classList.add("active"); this[inlineFilterElementSymbol].classList.remove("active"); this[inlineFilterElementSymbol].classList.add("d-none"); if (!(this[popperFilterElementSymbol] instanceof HTMLElement)) { addErrorAttribute(this, "Missing Popper Filter Element."); return; } // visibility is set to visible, because focus() does not work on invisible elements // and the class definition is assigned later in the processing setTimeout(() => { if (focusOptions === undefined || focusOptions === null) { this[popperFilterElementSymbol].focus(); } else { this[popperFilterElementSymbol].focus(focusOptions); } }, 100); } /** * @private * @param focusOptions */ function focusInlineFilter(focusOptions) { const options = this.getOption("options"); if ( (!isArray(options) || options.length === 0) && getFilterMode.call(this) !== FILTER_MODE_REMOTE ) { return; } this[popperFilterContainerElementSymbol].classList.add("d-none"); this[inlineFilterElementSymbol].classList.add("active"); this[inlineFilterElementSymbol].classList.remove("d-none"); // visibility is set to visible, because focus() does not work on invisible elements // and the class definition is assigned later in the processing setTimeout(() => { if (focusOptions === undefined || focusOptions === null) { this[inlineFilterElementSymbol].focus(); } else { this[inlineFilterElementSymbol].focus(focusOptions); } }, 100); } /** * @private */ function focusFilter(focusOptions) { if (getFilterMode.call(this) === FILTER_MODE_DISABLED) { this[popperFilterContainerElementSymbol].classList.add("d-none"); this[inlineFilterElementSymbol].classList.add("d-none"); return; } if (this.getOption("filter.position") === FILTER_POSITION_INLINE) { return focusInlineFilter.call(this, focusOptions); } return focusPopperFilter.call(this, focusOptions); } /** * @private * @return {array} * @throws {Error} no shadow-root is defined * @throws {Error} unsupported type */ function gatherState() { const type = this.getOption("type"); if (["radio", "checkbox"].indexOf(type) === -1) { throw new Error("unsupported type"); } if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } const selection = []; const elements = this.shadowRoot.querySelectorAll( `input[type=${type}]:checked`, ); for (const e of elements) { selection.push({ label: getSelectionLabel.call(this, e.value), value: e.value, }); } setSelection .call(this, selection) .then(() => {}) .catch((e) => { addErrorAttribute(this, e); }); if (this.getOption("features.closeOnSelect") === true) { toggle.call(this); } return this; } /** * @private * @throws {Error} no shadow-root is defined * @throws {Error} unsupported type */ function clearSelection() { const type = this.getOption("type"); if (["radio", "checkbox"].indexOf(type) === -1) { throw new Error("unsupported type"); } if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } setSelection .call(this, []) .then(() => {}) .catch((e) => { addErrorAttribute(this, e); }); } /** * @private */ function areOptionsAvailableAndInit() { // prevent multiple calls if (this[areOptionsAvailableAndInitSymbol] === undefined) { this[areOptionsAvailableAndInitSymbol] = 0; } if (this[areOptionsAvailableAndInitSymbol] > 0) { this[areOptionsAvailableAndInitSymbol]--; return true; } this[areOptionsAvailableAndInitSymbol]++; const options = this.getOption("options"); if ( options === undefined || options === null || (isArray(options) && options.length === 0) ) { setStatusOrRemoveBadges.call(this, "empty"); // hide.call(this); let msg = this.getOption("labels.no-options-available"); if ( this.getOption("url") !== null && this.getOption("features.lazyLoad") === true && this[lazyLoadDoneSymbol] !== true ) { msg = this.getOption("labels.click-to-load-options"); } this.setOption("messages.control", msg); this.setOption("messages.summary", ""); if (this.getOption("features.emptyValueIfNoOptions") === true) { this.value = ""; } addErrorAttribute(this, "No options available."); return false; } const selections = this.getOption("selection"); if ( selections === undefined || selections === null || selections.length === 0 ) { this.setOption( "messages.control", this.getOption("labels.select-an-option"), ); } else { this.setOption("messages.control", ""); } this.setOption("messages.summary", setSummaryAndControlText.call(this)); let updated = false; let valueCounter = 1; for (const option of options) { if (option?.visibility === undefined) { option.visibility = "visible"; updated = true; } if (option?.value === undefined && option?.label === undefined) { option.value = `${valueCounter++}`; option.label = option.value; updated = true; continue; } if (option?.value === undefined) { option.value = option.label; updated = true; } if (option?.label === undefined) { option.label = option.value; updated = true; } } if (updated) { this.setOption("options", options); } setStatusOrRemoveBadges.call(this); removeErrorAttribute(this, "No options available."); return true; } /** * @private * @throws {Error} no shadow-root is defined */ function checkOptionState() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } const elements = this.shadowRoot.querySelectorAll( `[${ATTRIBUTE_ROLE}=option] input`, ); let selection = this.getOption("selection"); if (!isArray(selection)) { selection = []; } const checkedValues = selection.map((a) => { return a.value; }); for (const e of elements) { if (checkedValues.indexOf(e.value) !== -1) { if (e.checked !== true) e.checked = true; } else { if (e.checked !== false) e.checked = false; } } } /** * @private * @param {*} value * @return {Object} */ function convertValueToSelection(value) { const selection = []; if (isString(value)) { value = value .split(",") .map((a) => { return a.trim(); }) .filter((a) => { return a !== ""; }); } if (isString(value) || isInteger(value)) { selection.push({ label: getSelectionLabel.call(this, value), value: value, }); } else if (isArray(value)) { for (const v of value) { selection.push({ label: getSelectionLabel.call(this, v), value: v, }); } value = value.join(","); } else { throw new Error("unsupported type"); } return { selection: selection, value: value, }; } /** * @private * @param {array} selection * @return {string} */ function convertSelectionToValue(selection) { const value = []; if (isArray(selection)) { for (const obj of selection) { const v = obj?.["value"]; if (v !== undefined) value.push(`${v}`); } } if (value.length === 0) { return ""; } else if (value.length === 1) { const v = value.pop(); if (v === undefined) return ""; if (v === null) return ""; return `${v}`; } return value.join(","); } /** * @private * @param {array} selection * @return {Promise} * @throws {Error} no shadow-root is defined */ function setSelection(selection) { if (isString(selection) || isInteger(selection)) { const result = convertValueToSelection.call(this, selection); selection = result?.selection; } else if (selection === undefined || selection === null) { selection = []; } validateArray(selection); for (let i = 0; i < selection.length; i++) { let l = getSelectionLabel.call(this, selection[i].value); if (l === selection[i].value) { l = selection[i].label; } selection[i] = { label: l, value: selection[i].value, }; } this.setOption("selection", selection); checkOptionState.call(this); setSummaryAndControlText.call(this); try { this?.setFormValue(this.value); } catch (e) { addErrorAttribute(this, e); } fireCustomEvent(this, "monster-selected", { selection, }); fireEvent(this, "change"); return new Processing(() => { const CLASSNAME = "selected"; if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } const notSelected = this.shadowRoot.querySelectorAll(":not(:checked)"); if (notSelected) { notSelected.forEach((node) => { const parent = node.closest(`[${ATTRIBUTE_ROLE}=option]`); if (parent) { parent.classList.remove(CLASSNAME); } }); } const selected = this.shadowRoot.querySelectorAll(":checked"); if (selected) { selected.forEach((node) => { const parent = node.closest(`[${ATTRIBUTE_ROLE}=option]`); if (parent) { parent.classList.add(CLASSNAME); } }); } }) .run() .catch((e) => { addErrorAttribute(this, e); }); } /** * @private * @param {string} url * @return {Promise} * @throws {TypeError} the result cannot be parsed * @throws {TypeError} unsupported response */ function fetchData(url) { const self = this; if (!url) url = this.getOption("url"); if (!url) return Promise.resolve(); const fetchOptions = this.getOption("fetch", {}); let delayWatch = false; // if fetch short time, do not show loading badge, because of flickering requestAnimationFrame(() => { if (delayWatch === true) return; setStatusOrRemoveBadges.call(this, "loading"); delayWatch = true; }); url = formatURL.call(this, url); self[isLoadingSymbol] = true; const global = getGlobal(); return global .fetch(url, fetchOptions) .then((response) => { self[isLoadingSymbol] = false; delayWatch = true; const contentType = response.headers.get("content-type"); if (contentType && contentType.indexOf("application/json") !== -1) { return response.text(); } throw new TypeError(`unsupported response ${contentType}`); }) .then((text) => { try { return Promise.resolve(JSON.parse(String(text))); } catch (e) { throw new TypeError("the result cannot be parsed, check the URL"); } }) .catch((e) => { self[isLoadingSymbol] = false; delayWatch = true; throw e; }); } /** * @private */ function hide() { this[popperElementSymbol].style.display = "none"; setStatusOrRemoveBadges.call(this, "closed"); removeAttributeToken(this[controlElementSymbol], "class", "open"); } /** * @private */ function show() { if (this.getOption("disabled", undefined) === true) { return; } if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { return; } focusFilter.call(this); const lazyLoadFlag = this.getOption("features.lazyLoad") && this[lazyLoadDoneSymbol] !== true; if (lazyLoadFlag === true) { this[lazyLoadDoneSymbol] = true; setStatusOrRemoveBadges.call(this, "loading"); new Processing(200, () => { this.fetch() .then(() => { checkOptionState.call(this); requestAnimationFrame(() => { show.call(this); }); }) .catch((e) => { addErrorAttribute(this, e); setStatusOrRemoveBadges.call(this, "error"); }); }) .run() .catch((e) => { addErrorAttribute(this, e); setStatusOrRemoveBadges.call(this, "error"); }); return; } const hasPopperFilterFlag = this.getOption("filter.position") === FILTER_POSITION_POPPER && getFilterMode.call(this) !== FILTER_MODE_DISABLED; const options = getOptionElements.call(this); if (options.length === 0 && hasPopperFilterFlag === false) { return; } this[popperElementSymbol].style.visibility = "hidden"; this[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK; setStatusOrRemoveBadges.call(this, "open"); addAttributeToken(this[controlElementSymbol], "class", "open"); new Processing(() => { calcAndSetOptionsDimension.call(this); focusFilter.call(this); this[popperElementSymbol].style.removeProperty("visibility"); updatePopper.call(this); }) .run() .catch((e) => { addErrorAttribute(this, e); }); } /** * @private */ function toggle() { if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { hide.call(this); } else { show.call(this); } } /** * @private * @fires monster-selection-removed * @fires monster-selection-cleared */ function initEventHandler() { const self = this; /** * @param {Event} event */ self[clearOptionEventHandler] = (event) => { const element = findTargetElementFromEvent( event, ATTRIBUTE_ROLE, "remove-badge", ); if (element instanceof HTMLElement) { const badge = findClosestByAttribute(element, ATTRIBUTE_ROLE, "badge"); if (badge instanceof HTMLElement) { const value = badge.getAttribute(`${ATTRIBUTE_PREFIX}value`); let selection = self.getOption("selection"); selection = selection.filter((b) => { return value !== b.value; }); setSelection .call(self, selection) .then(() => { fireCustomEvent(self, "monster-selection-removed", { value, }); }) .catch((e) => { addErrorAttribute(self, e); }); } } }; /** * @param {Event} event */ self[closeEventHandler] = (event) => { const path = event.composedPath(); for (const [, element] of Object.entries(path)) { if (element === self) { return; } } hide.call(self); }; /** * @param {Event} event */ self[inputEventHandler] = (event) => { const path = event.composedPath(); const element = path?.[0]; if (element instanceof HTMLElement) { if ( element.hasAttribute(ATTRIBUTE_ROLE) && element.getAttribute(ATTRIBUTE_ROLE) === "option-control" ) { fireCustomEvent(self, "monster-change", { type: event.type, value: element.value, checked: element.checked, }); } else if ( element.hasAttribute(ATTRIBUTE_ROLE) && element.getAttribute(ATTRIBUTE_ROLE) === "filter" ) { } } }; /** * @param {Event} event */ self[changeEventHandler] = (event) => { gatherState.call(self); fireCustomEvent(self, "monster-changed", event?.detail); }; self[keyEventHandler] = (event) => { const path = event.composedPath(); const element = path.shift(); let role; if (element instanceof HTMLElement) { if (element.hasAttribute(ATTRIBUTE_ROLE)) { role = element.getAttribute(ATTRIBUTE_ROLE); } else if (element === this) { show.call(this); // focusFilter.call(self); } else { const e = element.closest(`[${ATTRIBUTE_ROLE}]`); if (e instanceof HTMLElement && e.hasAttribute(ATTRIBUTE_ROLE)) { role = e.getAttribute(ATTRIBUTE_ROLE); } } } else { return; } switch (role) { case "filter": handleFilterKeyboardEvents.call(self, event); break; case "option-label": case "option-control": case "option": handleOptionKeyboardEvents.call(self, event); break; case "control": case "toggle": handleToggleKeyboardEvents.call(self, event); break; } }; const types = self.getOption("toggleEventType", ["click"]); for (const [, type] of Object.entries(types)) { self[controlElementSymbol] .querySelector(`[${ATTRIBUTE_ROLE}="container"]`) .addEventListener(type, function (event) { const element = findTargetElementFromEvent( event, ATTRIBUTE_ROLE, "remove-badge", ); if (element instanceof HTMLElement) { return; } toggle.call(self); }); self[controlElementSymbol] .querySelector(`[${ATTRIBUTE_ROLE}="status-or-remove-badges"]`) .addEventListener(type, function (event) { if (self.getOption("disabled", undefined) === true) { return; } const path = event.composedPath(); const element = path?.[0]; if (element instanceof HTMLElement) { const control = element.closest( `[${ATTRIBUTE_ROLE}="status-or-remove-badges"]`, ); if (control instanceof HTMLElement) { if (control.classList.contains("clear")) { clearSelection.call(self); fireCustomEvent(self, "monster-selection-cleared", {}); } else { const element = findTargetElementFromEvent( event, ATTRIBUTE_ROLE, "remove-badge", ); if (element instanceof HTMLElement) { return; } toggle.call(self); } } } }); // badge, selection self.addEventListener(type, self[clearOptionEventHandler]); } self.addEventListener("monster-change", self[changeEventHandler]); self.addEventListener("input", self[inputEventHandler]); self.addEventListener("keydown", self[keyEventHandler]); return self; } /** * @private * @return {Select} */ function setStatusOrRemoveBadges(suggestion) { requestAnimationFrame(() => { const selection = this.getOption("selection"); const clearAllFlag = isArray(selection) && selection.length > 0 && this.getOption("features.clearAll") === true; const current = this.getOption("classes.statusOrRemoveBadge"); if (suggestion === "error") { if (current !== "error") { this.setOption("classes.statusOrRemoveBadge", "error"); } return; } if (this[isLoadingSymbol] === true) { if (current !== "loading") { this.setOption("classes.statusOrRemoveBadge", "loading"); } return; } if (suggestion === "loading") { if (current !== "loading") { this.setOption("classes.statusOrRemoveBadge", "loading"); } return; } if (clearAllFlag) { if (current !== "clear") { this.setOption("classes.statusOrRemoveBadge", "clear"); } return; } if (this[controlElementSymbol].classList.contains("open")) { if (current !== "open") { this.setOption("classes.statusOrRemoveBadge", "open"); } return; } const options = this.getOption("options"); if ( options === undefined || options === null || (isArray(options) && options.length === 0) ) { if (current !== "empty") { this.setOption("classes.statusOrRemoveBadge", "empty"); } return; } if (suggestion) { if (current !== suggestion) { this.setOption("classes.statusOrRemoveBadge", suggestion); } return; } }); } /** * @private * @return {Select} * @throws {Error} no shadow-root is defined */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[controlElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=control]`, ); this[selectionElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=selection]`, ); this[containerElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=container]`, ); this[popperElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=popper]`, ); this[inlineFilterElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=filter][name="inline-filter"]`, ); this[popperFilterElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=filter][name="popper-filter"]`, ); this[popperFilterContainerElementSymbol] = this[popperFilterElementSymbol].parentElement; this[optionsElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=options]`, ); this[noOptionsAvailableElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="no-options"]`, ); this[statusOrRemoveBadgesElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=status-or-remove-badges]`, ); } /** * @private */ function updatePopper() { if (this[popperElementSymbol].style.display !== STYLE_DISPLAY_MODE_BLOCK) { return; } if (this.getOption("disabled", false) === true) { return; } new Processing(() => { calcAndSetOptionsDimension.call(this); positionPopper.call( this, this[controlElementSymbol], this[popperElementSymbol], this.getOption("popper", {}), ); }) .run() .catch((e) => { addErrorAttribute(this, e); }); return this; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="options"> <div data-monster-role="option" tabindex="-1" data-monster-attributes=" data-monster-filtered path:options.filtered, data-monster-visibility path:options.visibility"> <label part="option"> <input data-monster-role="option-control" data-monster-attributes=" type path:type, role path:role, value path:options | index:value, name path:name, part path:type | prefix:option- | suffix: form, class path:options.class " tabindex="-1"> <div data-monster-replace="path:options | index:label" part="option-label"></div> </label> </div> </template> <template id="selection"> <div data-monster-role="badge" part="badge" data-monster-attributes=" data-monster-value path:selection | index:value, class path:classes | index:badge, part path:type | suffix:-option | prefix: form-" tabindex="-1"> <div data-monster-replace="path:selection | index:label" part="badge-label" data-monster-role="badge-label"></div> <div part="remove-badge" data-monster-select-this data-monster-attributes="class path:features.clear | ?::hidden " data-monster-role="remove-badge" tabindex="-1"></div> </div> </template> <slot class="hidden"></slot> <div data-monster-role="control" part="control" tabindex="0"> <div data-monster-role="container"> \${selected} </div> <div data-monster-role="popper" part="popper" tabindex="-1" class="monster-color-primary-1"> <div class="option-filter-control" role="search"> <input type="text" role="searchbox" part="popper-filter" name="popper-filter" data-monster-role="filter" autocomplete="off" tabindex="0"> </div> <div part="content" class="flex" data-monster-replace="path:content"> <div part="options" data-monster-role="options" data-monster-insert="options path:options" tabindex="-1"></div> </div> <div part="no-options" data-monster-role="no-options" data-monster-replace="path:messages.emptyOptions"></div> </div> <div part="status-or-remove-badges" data-monster-role="status-or-remove-badges" data-monster-attributes="class path:classes.statusOrRemoveBadge"></div> </div> `; } registerCustomElement(Select);