@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
1,624 lines (1,457 loc) • 157 kB
JavaScript
/**
* 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,
getWindow,
} 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 { SelectStyleSheet } from "./stylesheet/select.mjs";
import {
closePositionedPopper,
isPositionedPopperOpen,
openPositionedPopper,
positionPopper,
resolveClippingBoundaryElement,
resolveParentPopperContentBoundary,
} from "./util/floating-ui.mjs";
import { Pathfinder } from "../../data/pathfinder.mjs";
import { TokenList } from "../../types/tokenlist.mjs";
import "../datatable/pagination.mjs";
export {
getDefaultSelectPopperPositionProfile,
getSelectionTemplate,
getSummaryTemplate,
popperElementSymbol,
resolveSelectPopperWidthConstraints,
resolveSelectListDimension,
resolveSelectVisibleRect,
resolveSelectViewportMetrics,
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");
const resizeObserverFrameSymbol = Symbol("resizeObserverFrame");
const layoutCycleFrameSymbol = Symbol("layoutCycleFrame");
const layoutCycleTokenSymbol = Symbol("layoutCycleToken");
const layoutCycleModeSymbol = Symbol("layoutCycleMode");
const layoutCyclePendingFlagsSymbol = Symbol("layoutCyclePendingFlags");
const layoutCyclePendingPrioritySymbol = Symbol("layoutCyclePendingPriority");
const layoutCycleRunningSymbol = Symbol("layoutCycleRunning");
const visualViewportResizeHandlerSymbol = Symbol("visualViewportResizeHandler");
const visualViewportScrollHandlerSymbol = Symbol("visualViewportScrollHandler");
const visibilityChangeHandlerSymbol = Symbol("visibilityChangeHandler");
const windowResizeHandlerSymbol = Symbol("windowResizeHandler");
const windowOrientationChangeHandlerSymbol = Symbol(
"windowOrientationChangeHandler",
);
/**
* 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");
/**
* Internal sentinel used to suppress a remote request after URL formatting.
*
* This is currently inserted only when `filter.defaultValue` resolves to
* `undefined` or `null`. An empty string is treated as a real filter value and
* therefore still produces a request.
*
* @private
* @type {symbol}
*/
const disabledRequestMarker = Symbol("@@disabledRequestMarker");
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");
const lookupCacheSymbol = Symbol("lookupCache");
/**
* @private
* @type {symbol}
*/
const lookupInProgressSymbol = Symbol("lookupInProgress");
const unresolvedSelectionValuesSymbol = Symbol("unresolvedSelectionValues");
const fetchRequestVersionSymbol = Symbol("fetchRequestVersion");
const remoteInfoRequestSymbol = Symbol("remoteInfoRequest");
const remoteInfoStableMessageSymbol = Symbol("remoteInfoStableMessage");
const SELECT_LAYOUT_PRIORITY_PASSIVE = 1;
const SELECT_LAYOUT_PRIORITY_INTERACTIVE = 2;
const SELECT_LAYOUT_PRIORITY_CRITICAL = 3;
const SELECT_LAYOUT_REASON_OPTION_STATE = 1 << 0;
const SELECT_LAYOUT_REASON_PAGINATION = 1 << 1;
const SELECT_LAYOUT_REASON_POSITION = 1 << 2;
const SELECT_LAYOUT_REASON_ALL =
SELECT_LAYOUT_REASON_OPTION_STATE |
SELECT_LAYOUT_REASON_PAGINATION |
SELECT_LAYOUT_REASON_POSITION;
/**
* @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";
const SELECT_MIN_POPPER_WIDTH = 240;
const SELECT_MAX_POPPER_HEIGHT = 500;
const SELECT_VIEWPORT_PADDING = 12;
/**
* 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 searchable select control with single or multiple values, lazy loading, remote filtering and lookup support.
* @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[unresolvedSelectionValuesSymbol] = new Set();
this[optionsMapSymbol] = new Map();
this[closeOnSelectAutoSymbol] = true;
initOptionObserver.call(this);
}
setOption(path, value) {
if (path === "features.closeOnSelect") {
this[closeOnSelectAutoSymbol] = false;
}
return super.setOption(path, value);
}
/**
* 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`.
* Use JSON in `data-monster-options` when option values must keep their JSON
* types, for example:
*
* ```html
* <monster-select
* data-monster-options='{"empty":{"equivalents":[null,"",0]}}'>
* </monster-select>
* ```
*
* When the value comes from an updater object, bind it through
* `data-monster-properties`. The bound object value keeps its JavaScript type,
* so an array such as `[null, "", 0]` keeps the numeric `0` as a number:
*
* ```html
* <monster-select
* data-monster-properties="option:empty.equivalents path:emptyEquivalents">
* </monster-select>
* ```
*
* Do not use `data-monster-option-empty-equivalents` for typed primitive
* values; array option attributes are split as strings and cannot express a
* numeric `0`.
* @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 hydrating already selected values.
* @property {string|null} lookup.url - URL template with a `${filter}` placeholder to look up selected entries. Prefer a stable value-based lookup endpoint. This is used for initial hydration and for resolving labels of externally assigned selections that are not yet present in the local option map.
* @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. Unresolved selected values keep their raw key instead of replacing it with this label.
* @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. In the current implementation only `undefined` or `null` suppress the request by inserting an internal disabled marker. An empty string `""` is still formatted into the URL and therefore does not prevent the 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 options when `filter.mode` is `"remote"` and no filter value has been entered. This is used as the empty-filter source both on open and after the filter is cleared again.
* @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`, `""`, `NaN`) and are normalized to the default value. Matching is type-safe: a configured string `"0"` only matches the string `"0"`; configure numeric `0` explicitly, via `data-monster-options` JSON, `data-monster-properties`, or `setOption()`, when the number `0` should be treated as empty.
* @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.badgeUnresolved - CSS class used for selection badges whose value could not be hydrated. By default this swaps the badge into a warning style while keeping the raw key visible.
* @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",
badgeUnresolved: "monster-badge-warning",
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();
this[unresolvedSelectionValuesSymbol].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", "");
setRemoteInfoText.call(this, "");
this.setOption("messages.summary", "");
this.setOption("total", null);
resetPaginationState.call(this);
resetErrorAttribute(this);
this[lazyLoadDoneSymbol] = false;
scheduleSelectLayoutCycle.call(
this,
SELECT_LAYOUT_PRIORITY_CRITICAL,
SELECT_LAYOUT_REASON_ALL,
);
})
.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;
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) {
if (self.getOption("lookup.url")) {
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) {
this.setOption("total", null);
resetPaginationState.call(this);
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);
resetPaginationState.call(this);
return;
}
this.setOption("total", total);
if (!mappingCurrentPage || !mappingObjectsPerPage) {
resetPaginationState.call(this, false);
return;
}
const currentPage = pathfinder.getVia(mappingCurrentPage);
const objectsPerPage = pathfinder.getVia(mappingObjectsPerPage);
if (total === 0) {
resetPaginationState.call(this, false);
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
* @param {string|null|undefined} message
* @param {object} [options]
* @param {boolean} [options.reserveSpace=false]
* @returns {void}
*/
function setRemoteInfoText(message, { reserveSpace = false } = {}) {
const normalizedMessage =
message === undefined || message === null ? "" : `${message}`;
const previousStableMessage =
typeof this[remoteInfoStableMessageSymbol] === "string"
? this[remoteInfoStableMessageSymbol]
: "";
const renderedMessage =
reserveSpace === true &&
normalizedMessage === "" &&
previousStableMessage !== ""
? previousStableMessage
: normalizedMessage;
this.setOption("messages.total", renderedMessage);
if (normalizedMessage !== "") {
this[remoteInfoStableMessageSymbol] = normalizedMessage;
} else if (reserveSpace !== true) {
this[remoteInfoStableMessageSymbol] = "";
}
if (this[remoteInfoElementSymbol] instanceof HTMLElement) {
if (reserveSpace === true && renderedMessage !== "") {
this[remoteInfoElementSymbol].style.visibility = "hidden";
} else {
this[remoteInfoElementSymbol].style.removeProperty("visibility");
}
}
}
/**
* @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 };
}
function hasSelectionLikeValue(candidate) {
if (isArray(candidate)) {
return candidate.some((entry) => {
if (
isObject(entry) &&
Object.prototype.hasOwnProperty.call(entry, "value")
) {
return hasSelectionLikeValue.call(this, entry.value);
}
return hasSelectionLikeValue.call(this, entry);
});
}
if (isObject(candidate)) {
for (const key of ["value", "id", "key"]) {
if (Object.prototype.hasOwnProperty.call(candidate, key)) {
return hasSelectionLikeValue.call(this, candidate[key]);
}
}
}
if (candidate === undefined || candidate === null) {
return false;
}
if (isValueIsEmpty.call(this, candidate)) {
return false;
}
if (isString(candidate)) {
return candidate.trim() !== "";
}
return true;
}
function hasRequestedSelectionValue() {
const pendingValue = this[pendingSelectionSymbol]?.value;
const optionValue = this.getOption("value");
const attrValue = this.getAttribute("value");
return (
hasSelectionLikeValue.call(this, pendingValue) ||
hasSelectionLikeValue.call(this, optionValue) ||
hasSelectionLikeValue.call(this, attrValue)
);
}
/**
* @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) {
const optionLabel = resolveOptionLabel(label, value);
let found = false;
for (const option of options) {
if (option.value === value) {
option.label = optionLabel;
option.visibility = visibility;
option.data = map.get(value);
found = true;
break;
}
}
if (!found) {
options.push({
value,
label: optionLabel,
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;
}
function resolveOptionLabel(label, value) {
if (!isEmptyOptionLabel(label)) {
return label;
}
if (value === undefined || value === null) {
return label;
}
const fallback = `${value}`;
return fallback.trim() === "" ? label : fallback;
}
function isEmptyOptionLabel(label) {
return (
label === undefined ||
label === null ||
(isString(label) && label.trim() === "")
);
}
/**
* @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> записей выбрано',
},