UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,594 lines (1,442 loc) 127 kB
/** * Copyright © Volker Schukai 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 Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol, internalSymbol } from "../../constants.mjs"; import { buildMap, build as buildValue } from "../../data/buildmap.mjs"; import { addAttributeToken, containsAttributeToken, 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 { resetErrorAttribute, addErrorAttribute, removeErrorAttribute, } from "../../dom/error.mjs"; import { findTargetElementFromEvent, fireCustomEvent, fireEvent, } from "../../dom/events.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { findElementWithSelectorUpwards, getDocument, } from "../../dom/util.mjs"; import { getDocumentTranslations, Translations, } from "../../i18n/translations.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 { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { Processing } from "../../util/processing.mjs"; import { STYLE_DISPLAY_MODE_BLOCK } from "./constants.mjs"; import { SelectStyleSheet } from "./stylesheet/select.mjs"; import { positionPopper } from "./util/floating-ui.mjs"; import { Pathfinder } from "../../data/pathfinder.mjs"; import { TokenList } from "../../types/tokenlist.mjs"; import "../datatable/pagination.mjs"; export { getSelectionTemplate, getSummaryTemplate, popperElementSymbol, Select, }; /** * @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"); const hostElementSymbol = Symbol("hostElement"); const dismissRecordSymbol = Symbol("dismissRecord"); const usesHostDismissSymbol = Symbol("usesHostDismiss"); /** * 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 paginationElementSymbol = Symbol("paginationElement"); /** * 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"); /** * local symbol * @type {symbol} */ const remoteInfoElementSymbol = Symbol("remoteInfoElement"); /** * @private * @type {Symbol} */ const areOptionsAvailableAndInitSymbol = Symbol("@@areOptionsAvailableAndInit"); /** * @private * @type {symbol} */ const disabledRequestMarker = Symbol("@@disabledRequestMarker"); /** * @private * @type {symbol} */ const runLookupOnceSymbol = Symbol("runLookupOnce"); /** * @private * @type {symbol} */ const cleanupOptionsListSymbol = Symbol("cleanupOptionsList"); const optionsVersionSymbol = Symbol("optionsVersion"); const pendingSelectionSymbol = Symbol("pendingSelection"); const selectionSyncScheduledSymbol = Symbol("selectionSyncScheduled"); const optionsSnapshotSymbol = Symbol("optionsSnapshot"); const strictModeSnapshotSymbol = Symbol("strictModeSnapshot"); const optionsMapSymbol = Symbol("optionsMap"); const optionsMapVersionSnapshotSymbol = Symbol("optionsMapVersionSnapshot"); const selectionVersionSymbol = Symbol("selectionVersion"); const closeOnSelectAutoSymbol = Symbol("closeOnSelectAuto"); /** * @private * @type {symbol} */ const debounceOptionsMutationObserverSymbol = Symbol( "debounceOptionsMutationObserver", ); /** * @private * @type {symbol} */ const currentPageSymbol = Symbol("currentPage"); /** * @private * @type {symbol} */ const remoteFilterFirstOpendSymbol = Symbol("remoteFilterFirstOpend"); /** * @private * @type {symbol} */ const lookupCacheSymbol = Symbol("lookupCache"); /** * @private * @type {symbol} */ const lookupInProgressSymbol = Symbol("lookupInProgress"); const fetchRequestVersionSymbol = Symbol("fetchRequestVersion"); /** * @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 o * * @issue @issue https://localhost.alvine.dev:8440/development/issues/closed/280.html * @issue @issue https://localhost.alvine.dev:8440/development/issues/closed/287.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 * @example /examples/components/form/select-remote-filter Server-side filtering with a remote URL * @example /examples/components/form/select-remote-pagination Server-side filtering with pagination * @example /examples/components/form/select-summary-template Using a summary template for selections * * @copyright Volker Schukai * @summary A beautiful select control that can make your life easier and also looks good. * @fires monster-change * @fires monster-changed * @fires monster-options-set this event is fired when the options are set * @fires monster-selection-removed * @fires monster-selection-cleared */ class Select extends CustomControl { /** * */ constructor() { super(); this[currentPageSymbol] = 1; this[lookupCacheSymbol] = new Map(); this[lookupInProgressSymbol] = new Map(); this[optionsMapSymbol] = new Map(); this[closeOnSelectAutoSymbol] = true; 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|null} value * @throws {Error} unsupported type * @fires monster-selected this event is fired when the selection is set */ set value(value) { const valValue = isValueIsEmptyThenGetNormalize.call(this, value); const result = convertValueToSelection.call(this, valValue); setSelection .call(this, result.selection) .then(() => {}) .catch((e) => { addErrorAttribute(this, e); }); } /** * Defines the default configuration options for the monster-control. * These options can be overridden via the HTML attribute `data-monster-options`. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * @property {string[]} toggleEventType - Array of DOM event names (e.g., ["click", "touch"]) that toggle the dropdown. * @property {boolean} delegatesFocus - If `true`, the element delegates focus to its internal control (e.g., the filter input). * @property {Array<Object>} options - Array of option objects `{label, value, visibility?, data?}` for a static option list. * @property {string|string[]} selection - Initial selected value(s), as a string, comma-separated string, or array of strings. * @property {number} showMaxOptions - Maximum number of visible options before the list becomes scrollable. * @property {"radio"|"checkbox"} type - Selection mode: "radio" for single selection, "checkbox" for multiple selections. * @property {string} name - Name of the hidden form field for form submission. * @property {string|null} url - URL to dynamically fetch options via HTTP when opening or filtering. * @property {number|null} total - Total number of available options, useful for pagination with remote data. * @property {Object} lookup - Configuration for fetching initially selected values. * @property {string|null} lookup.url - URL template with a `${filter}` placeholder to look up selected entries on initialization. Used when `url` is set and either `features.lazyLoad` is active or `filter.mode` is `"remote"`. * @property {boolean} lookup.grouping - If `true`, all selected values are fetched in a single request; otherwise, a separate request is sent for each value. * @property {Object} fetch - Configuration for HTTP requests via `fetch`. * @property {string} fetch.redirect - Fetch redirect mode (e.g., "error", "follow"). * @property {string} fetch.method - HTTP method for fetching options (e.g., "GET", "POST"). * @property {string} fetch.mode - Fetch mode (e.g., "cors", "same-origin"). * @property {string} fetch.credentials - Credentials policy for fetch (e.g., "include", "same-origin"). * @property {Object.<string, string>} fetch.headers - HTTP headers to be sent with every request. * @property {Object} labels - Text labels for various states and UI elements. * @property {string} labels.cannot-be-loaded - Message displayed when options cannot be loaded. * @property {string} labels.no-options-available - Message displayed when no static options are provided. * @property {string} labels.click-to-load-options - Prompt to load options when `features.lazyLoad` is enabled. * @property {string} labels.select-an-option - Placeholder text when no selection has been made. * @property {string} labels.no-options - Message displayed when no options are available from slots or fetch. * @property {string} labels.no-options-found - Message displayed when the filter yields no matching options. * @property {string} labels.summary-text.zero - Pluralization template for zero selected entries (e.g., "No entries selected"). * @property {string} labels.summary-text.one - Pluralization template for one selected entry. * @property {string} labels.summary-text.other - Pluralization template for multiple selected entries. * @property {Object} features - Toggles to enable/disable functionalities. * @property {boolean} features.clearAll - Shows a "Clear all" button to reset the selection. * @property {boolean} features.clear - Shows a "remove" icon on individual selection badges. * @property {boolean} features.lazyLoad - If `true`, options are fetched from the `url` only when the dropdown is first opened. Ignored if `filter.mode` is `"remote"`. * @property {boolean} features.closeOnSelect - If `true`, the dropdown closes automatically after a selection is made. * @property {boolean} features.emptyValueIfNoOptions - Sets the hidden field's value to empty if no options are available. * @property {boolean} features.storeFetchedData - If `true`, the raw fetched data is stored and can be retrieved via `getLastFetchedData()`. * @property {boolean} features.useStrictValueComparison - Uses strict comparison (`===`) when matching option values. * @property {boolean} features.showRemoteInfo - When `filter.mode === "remote"`, displays an info badge indicating that more options may exist on the server. * @property {Object} remoteInfo - Configuration for the remote info display. * @property {string|null} remoteInfo.url - URL to fetch the total count of remote options (used when `filter.mode === "remote"`). * @property {Object} placeholder - Placeholder texts for input fields. * @property {string} placeholder.filter - Placeholder text for the filter input field. * @property {Object} filter - Configuration for the filtering functionality. * @property {string|null} filter.defaultValue - Default filter value for remote requests. An empty string will prevent the initial request. * @property {"options"|"remote"|"disabled"} filter.mode - Filter mode: `"options"` (client-side), `"remote"` (server-side, `lazyLoad` is ignored), or `"disabled"`. * @property {"inline"|"popper"} filter.position - Position of the filter input: `"inline"` (inside the control) or `"popper"` (inside the dropdown). * @property {string|null} filter.defaultOptionsUrl - URL to load an initial list of options when `filter.mode` is `"remote"` and no filter value has been entered. * @property {Object} filter.marker - Markers for embedding the filter value into the `url` for server-side filtering. * @property {string} filter.marker.open - Opening marker (e.g., `{`). * @property {string} filter.marker.close - Closing marker (e.g., `}`). * @property {Object} templates - HTML templates for the main components. * @property {string} templates.main - HTML template string for the control's basic structure. * @property {Object} templateMapping - Mapping of templates for specific use cases. * @property {string} templateMapping.selected - Template variant for selected items (e.g., for rendering as a badge). * @property {Object} popper - Configuration for Popper.js to position the dropdown. * @property {string} popper.placement - Popper.js placement strategy (e.g., "bottom-start"). * @property {Array<string|Object>} popper.middleware - Array of middleware configurations for Popper.js (e.g., ["flip", "offset:1"]). * @property {Object} mapping - Defines how fetched data is transformed into `options`. * @property {string} mapping.selector - Path/selector to find the array of items within the fetched data (e.g., "results" for `data.results`). * @property {string} mapping.labelTemplate - Template for the option label using placeholders (e.g., `Label: ${name}`). * @property {string} mapping.valueTemplate - Template for the option value using placeholders (e.g., `ID: ${id}`). * @property {Function|null} mapping.filter - Optional callback function `(item) => boolean` to filter fetched items before processing. * @property {string|null} mapping.sort - Optional sorting strategy for fetched items. Can be a string like `"asc"`/`"desc"` for label sorting, or a custom function defined as `"run:<code>"` or `"call:<functionName>"`. * @property {string|null} mapping.total - Path/selector to the total number of items for pagination (e.g., "pagination.total"). * @property {string|null} mapping.currentPage - Path/selector to the current page number. * @property {string|null} mapping.objectsPerPage - Path/selector to the number of objects per page. * @property {Object} empty - Handling of empty or undefined values. * @property {string} empty.defaultValueRadio - Default value for `type="radio"` when no selection exists. * @property {Array} empty.defaultValueCheckbox - Default value (empty array) for `type="checkbox"`. * @property {Array} empty.equivalents - Values that are considered "empty" (e.g., `undefined`, `null`, `""`) and are normalized to the default value. * @property {Object} formatter - Functions for formatting display values. * @property {Function} formatter.selection - Callback `(value, option) => string` to format the display text of selected values. * @property {Object} classes - CSS classes for various elements. * @property {string} classes.badge - CSS class for selection badges. * @property {string} classes.statusOrRemoveBadge - CSS class for the status or remove badge. * @property {string} classes.remoteInfo - CSS class for the remote info badge. * @property {string} classes.noOptions - CSS class for the "no options" message. * @property {Object} messages - Internal messages for ARIA attributes and screen readers (should not normally be changed). * @property {string|null} messages.control - Message for the main control element. * @property {string|null} messages.selected - Message for the selected items area. * @property {string|null} messages.emptyOptions - Message for an empty options list. * @property {string|null} messages.total - Message that communicates the total number of options. */ get defaults() { return Object.assign( {}, super.defaults, { toggleEventType: ["click", "touch"], delegatesFocus: false, options: [], selection: [], showMaxOptions: 40, type: "radio", name: new ID("s").toString(), features: { clearAll: true, clear: true, lazyLoad: false, closeOnSelect: false, emptyValueIfNoOptions: false, storeFetchedData: false, useStrictValueComparison: false, showRemoteInfo: true, }, placeholder: { filter: "", }, url: null, remoteInfo: { url: null, }, lookup: { url: null, grouping: false, }, labels: getTranslations(), messages: { control: null, selected: null, emptyOptions: null, total: 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: "}", }, params: {}, paramsDefaults: {}, defaultOptionsUrl: null, }, classes: { badge: "monster-badge-primary", statusOrRemoveBadge: "empty", remoteInfo: "monster-margin-start-4 monster-margin-top-4", noOptions: "monster-margin-top-4 monster-margin-start-4", }, mapping: { selector: "*", labelTemplate: "", valueTemplate: "", filter: null, total: null, currentPage: null, objectsPerPage: null, sort: null, }, empty: { defaultValueRadio: "", defaultValueCheckbox: [], equivalents: [undefined, null, "", NaN], }, 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(), }, total: null, popper: { placement: "bottom", middleware: ["flip", "offset:1"], }, }, initOptionsFromArguments.call(this), ); } /** * Resets the select control: clears selection, resets options, removes status/errors. */ reset() { try { hide.call(this); // Clear the lookup cache this[lookupCacheSymbol].clear(); this[lookupInProgressSymbol].clear(); setSelection .call(this, null) .then(() => { const lazyLoadFlag = this.getOption("features.lazyLoad"); const remoteFilterFlag = getFilterMode.call(this) === FILTER_MODE_REMOTE; if (lazyLoadFlag || remoteFilterFlag) { this.setOption("options", []); } this.setOption("messages.selected", ""); this.setOption("messages.total", ""); this.setOption("messages.summary", ""); this.setOption("total", null); resetPaginationState.call(this); resetErrorAttribute(this); this[lazyLoadDoneSymbol] = false; this[runLookupOnceSymbol] = false; checkOptionState.call(this); calcAndSetOptionsDimension.call(this); updatePopper.call(this); initTotal.call(this); }) .catch((e) => { addErrorAttribute(this, e); }); } catch (e) { addErrorAttribute(this, e); } } /** * @return {Select} */ [assembleMethodSymbol]() { const self = this; super[assembleMethodSymbol](); initControlReferences.call(self); initEventHandler.call(self); let lazyLoadFlag = self.getOption("features.lazyLoad", false); const remoteFilterFlag = getFilterMode.call(this) === FILTER_MODE_REMOTE; initTotal.call(self); if (getFilterMode.call(this) === FILTER_MODE_REMOTE) { self.setOption("features.lazyLoad", false); lazyLoadFlag = false; } if (self.hasAttribute("value")) { new Processing(10, () => { const oldValue = self.value; const newValue = self.getAttribute("value"); if (oldValue !== newValue) { self.value = newValue; } }) .run() .catch((e) => { addErrorAttribute(this, e); }); } if (self.getOption("url") !== null) { if (lazyLoadFlag || remoteFilterFlag) { lookupSelection.call(self); } else { self .fetch() .then(() => {}) .catch((e) => { addErrorAttribute(self, e); }); } } setTimeout(() => { let lastValue = self.value; self[internalSymbol].attachObserver( new Observer(function () { // this is here not the Control, but the ProxyObserver if (isObject(this) && this instanceof ProxyObserver) { const n = this.getSubject()?.options?.value; if (lastValue !== n && n !== undefined) { lastValue = n; setSelection .call(self, n) .then(() => {}) .catch((e) => { addErrorAttribute(self, e); }); } } }), ); areOptionsAvailableAndInit.call(self); }, 0); 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) { try { const result = fetchIt.call(this, url); if (result instanceof Promise) { return result; } } catch (e) { addErrorAttribute(this, e); return Promise.reject(e); } } /** * @return {void} */ connectedCallback() { super.connectedCallback(); this[hostElementSymbol] = findElementWithSelectorUpwards( this, "monster-host", ); this[usesHostDismissSymbol] = this[hostElementSymbol] && typeof this[hostElementSymbol].registerDismissable === "function"; if (!this[usesHostDismissSymbol]) { 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); }); if (this.parentElement?.nodeName === "MONSTER-BUTTON-BAR") { this.shadowRoot .querySelector("[data-monster-role=control]") ?.classList.add("in-button-bar"); } } /** * @return {void} */ disconnectedCallback() { super.disconnectedCallback(); if (!this[usesHostDismissSymbol]) { const document = getDocument(); // close on outside ui-events for (const [, type] of Object.entries(["click", "touch"])) { document.removeEventListener(type, this[closeEventHandler]); } } unregisterFromHost.call(this); 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) { this[cleanupOptionsListSymbol] = true; return importOptionsIntern.call(this, data); } /** * @private * @return {Select} */ calcAndSetOptionsDimension() { calcAndSetOptionsDimension.call(this); return this; } /** * * @return {string} */ static getTag() { return "monster-select"; } /** * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [SelectStyleSheet]; } } /** * @private * @param {object} data Die rohen Daten aus der API-Antwort. */ function processAndApplyPaginationData(data) { if (!this[paginationElementSymbol]) { return; } let dataCount; const mappingSelector = this.getOption("mapping.selector"); if (isString(mappingSelector)) { try { const pathfinder = new Pathfinder(data); const mapped = pathfinder.getVia(mappingSelector); if (isArray(mapped)) { dataCount = mapped.length; } else if (mapped instanceof Map) { dataCount = mapped.size; } else if (isObject(mapped)) { dataCount = Object.keys(mapped).length; } } catch (e) {} } const mappingTotal = this.getOption("mapping.total"); const mappingCurrentPage = this.getOption("mapping.currentPage"); const mappingObjectsPerPage = this.getOption("mapping.objectsPerPage"); if (!mappingTotal || !mappingCurrentPage || !mappingObjectsPerPage) { this.setOption("total", null); resetPaginationState.call(this); return; } try { const pathfinder = new Pathfinder(data); const total = pathfinder.getVia(mappingTotal); const currentPage = pathfinder.getVia(mappingCurrentPage); const objectsPerPage = pathfinder.getVia(mappingObjectsPerPage); if (!isInteger(total)) { addErrorAttribute(this, "total is not an integer"); this.setOption("total", null); resetPaginationState.call(this); return; } this.setOption("total", total); if (total === 0) { resetPaginationState.call(this); return; } if ( isInteger(currentPage) && currentPage > 0 && isInteger(objectsPerPage) && objectsPerPage > 0 ) { if ( isInteger(dataCount) && (dataCount === 0 || dataCount > objectsPerPage || total < dataCount || currentPage > Math.ceil(total / objectsPerPage)) ) { addErrorAttribute(this, "Invalid pagination data."); this.setOption("total", null); resetPaginationState.call(this); return; } updatePagination.call(this, total, currentPage, objectsPerPage); } } catch (e) { addErrorAttribute(this, e); this.setOption("total", null); resetPaginationState.call(this); } } /** * @private * @param {object} data Die rohen Daten aus der API-Antwort. */ function processAndApplyRemoteInfoTotal(data) { const mappingTotal = this.getOption("mapping.total"); if (!isString(mappingTotal)) { return; } try { const pathfinder = new Pathfinder(data); const total = pathfinder.getVia(mappingTotal); if (!isInteger(total)) { addErrorAttribute(this, "total is not an integer"); this.setOption("total", null); return; } this.setOption("total", total); // Note: remoteInfo is a lightweight request (count=1). Only update total/message here. setTotalText.call(this); } catch (e) { addErrorAttribute(this, e); this.setOption("total", null); } } /** * @private * @returns {number} */ function bumpOptionsVersion() { if (!isInteger(this[optionsVersionSymbol])) { this[optionsVersionSymbol] = 0; } this[optionsVersionSymbol] += 1; return this[optionsVersionSymbol]; } /** * @private * @returns {*} */ function getSelectionSyncState() { const attrValue = this.getAttribute("value"); const selection = this.getOption("selection"); const options = this.getOption("options"); const optionsLength = Array.isArray(options) ? options.length : 0; return { attrValue, selection, optionsLength }; } /** * @private * @param {number} version */ function scheduleSelectionSync(version) { const state = getSelectionSyncState.call(this); const selectionIsEmpty = Array.isArray(state.selection) && state.selection.length === 0; if (state.attrValue === null && selectionIsEmpty) { return; } // Prefer the existing selection when it is already set to avoid // resetting user changes back to the initial attribute value. const shouldUseAttrValue = selectionIsEmpty && state.attrValue !== null; const pending = { version, selectionVersion: this[selectionVersionSymbol] || 0, value: shouldUseAttrValue ? state.attrValue : state.selection, }; this[pendingSelectionSymbol] = pending; if (this[selectionSyncScheduledSymbol] === true) { return; } this[selectionSyncScheduledSymbol] = true; queueMicrotask(() => { this[selectionSyncScheduledSymbol] = false; const current = this[pendingSelectionSymbol]; if (!current) { return; } if (current.version !== this[optionsVersionSymbol]) { return; } if (current.selectionVersion !== (this[selectionVersionSymbol] || 0)) { return; } setSelection .call(this, current.value) .then(() => {}) .catch((e) => { addErrorAttribute(this, e); }); }); } /** * @private * @param data * @returns {any} */ function importOptionsIntern(data) { const self = this; const mappingOptions = this.getOption("mapping", {}); const selector = mappingOptions?.["selector"]; const labelTemplate = mappingOptions?.["labelTemplate"]; const valueTemplate = mappingOptions?.["valueTemplate"]; let filter = mappingOptions?.["filter"]; let sort = mappingOptions?.["sort"]; 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"); } if (isString(filter)) { if (0 === filter.indexOf("run:")) { const code = filter.replace("run:", ""); filter = (m, v, k) => { const fkt = new Function("m", "v", "k", "control", code); return fkt(m, v, k, self); }; } else if (0 === filter.indexOf("call:")) { const parts = filter.split(":"); parts.shift(); // remove prefix const fkt = parts.shift(); switch (fkt) { case "filterValueOfAttribute": const attribute = parts.shift(); const attrValue = self.getAttribute(attribute); filter = (m, v, k) => { const mm = buildValue(m, valueTemplate); return mm != attrValue; // no type check, no !== }; break; default: addErrorAttribute(this, new Error(`Unknown filter function ${fkt}`)); } } } const map = buildMap(data, selector, labelTemplate, valueTemplate, filter); let options = []; const currentOptions = this.getOption("options"); if (this[cleanupOptionsListSymbol] !== true) { options = currentOptions ?? []; } this[cleanupOptionsListSymbol] = false; if (!isIterable(map)) { throw new Error("map is not iterable"); } const visibility = "visible"; let entries = Array.from(map.entries()); if ( sort === "false" || sort === false || sort === null || sort === "null" || sort === "" || sort === "none" || sort === "no" ) { // no sorting } else if (isString(sort)) { if (sort.startsWith("run:")) { const code = sort.replace("run:", ""); const sortFn = new Function("a", "b", "control", code); entries.sort((a, b) => sortFn(a, b, self)); } else if (sort.startsWith("call:")) { const parts = sort.split(":"); parts.shift(); const fkt = parts.shift(); switch (fkt) { case "asc": entries.sort((a, b) => a[1].localeCompare(b[1])); break; case "desc": entries.sort((a, b) => b[1].localeCompare(a[1])); break; default: addErrorAttribute(this, new Error(`Unknown sort function ${fkt}`)); } } } else { entries.sort((a, b) => { const la = a[1]?.toString() ?? ""; const lb = b[1]?.toString() ?? ""; return la.localeCompare(lb); }); } for (const [value, label] of entries) { let found = false; for (const option of options) { if (option.value === value) { option.label = label; option.visibility = visibility; option.data = map.get(value); found = true; break; } } if (!found) { options.push({ value, label, visibility, data: map.get(value), }); } } this.setOption("options", options); fireCustomEvent(this, "monster-options-set", { options, }); if (options === currentOptions) { const version = bumpOptionsVersion.call(this); scheduleSelectionSync.call(this, version); } return this; } /** * @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 Auswahl verfügbar.", "click-to-load-options": "Klicken, um Auswahl zu laden.", "select-an-option": "Bitte Auswahl treffen", "summary-text": { zero: "Keine Auswahl getroffen", one: '<span class="monster-badge-primary-pill">1</span> Auswahl getroffen', other: '<span class="monster-badge-primary-pill">${count}</span> Auswahlen getroffen', }, "no-options": '<span class="monster-badge-error-pill">Leider gibt es keine Auswahlmöglichkeiten in der Liste.</span>', "no-options-found": '<span class="monster-badge-error-pill">Keine Auswahlmöglichkeiten verfügbar. Bitte ändern Sie den Filter.</span>', total: { zero: '<span class="monster-badge-primary-pill">Es sind keine weiteren Auswahlmöglichkeiten verfügbar.</span>', one: '<span class="monster-badge-primary-pill">Es ist 1 weitere Auswahlmöglichkeit verfügbar.</span>', other: '<span class="monster-badge-primary-pill">Es sind ${count} weitere Auswahlmöglichkeiten verfügbar.</span>', }, }; case "es": 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": '<span class="monster-badge-error-pill">Desafortunadamente, no hay opciones disponibles en la lista.</span>', "no-options-found": '<span class="monster-badge-error-pill">No hay opciones disponibles en la lista. Por favor, modifique el filtro.</span>', total: { zero: '<span class="monster-badge-primary-pill">No hay entradas adicionales disponibles.</span>', one: '<span class="monster-badge-primary-pill">1 entrada adicional está disponible.</span>', other: '<span class="monster-badge-primary-pill">${count} entradas adicionales están disponibles.</span>', }, }; case "zh": return { "cannot-be-loaded": "无法加载", "no-options-available": "没有可用选项。", "click-to-load-options": "点击以加载选项。", "select-an-option": "选择一个选项", "summary-text": { zero: "未选择任何条目", one: '<span class="monster-badge-primary-pill">1</span> 个条目已选择', other: '<span class="monster-badge-primary-pill">${count}</span> 个条目已选择', }, "no-options": '<span class="monster-badge-error-pill">很抱歉,列表中没有可用选项。</span>', "no-options-found": '<span class="monster-badge-error-pill">列表中没有可用选项。请修改筛选条件。</span>', total: { zero: '<span class="monster-badge-primary-pill">没有更多条目可用。</span>', one: '<span class="monster-badge-primary-pill">还有 1 个可用条目。</span>', other: '<span class="monster-badge-primary-pill">还有 ${count} 个可用条目。</span>', }, }; case "hi": return { "cannot-be-loaded": "लोड नहीं किया जा सकता", "no-options-available": "कोई विकल्प उपलब्ध नहीं है।", "click-to-load-options": "विकल्प लोड करने के लिए क्लिक करें।", "select-an-option": "एक विकल्प चुनें", "summary-text": { zero: "कोई प्रविष्टि चयनित नहीं", one: '<span class="monster-badge-primary-pill">1</span> प्रविष्टि चयनित', other: '<span class="monster-badge-primary-pill">${count}</span> प्रविष्टियाँ चयनित', }, "no-options": '<span class="monster-badge-error-pill">क्षमा करें, सूची में कोई विकल्प उपलब्ध नहीं है।</span>', "no-options-found": '<span class="monster-badge-error-pill">सूची में कोई विकल्प उपलब्ध नहीं है। कृपया फ़िल्टर बदलें।</span>', total: { zero: '<span class="monster-badge-primary-pill">कोई अतिरिक्त प्रविष्टि उपलब्ध नहीं है।</span>', one: '<span class="monster-badge-primary-pill">1 अतिरिक्त प्रविष्टि उपलब्ध है।</span>', other: '<span class="monster-badge-primary-pill">${count} अतिरिक्त प्रविष्टियाँ उपलब्ध हैं।</span>', }, }; case "bn": return { "cannot-be-loaded": "লোড করা যায়নি", "no-options-available": "কোন বিকল্প উপলব্ধ নেই।", "click-to-load-options": "বিকল্প লোড করতে ক্লিক করুন।", "select-an-option": "একটি বিকল্প নির্বাচন করুন", "summary-text": { zero: "কোন এন্ট্রি নির্বাচিত হয়নি", one: '<span class="monster-badge-primary-pill">1</span> এন্ট্রি নির্বাচিত', other: '<span class="monster-badge-primary-pill">${count}</span> এন্ট্রি নির্বাচিত', }, "no-options": '<span class="monster-badge-error-pill">দুঃখিত, তালিকায় কোন বিকল্প পাওয়া যায়নি।</span>', "no-options-found": '<span class="monster-badge-error-pill">তালিকায় কোন বিকল্প পাওয়া যায়নি। দয়া করে ফিল্টার পরিবর্তন করুন।</span>', total: { zero: '<span class="monster-badge-primary-pill">আর কোনো এন্ট্রি উপলব্ধ নেই।</span>', one: '<span class="monster-badge-primary-pill">1 অতিরিক্ত এন্ট্রি উপলব্ধ।</span>', other: '<span class="monster-badge-primary-pill">${count} অতিরিক্ত এন্ট্রি উপলব্ধ।</span>', }, }; case "pt": return { "cannot-be-loaded": "Não é possível carregar", "no-options-available": "Nenhuma opção disponível.", "click-to-load-options": "Clique para carregar opções.", "select-an-option": "Selecione uma opção", "summary-text": { zero: "Nenhuma entrada selecionada", one: '<span class="monster-badge-primary-pill">1</span> entrada selecionada', other: '<span class="monster-badge-primary-pill">${count}</span> entradas selecionadas', }, "no-options": '<span class="monster-badge-error-pill">Infelizmente, não há opções disponíveis na lista.</span>', "no-options-found": '<span class="monster-badge-error-pill">Nenhuma opção disponível na lista. Considere modificar o filtro.</span>', total: { zero: '<span class="monster-badge-primary-pill">Não há entradas adicionais disponíveis.</span>', one: '<span class="monster-badge-primary-pill">1 entrada adicional está disponível.</span>', other: '<span class="monster-badge-primary-pill">${count} entradas adicionais estão disponíveis.</span>', }, }; case "ru": return { "cannot-be-loaded": "Не удалось загрузить", "no-options-available": "Нет доступных вариантов.", "click-to-load-options": "Нажмите, чтобы загрузить варианты.", "select-an-option": "Выберите вариант", "summary-text": { zero: "Нет выбранных записей", one: '<span class="monster-badge-primary-pill">1</span> запись выбрана', other: '<span class="monster-badge-primary-pill">${count}</span> записей выбрано', }, "no-options": '<span class="monster-badge-error-pill">К сожалению, в списке нет доступных вариантов.</span>', "no-options-found": '<span class="monster-badge-error-pill">В списке нет доступных вариантов. Пожалуйста, измените фильтр.</span>', total: { zero: '<span class="monster-badge-primary-pill">Дополнительных записей нет.</span>', one: '<span class="monster-badge-primary-pill">Доступна 1 дополнительная запись.</span>', other: '<span class="monster-badge-primary-pill">${count} дополнительных записей доступны.</span>', }, }; case "ja": return { "cannot-be-loaded": "読み込めません", "no-options-available": "利用可能なオプションがありません。", "click-to-load-options": "クリックしてオプションを読み込む。", "select-an-option": "オプションを選択", "summary-text": { zero: "選択された項目はありません", one: '<span class="monster-badge-primary-pill">1</span> 件選択されました', other: '<span class="monster-badge-primary-pill">${count}</span> 件選択されました', }, "no-options": '<span class="monster-badge-error-pill">申し訳ありませんが、リストに利用可能なオプションがありません。</span>', "no-options-found": '<span class="monster-badge-error-pill">リストに利用可能なオプションがありません。フィルターを変更してください。</span>', total: { zero: '<span class="monster-badge-primary-pill">追加の項目はありません。</span>', one: '<span class="monster-badge-primary-pill">1 件の追加項目があります。</span>', other: '<span class="monster-badge-primary-pill">${count} 件の追加項目があります。</span>', }, }; case "pa": return { "cannot-be-loaded": "ਲੋਡ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ", "no-options-available": "ਕੋਈ ਚੋਣ ਉਪਲਬਧ ਨਹੀਂ।", "click-to-load-options": "ਚੋਣਾਂ ਲੋਡ ਕਰਨ ਲਈ ਕਲਿੱਕ ਕਰੋ।", "select-an-option": "ਇੱਕ ਚੋਣ ਚੁਣੋ", "summary-text": { zero: "ਕੋਈ ਐਂਟਰੀ ਚੁਣੀ ਨਹੀਂ ਗਈ", one: '<span class="monster-badge-primary-pill">1</span> ਐਂਟਰੀ ਚੁਣੀ ਗਈ', other: '<span class="monster-badge-primary-pill">${count}</span> ਐਂਟਰੀਆਂ ਚੁਣੀਆਂ ਗਈਆਂ', }, "no-options": '<span class="monster-badge-error-pill">ਮਾਫ ਕਰਨਾ, ਸੂਚੀ ਵਿੱਚ ਕੋਈ ਚੋਣ ਉਪਲਬਧ ਨਹੀਂ ਹੈ।</span>', "no-options-found": '<span class="monster-badge-error-pill">ਸੂਚੀ ਵਿੱਚ ਕੋਈ ਚੋਣ ਉਪਲਬਧ ਨਹੀਂ ਹੈ। ਕਿਰਪਾ ਕਰਕੇ ਫਿਲਟਰ ਬਦਲੋ।</span>', total: { zero: '<span class="monster-badge-primary-pill">ਕੋਈ ਹੋਰ ਐਂਟਰੀ ਉਪਲਬਧ ਨਹੀਂ ਹੈ.</span>', one: '<span class="monster-badge-primary-pill">1 ਵਾਧੂ ਐਂਟਰੀ ਉਪਲਬਧ ਹੈ.</span>', other: '<span class="monster-badge-primary-pill">${count} ਵਾਧੂ ਐਂਟਰੀਆਂ ਉਪਲਬਧ ਹਨ.</span>', }, }; case "mr": return { "cannot-be-loaded": "लोड केले जाऊ शकत नाही", "no-options-available": "कोणतीही पर्याय उपलब्ध नाहीत。", "click-to-load-options": "पर्याय लोड करण्यासाठी क्लिक करा。", "select-an-option": "एक पर्याय निवडा", "summary-text": { zero: "कोणीही नोंद निवडलेली नाही", one: '<span class="monster-badge-primary-pill">1</span> नोंद निवडली', other: '<span class="monster-badge-primary-pill">${count}</span> नोंदी निवडल्या', }, "no-options": '<span class="monster-badge-error-pill">क्षमस्व, यादीमध्ये कोणतीही पर्याय उपलब्ध नाहीत。</span>', "no-options-found": '<span class="monster-badge-error-pill">यादीमध्ये कोणतेही पर्याय उपलब्ध नाहीत। कृपया फिल्टर बदला。</span>', total: { zero: '<span class="monster-badge-primary-pill">आणखी कोणतीही नोंद उपलब्ध नाही。</span>', one: '<span class="monster-badge-primary-pill">1 अतिरिक्त नोंद उपलब्ध आहे。</span>', other: '<span class="monster-badge-primary-pill">${count} अतिरिक्त नोंदी उपलब्ध आहेत。</span>', }, }; 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": '<span class="monster-badge-error-pill">Purtroppo, non ci sono opzioni disponibili nella lista。</span>', "no-options-found": '<span class="monster-badge-error-pill">Nessuna opzione disponibile nella lista。Si prega di modificare il filtro。</span>', total: { zero: '<span class="monster-badge-primary-pill">Non ci sono altre voci disponibili。</span>', one: '<span class="monster-badge-primary-pill">C\'è 1 voce aggiuntiva disponibile。</span>', other: '<span class="monster-badge-primary-pill">Ci sono ${count} voci aggiuntive disponibili。</span>', }, }; case "nl": return { "cannot-be-loaded": "Kan niet worden geladen", "no-options-available": "Geen opties beschikbaar。", "click-to-load-options": "Klik om opties te laden。", "select-an-option": "Selecteer een optie", "summary-text": { zero: "Er zijn geen items geselecteerd", one: '<span class="monster-badge-primary-pill">1</span> item geselecteerd', other: '<span class="monster-badge-primary-pill">${count}</span> items geselecteerd', }, "no-options": '<span class="monster-badge-error-pill">Helaas zijn er geen opties beschikbaar in de lijst。</span>', "no-options-found": '<span class="monster-badge-error-pill">Geen opties beschikbaar in de lijst。Overweeg het filter aan te passen。</span>', total: { zero: '<span class="monster-badge-primary-pill">Er zijn geen extra items beschikbaar。</span>', one: '<span class="monster-badge-primary-pill">1 extra item is beschikbaar。</span>', other: '<span class="monster-badge-primary-pill">${count} extra items zijn beschikbaar。</span>', }, }; case "sv": 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": '<span class="monster-badge-error-pill">Tyvärr finns det inga alternativ tillgängliga i listan。</span>', "no-options-found": '<span class="monster-badge-error-pill">Inga alternativ finns tillgängliga i listan。Överväg att modifiera filtret。</span>', total: { zero: '<span class="monster-badge-primary-pill">Det finns inga f