Something went wrong on our end
Select Git revision
-
Volker Schukai authoredVolker Schukai authored
select.mjs 62.99 KiB
/**
* Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved.
* Node module: @schukai/monster
*
* This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
* The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
*
* For those who do not wish to adhere to the AGPLv3, a commercial license is available.
* Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
* For more information about purchasing a commercial license, please contact schukai GmbH.
*
* SPDX-License-Identifier: AGPL-3.0
*/
import { instanceSymbol } from "../../constants.mjs";
import { internalSymbol } from "../../constants.mjs";
import { buildMap } from "../../data/buildmap.mjs";
import { DeadMansSwitch } from "../../util/deadmansswitch.mjs";
import { positionPopper } from "./util/floating-ui.mjs";
import {
addAttributeToken,
findClosestByAttribute,
removeAttributeToken,
} from "../../dom/attributes.mjs";
import {
ATTRIBUTE_ERRORMESSAGE,
ATTRIBUTE_PREFIX,
ATTRIBUTE_ROLE,
} from "../../dom/constants.mjs";
import { CustomControl } from "../../dom/customcontrol.mjs";
import {
assembleMethodSymbol,
getSlottedElements,
registerCustomElement,
} from "../../dom/customelement.mjs";
import {
findTargetElementFromEvent,
fireCustomEvent,
fireEvent,
} from "../../dom/events.mjs";
import { getDocument } from "../../dom/util.mjs";
import { Formatter } from "../../text/formatter.mjs";
import { getGlobal } from "../../types/global.mjs";
import { ID } from "../../types/id.mjs";
import {
isArray,
isFunction,
isInteger,
isIterable,
isObject,
isPrimitive,
isString,
} from "../../types/is.mjs";
import { Observer } from "../../types/observer.mjs";
import { ProxyObserver } from "../../types/proxyobserver.mjs";
import { validateArray, validateString } from "../../types/validate.mjs";
import { Processing } from "../../util/processing.mjs";
import { STYLE_DISPLAY_MODE_BLOCK } from "./constants.mjs";
import { SelectStyleSheet } from "./stylesheet/select.mjs";
import {
getDocumentTranslations,
Translations,
} from "../../i18n/translations.mjs";
export {
Select,
popperElementSymbol,
getSummaryTemplate,
getSelectionTemplate,
};
/**
* @private
* @type {string}
*/
const noOptionsAvailableMessage = "No options available.";
/**
* @private
* @type {string}
*/
const clickToLoadOptionsMessage = "Click to load options.";
/**
* @private
* @type {Symbol}
*/
const timerCallbackSymbol = Symbol("timerCallback");
/**
* @private
* @type {Symbol}
*/
const keyFilterEventSymbol = Symbol("keyFilterEvent");
/**
* @private
* @type {Symbol}
*/
const lazyLoadDoneSymbol = Symbol("lazyLoadDone");
/**
* @private
* @type {Symbol}
*/
const isLoadingSymbol = Symbol("isLoading");
/**
* local symbol
* @private
* @type {Symbol}
*/
const closeEventHandler = Symbol("closeEventHandler");
/**
* local symbol
* @private
* @type {Symbol}
*/
const clearOptionEventHandler = Symbol("clearOptionEventHandler");
/**
* local symbol
* @private
* @type {Symbol}
*/
const resizeObserverSymbol = Symbol("resizeObserver");
/**
* local symbol
* @private
* @type {Symbol}
*/
const keyEventHandler = Symbol("keyEventHandler");
/**
* local symbol
* @private
* @type {Symbol}
*/
const lastFetchedDataSymbol = Symbol("lastFetchedData");
/**
* local symbol
* @private
* @type {Symbol}
*/
const inputEventHandler = Symbol("inputEventHandler");
/**
* local symbol
* @private
* @type {Symbol}
*/
const changeEventHandler = Symbol("changeEventHandler");
/**
* local symbol
* @private
* @type {Symbol}
*/
const controlElementSymbol = Symbol("controlElement");
/**
* local symbol
* @private
* @type {Symbol}
*/
const selectionElementSymbol = Symbol("selectionElement");
/**
* local symbol
* @private
* @type {Symbol}
*/
const containerElementSymbol = Symbol("containerElement");
/**
* local symbol
* @private
* @type {Symbol}
*/
const popperElementSymbol = Symbol("popperElement");
/**
* local symbol
* @private
* @type {Symbol}
*/
const inlineFilterElementSymbol = Symbol("inlineFilterElement");
/**
* local symbol
* @private
* @type {Symbol}
*/
const popperFilterElementSymbol = Symbol("popperFilterElement");
/**
* local symbol
* @private
* @type {Symbol}
*/
const popperFilterContainerElementSymbol = Symbol(
"popperFilterContainerElement",
);
/**
* local symbol
* @private
* @type {Symbol}
*/
const optionsElementSymbol = Symbol("optionsElement");
/**
* local symbol
* @private
* @type {Symbol}
*/
const noOptionsAvailableElementSymbol = Symbol("noOptionsAvailableElement");
/**
* local symbol
* @private
* @type {Symbol}
*/
const statusOrRemoveBadgesElementSymbol = Symbol("statusOrRemoveBadgesElement");
/**
* @private
* @type {Symbol}
*/
const areOptionsAvailableAndInitSymbol = Symbol("@@areOptionsAvailableAndInit");
/**
* @private
* @type {symbol}
*/
const disabledRequestMarker = Symbol("@@disabledRequestMarker");
/**
* @private
* @type {number}
*/
const FOCUS_DIRECTION_UP = 1;
/**
* @private
* @type {number}
*/
const FOCUS_DIRECTION_DOWN = 2;
/**
* @private
* @type {string}
*/
const FILTER_MODE_REMOTE = "remote";
/**
* @private
* @type {string}
*/
const FILTER_MODE_OPTIONS = "options";
/**
* @private
* @type {string}
*/
const FILTER_MODE_DISABLED = "disabled";
/**
* @private
* @type {string}
*/
const FILTER_POSITION_POPPER = "popper";
/**
* @private
* @type {string}
*/
const FILTER_POSITION_INLINE = "inline";
/**
* A select control that can be used to select one or more options from a list.
*
* @fragments /fragments/components/form/select/
*
* @example /examples/components/form/select-with-options Select with options
* @example /examples/components/form/select-multiple Multiple selection
* @example /examples/components/form/select-filter Filter
* @example /examples/components/form/select-fetch Fetch options
* @example /examples/components/form/select-lazy Lazy load
* @example /examples/components/form/select-remote-filter Remote filter
*
* @copyright schukai GmbH
* @summary A beautiful select control that can make your life easier and also looks good.
* @fires monster-change
* @fires monster-changed
*/
class Select extends CustomControl {
/**
*
*/
constructor() {
super();
initOptionObserver.call(this);
}
/**
* This method is called by the `instanceof` operator.
* @return {Symbol}
*/
static get [instanceSymbol]() {
return Symbol.for("@schukai/monster/components/form/select@@instance");
}
/**
* The current selection of the Select
*
* ```
* e = document.querySelector('monster-select');
* console.log(e.value)
* // ↦ 1
* // ↦ ['1','2']
* ```
*
* @return {string}
*/
get value() {
return convertSelectionToValue.call(this, this.getOption("selection"));
}
/**
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/attachInternals}
* @return {boolean}
*/
static get formAssociated() {
return true;
}
/**
* Set selection
*
* ```
* e = document.querySelector('monster-select');
* e.value=1
* ```
*
* @property {string|array} value
* @throws {Error} unsupported type
* @fires monster-selected this event is fired when the selection is set
*/
set value(value) {
const result = convertValueToSelection.call(this, value);
setSelection
.call(this, result.selection)
.then(() => {})
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
}
/**
* To set the options via the HTML tag, the attribute `data-monster-options` must be used.
* @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
*
* The individual configuration values can be found in the table.
*
* @property {Object} toggleEventType=click,touch List of event types to be observed for opening the dropdown
* @property {boolean} delegatesFocus=false lorem [see mozilla.org](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/delegatesFocus)
* @property {Object[]} options Selection of key identifier pairs available for selection and displayed in the dropdown.
* @property {string} options[].label
* @property {string} options[].value
* @property {string} options[].visibility hidden or visible
* @property {Array} selection Selected options
* @property {Integer} showMaxOptions=10 Maximum number of visible options before a scroll bar should be displayed.
* @property {string} type=radio Multiple (checkbox) or single selection (radio)
* @property {string} name=(random id) Name of the form field
* @property {string} url Load options from server per url
* @property {object} lookup Load options from server per url
* @property {string} lookup.url=null Load options from server per url
* @property {boolean} lookup.grouping=false Load all selected options from server per url at once (true) or one by one (false)
* @property {Object} fetch Fetch [see Using Fetch mozilla.org](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch)
* @property {String} fetch.redirect=error
* @property {String} fetch.method=GET
* @property {String} fetch.mode=same-origin
* @property {String} fetch.credentials=same-origin
* @property {Object} fetch.headers={"accept":"application/json"}}
* @property {Object} labels
* @property {string} labels.cannot-be-loaded cannot be loaded
* @property {string} labels.no-options-available no options available
* @property {string} labels.select-an-option select an option
* @property {string} labels.no-option no option in the list, maybe you have to change the filter
* @property {Object} features List with features
* @property {Boolean} features.clearAll=true Display of a delete button to delete the entire selection
* @property {Boolean} features.clear=true Display of a delete key for deleting the specific selection
* @property {Boolean} features.lazyLoad=false Load options when first opening the dropdown. (Hint; lazylLoad is not supported with remote filter)
* @property {Boolean} features.closeOnSelect=false Close the dropdown when an option is selected (since 3.54.0)
* @property {Boolean} features.emptyValueIfNoOptions=false If no options are available, the selection is set to an empty array
* @property {Boolean} features.storeFetchedData=false Store fetched data in the object
* @property {Boolean} features.useStrictValueComparison=true Use strict value comparison for the selection
* @property {string} filter.defaultValue=null Default filter value, if the filter is empty, if the default value is null, then no request is made
* @property {Boolean} filter.mode=options Filter mode, values: options, remote, disabled (Hint; lazylLoad is not supported with remote filter, if you use remote filter, the lazyLoad is disabled)
* @property {Object} templates Template definitions
* @property {string} templates.main Main template
* @property {string} templateMapping Mapping of the template placeholders
* @property {string} templateMapping.selected Selected Template
* @property {Object} popper [PopperJS Options](https://popper.js.org/docs/v2/)
* @property {string} popper.placement=bottom PopperJS placement
* @property {Object[]} modifiers={name:offset} PopperJS placement
* @property {Object} mapping
* @property {String} mapping.selector=* Path to select the appropriate entries
* @property {String} mapping.labelTemplate="" template with the label placeholders in the form ${name}, where name is the key (**)
* @property {String} mapping.valueTemplate="" template with the value placeholders in the form ${name}, where name is the key
* @property {Monster.Components.Form~exampleFilterCallback|undefined} mapping.filter Filtering of values via a function
* @property {Object} formatter
* @property {Monster.Components.Form~formatterSelectionCallback|undefined} formatter.selection format selection label
*/
get defaults() {
return Object.assign(
{},
super.defaults,
{
toggleEventType: ["click", "touch"],
delegatesFocus: false,
options: [],
selection: [],
showMaxOptions: 10,
type: "radio",
name: new ID("s").toString(),
features: {
clearAll: true,
clear: true,
lazyLoad: false,
closeOnSelect: false,
emptyValueIfNoOptions: false,
storeFetchedData: false,
useStrictValueComparison: false,
},
url: null,
lookup: {
url: null,
grouping: false,
},
labels: {
"cannot-be-loaded": "Cannot be loaded",
"no-options-available": noOptionsAvailableMessage,
"click-to-load-options": clickToLoadOptionsMessage,
"select-an-option": "Select an option",
"summary-text": {
zero: "No entries were selected",
one: '<span class="monster-badge-primary-pill">1</span> entry was selected',
other:
'<span class="monster-badge-primary-pill">${count}</span> entries were selected',
},
"no-options":
"Unfortunately, there are no options available in the list.",
"no-options-found":
"No options are available in the list. Please consider modifying the filter.",
},
messages: {
control: null,
selected: null,
emptyOptions: null,
},
fetch: {
redirect: "error",
method: "GET",
mode: "same-origin",
credentials: "same-origin",
headers: {
accept: "application/json",
},
},
filter: {
defaultValue: null,
mode: FILTER_MODE_DISABLED,
position: FILTER_POSITION_INLINE,
marker: {
open: "{",
close: "}",
},
},
classes: {
badge: "monster-badge-primary",
statusOrRemoveBadge: "empty",
},
mapping: {
selector: "*",
labelTemplate: "",
valueTemplate: "",
filter: null,
},
formatter: {
selection: buildSelectionLabel,
},
templates: {
main: getTemplate(),
},
templateMapping: {
/** with the attribute `data-monster-selected-template` the template for the selected options can be defined. */
selected: getSelectionTemplate(),
},
popper: {
placement: "bottom",
middleware: ["flip", "offset:1"],
},
},
initOptionsFromArguments.call(this),
);
}
/**
* @return {Select}
*/
[assembleMethodSymbol]() {
const self = this;
super[assembleMethodSymbol]();
initControlReferences.call(self);
initEventHandler.call(self);
let lazyLoadFlag = self.getOption("features.lazyLoad", false);
let remoteFilterFlag = getFilterMode.call(this) === FILTER_MODE_REMOTE;
if (getFilterMode.call(this) === FILTER_MODE_REMOTE) {
self.getOption("features.lazyLoad", false);
if (lazyLoadFlag === true) {
addAttributeToken(
this,
ATTRIBUTE_ERRORMESSAGE,
"lazyLoad is not supported with remote filter",
);
lazyLoadFlag = false;
}
}
if (self.hasAttribute("value")) {
new Processing(10, () => {
this.value = this.getAttribute("value");
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
}
if (self.getOption("url") !== null) {
if (lazyLoadFlag || remoteFilterFlag) {
lookupSelection.call(self);
} else {
self.fetch().catch((e) => {
addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
}
}
let lastValue = self.value;
self[internalSymbol].attachObserver(
new Observer(function () {
if (isObject(this) && this instanceof ProxyObserver) {
const n = this.getSubject()?.options?.value;
if (lastValue !== n) {
lastValue = n;
setSelection
.call(self, n)
.then(() => {})
.catch((e) => {
addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
}
}
}),
);
areOptionsAvailableAndInit.call(self);
return this;
}
/**
*
* @return {*}
* @throws {Error} storeFetchedData is not enabled
* @since 3.66.0
*/
getLastFetchedData() {
if (this.getOption("features.storeFetchedData") === false) {
throw new Error("storeFetchedData is not enabled");
}
return this?.[lastFetchedDataSymbol];
}
/**
* The Button.click() method simulates a click on the internal button element.
*
* @since 3.27.0
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click}
*/
click() {
if (this.getOption("disabled") === true) {
return;
}
toggle.call(this);
}
/**
* The Button.focus() method sets focus on the internal button element.
*
* @since 3.27.0
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus}
*/
focus(options) {
if (this.getOption("disabled") === true) {
return;
}
new Processing(() => {
gatherState.call(this);
focusFilter.call(this, options);
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${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) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
}
/**
* If no url is specified, the options are taken from the Component itself.
*
* @param {string|URL} url URL to fetch the options
* @return {Promise}
*/
fetch(url) {
return fetchIt.call(this, url);
}
/**
* @return {void}
*/
connectedCallback() {
super.connectedCallback();
const document = getDocument();
for (const [, type] of Object.entries(["click", "touch"])) {
// close on outside ui-events
document.addEventListener(type, this[closeEventHandler]);
}
parseSlotsToOptions.call(this);
attachResizeObserver.call(this);
updatePopper.call(this);
new Processing(() => {
gatherState.call(this);
focusFilter.call(this);
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
}
/**
* @return {void}
*/
disconnectedCallback() {
super.disconnectedCallback();
const document = getDocument();
// close on outside ui-events
for (const [, type] of Object.entries(["click", "touch"])) {
document.removeEventListener(type, this[closeEventHandler]);
}
disconnectResizeObserver.call(this);
}
/**
* Import Select Options from dataset
* Not to be confused with the control defaults/options
*
* @param {array|object|Map|Set} data
* @return {Select}
* @throws {Error} map is not iterable
* @throws {Error} missing label configuration
* @fires monster-options-set this event is fired when the options are set
*/
importOptions(data) {
const mappingOptions = this.getOption("mapping", {});
const selector = mappingOptions?.["selector"];
const labelTemplate = mappingOptions?.["labelTemplate"];
const valueTemplate = mappingOptions?.["valueTemplate"];
const filter = mappingOptions?.["filter"];
let flag = false;
if (labelTemplate === "") {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "empty label template");
flag = true;
}
if (valueTemplate === "") {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "empty value template");
flag = true;
}
if (flag === true) {
throw new Error("missing label configuration");
}
const map = buildMap(data, selector, labelTemplate, valueTemplate, filter);
const options = [];
if (!isIterable(map)) {
throw new Error("map is not iterable");
}
const visibility = "visible";
map.forEach((label, value) => {
options.push({
value,
label,
visibility,
data: map.get(value),
});
});
runAsOptionLengthChanged.call(this, map.size);
this.setOption("options", options);
fireCustomEvent(this, "monster-options-set", {
options,
});
return this;
}
/**
* @private
* @return {Select}
*/
calcAndSetOptionsDimension() {
calcAndSetOptionsDimension.call(this);
return this;
}
/**
*
* @return {string}
*/
static getTag() {
return "monster-select";
}
/**
*
* @return {CSSStyleSheet[]}
*/
static getCSSStyleSheet() {
return [SelectStyleSheet];
}
}
/**
* @private
*/
function lookupSelection() {
const self = this;
setTimeout(() => {
const selection = self.getOption("selection");
if (selection.length === 0) {
return;
}
if (self[isLoadingSymbol] === true) {
return;
}
if (self[lazyLoadDoneSymbol] === true) {
return;
}
let url = self.getOption("url");
let lookupUrl = self.getOption("lookup.url");
if (lookupUrl !== null) {
url = lookupUrl;
}
if (this.getOption("lookup.grouping") === true) {
filterFromRemoteByValue
.call(
self,
url,
selection.map((s) => s?.["value"]),
)
.catch((e) => {
addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
return;
}
for (const s of selection) {
if (s?.["value"]) {
filterFromRemoteByValue.call(self, url, s?.["value"]).catch((e) => {
addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
}
}
}, 100);
}
function fetchIt(url, controlOptions) {
if (url instanceof URL) {
url = url.toString();
}
if (url !== undefined && url !== null) {
url = validateString(url);
} else {
url = this.getOption("url");
if (url === null) {
return Promise.reject(new Error("No url defined"));
}
}
return new Promise((resolve, reject) => {
setStatusOrRemoveBadges.call(this, "loading");
new Processing(10, () => {
fetchData
.call(this, url)
.then((map) => {
if (
isObject(map) ||
isArray(map) ||
map instanceof Set ||
map instanceof Map
) {
try {
this.importOptions(map);
} catch (e) {
setStatusOrRemoveBadges.call(this, "error");
reject(e);
return;
}
this[lastFetchedDataSymbol] = map;
let result;
const selection = this.getOption("selection");
let newValue = [];
if (selection) {
newValue = selection;
} else if (this.hasAttribute("value")) {
newValue = this.getAttribute("value");
}
result = setSelection.call(this, newValue);
requestAnimationFrame(() => {
checkOptionState.call(this);
setStatusOrRemoveBadges.call(this, "closed");
updatePopper.call(this);
resolve(result);
});
return;
}
setStatusOrRemoveBadges.call(this, "error");
reject(new Error("invalid response"));
})
.catch((e) => {
setStatusOrRemoveBadges.call(this, "error");
reject(e);
});
})
.run()
.catch((e) => {
setStatusOrRemoveBadges.call(this, "error");
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
reject(e);
});
});
}
/**
* This attribute can be used to pass a URL to this select.
*
* ```
* <monster-select data-monster-url="https://example.com/"></monster-select>
* ```
*
* @private
* @deprecated 2024-01-21 (you should use data-monster-option-...)
* @return {object}
*/
function initOptionsFromArguments() {
const options = {};
const template = this.getAttribute("data-monster-selected-template");
if (isString(template)) {
if (!options["templateMapping"]) options["templateMapping"] = {};
switch (template) {
case "summary":
case "default":
options["templateMapping"]["selected"] = getSummaryTemplate();
break;
case "selected":
options["templateMapping"]["selected"] = getSelectionTemplate();
break;
default:
addAttributeToken(
this,
ATTRIBUTE_ERRORMESSAGE,
"invalid template, use summary or selected",
);
}
}
return options;
}
/**
* @private
*/
function attachResizeObserver() {
// against flickering
this[resizeObserverSymbol] = new ResizeObserver((entries) => {
if (this[timerCallbackSymbol] instanceof DeadMansSwitch) {
try {
this[timerCallbackSymbol].touch();
return;
} catch (e) {
delete this[timerCallbackSymbol];
}
}
this[timerCallbackSymbol] = new DeadMansSwitch(200, () => {
updatePopper.call(this);
delete this[timerCallbackSymbol];
});
});
this[resizeObserverSymbol].observe(this.parentElement);
}
function disconnectResizeObserver() {
if (this[resizeObserverSymbol] instanceof ResizeObserver) {
this[resizeObserverSymbol].disconnect();
}
}
function getSelectionTemplate() {
return `<div data-monster-role="selection" part="selection"
data-monster-insert="selection path:selection" role="search"
><input type="text" role="searchbox"
part="inline-filter" name="inline-filter"
data-monster-role="filter"
autocomplete="off"
tabindex="0"
><div data-monster-replace="path:messages.control"></div>
</div>`;
}
function getSummaryTemplate() {
return `<div data-monster-role="selection" role="search" part="summary">
<input type="text" role="searchbox"
part="inline-filter" name="inline-filter"
data-monster-role="filter"
autocomplete="off"
tabindex="0"
>
<div data-monster-replace="path:messages.selected"></div>
</div>`;
}
/**
* @return {void}
* @private
*/
function parseSlotsToOptions() {
let options = this.getOption("options");
if (!isIterable(options)) {
options = [];
}
let counter = 1;
getSlottedElements.call(this, "div").forEach((node) => {
let value = (counter++).toString();
let visibility = "visible";
if (node.hasAttribute("data-monster-value")) {
value = node.getAttribute("data-monster-value");
}
if (node.style.display === "none") {
visibility = "hidden";
}
const label = node.outerHTML;
options.push({
value,
label,
visibility,
});
});
runAsOptionLengthChanged.call(this, options.length);
this.setOption("options", options);
}
/**
* wait until all options are finished rendering
*
* @private
* @param {int} targetLength
*/
function runAsOptionLengthChanged(targetLength) {
const self = this;
if (!self[optionsElementSymbol]) {
return;
}
const callback = function (mutationsList, observer) {
const run = false;
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
const run = true;
break;
}
}
if (run === true) {
const nodes = self[optionsElementSymbol].querySelectorAll(
`div[${ATTRIBUTE_ROLE}=option]`,
);
if (nodes.length === targetLength) {
checkOptionState.call(self);
observer.disconnect();
}
}
};
const observer = new MutationObserver(callback);
observer.observe(self[optionsElementSymbol], {
attributes: false,
childList: true,
subtree: true,
});
}
/**
* @private
* @param {*} value
* @return {*}
*/
function buildSelectionLabel(value) {
const options = this.getOption("options");
for (let i = 0; i < options.length; i++) {
let o = options?.[i];
let l, v, v2;
if (this.getOption("features.useStrictValueComparison") === true) {
v = value;
} else {
v = `${value}`;
}
if (isPrimitive(o) && o === value) {
return o;
} else if (!isObject(o)) {
continue;
}
if (this.getOption("features.useStrictValueComparison") === true) {
l = o?.["label"];
v2 = o?.["value"];
} else {
l = `${o?.["label"]}`;
v2 = `${o?.["value"]}`;
}
if (v2 === v) {
return l;
}
}
return undefined;
}
/**
* @private
* @param {*} value
* @return {string}
* @throws {Error} no value found
*/
function getSelectionLabel(value) {
const callback = this.getOption("formatter.selection");
if (isFunction(callback)) {
const label = callback.call(this, value);
if (isString(label)) return label;
}
if (isString(value) || isInteger(value)) {
return `${value}`;
}
return this.getOption("labels.cannot-be-loaded", value);
}
/**
* @private
* @param {Event} event
*/
function handleToggleKeyboardEvents(event) {
switch (event?.["code"]) {
case "Escape":
toggle.call(this);
event.preventDefault();
break;
case "Space":
toggle.call(this);
event.preventDefault();
break;
case "ArrowDown":
show.call(this);
activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN);
event.preventDefault();
break;
case "ArrowUp":
hide.call(this);
event.preventDefault();
break;
}
}
/**
* @license AGPLv3
* @since 1.15.0
* @private
* @this CustomElement
*/
function initOptionObserver() {
const self = this;
self.attachObserver(
new Observer(function () {
new Processing(() => {
try {
self.updateI18n();
} catch (e) {
console.error(e);
requestAnimationFrame(() => {
setStatusOrRemoveBadges.call(self, "error");
});
}
try {
areOptionsAvailableAndInit.call(self);
} catch (e) {
console.error(e);
requestAnimationFrame(() => {
setStatusOrRemoveBadges.call(self, "error");
});
}
setSummaryAndControlText.call(self);
}).run();
}),
);
}
function getDefaultTranslation() {
const translation = new Translations("en").assignTranslations(
this.getOption("labels", {}),
);
try {
const doc = getDocumentTranslations();
translation.locale = doc.locale;
} catch (e) {}
return translation;
}
/**
* @private
* @return {string|*}
*/
function setSummaryAndControlText() {
const translations = getDefaultTranslation.call(this);
const selections = this.getOption("selection");
const text = translations.getPluralRuleText(
"summary-text",
selections.length,
"",
);
const selectedText = new Formatter({
count: String(selections.length),
}).format(text);
this.setOption("messages.selected", selectedText);
const current = this.getOption("messages.control");
const msg = this.getOption("labels.select-an-option");
if (
current === "" ||
current === undefined ||
current === msg ||
current === null
) {
if (selections === undefined || selections.length === 0) {
this.setOption("messages.control", msg);
} else {
this.setOption("messages.control", "");
}
}
}
/**
* @private
* @return {NodeList}
*/
function getOptionElements() {
return this[optionsElementSymbol].querySelectorAll(
`[${ATTRIBUTE_ROLE}=option]`,
);
}
/**
* With the help of this filter callback, values can be filtered out. Only if the filter function returns true, the value is taken for the map.
*
* @callback Monster.Components.Form~exampleFilterCallback
* @param {*} value Value
* @param {string} key Key
* @see Monster.Data.buildMap
*/
/**
*
* @callback Monster.Components.Form~formatterSelectionCallback
* @param {*} value Value
* @return {string|undefined}
* @see Monster.Data.buildMap
*/
/**
* @private
*/
function calcAndSetOptionsDimension() {
const options = getOptionElements.call(this);
const container = this[optionsElementSymbol];
if (!(container instanceof HTMLElement && options instanceof NodeList)) {
return;
}
let visible = 0;
let optionHeight = 0;
const max = this.getOption("showMaxOptions", 10);
let scrollFlag = false;
for (const [, option] of Object.entries(options)) {
const computedStyle = getGlobal().getComputedStyle(option);
if (computedStyle.display === "none") continue;
let h = option.getBoundingClientRect().height;
h += parseInt(computedStyle.getPropertyValue("margin-top"), 10);
h += parseInt(computedStyle.getPropertyValue("margin-bottom"), 10);
optionHeight += h;
visible++;
if (visible > max) {
break;
}
}
if (visible > max) {
visible = max;
scrollFlag = true;
}
if (visible === 0) {
if (getFilterMode.call(this) === FILTER_MODE_DISABLED) {
this.setOption(
"messages.emptyOptions",
this.getOption("labels.no-options-available"),
);
} else {
this.setOption(
"messages.emptyOptions",
this.getOption("labels.no-options-found"),
);
}
this[noOptionsAvailableElementSymbol].classList.remove("d-none");
} else {
this[noOptionsAvailableElementSymbol].classList.add("d-none");
}
const styles = getGlobal().getComputedStyle(this[optionsElementSymbol]);
let padding = parseInt(styles.getPropertyValue("padding-top"), 10);
padding += parseInt(styles.getPropertyValue("padding-bottom"), 10);
let margin = parseInt(styles.getPropertyValue("margin-top"), 10);
margin += parseInt(styles.getPropertyValue("margin-bottom"), 10);
const containerHeight = optionHeight + padding + margin;
container.style.height = `${containerHeight}px`;
if (scrollFlag === true) {
container.style.overflowY = "scroll";
} else {
container.style.overflowY = "auto";
}
const domRect = this[controlElementSymbol].getBoundingClientRect();
this[popperElementSymbol].style.width = `${domRect.width}px`;
container.style.overflowX = "auto";
}
/**
* @private
* @param {number} direction
* @throws {Error} no shadow-root is defined
*/
function activateCurrentOption(direction) {
if (!this.shadowRoot) {
throw new Error("no shadow-root is defined");
}
let focused = this.shadowRoot.querySelector(`[${ATTRIBUTE_PREFIX}focused]`);
if (
!(focused instanceof HTMLElement) ||
focused.matches("[data-monster-visibility=hidden]")
) {
for (const [, e] of Object.entries(
this.shadowRoot.querySelectorAll(`[${ATTRIBUTE_ROLE}=option]`),
)) {
if (e.matches("[data-monster-visibility=visible]")) {
focused = e;
break;
}
}
} else {
if (direction === FOCUS_DIRECTION_DOWN) {
while (focused.nextSibling) {
focused = focused.nextSibling;
if (
focused instanceof HTMLElement &&
focused.hasAttribute(ATTRIBUTE_ROLE) &&
focused.getAttribute(ATTRIBUTE_ROLE) === "option" &&
focused.matches("[data-monster-visibility=visible]") &&
focused.matches(":not([data-monster-filtered=true])")
) {
break;
}
}
} else {
let found = false;
while (focused.previousSibling) {
focused = focused.previousSibling;
if (
focused instanceof HTMLElement &&
focused.hasAttribute(ATTRIBUTE_ROLE) &&
focused.getAttribute(ATTRIBUTE_ROLE) === "option" &&
focused.matches("[data-monster-visibility=visible]") &&
focused.matches(":not([data-monster-filtered=true])")
) {
found = true;
break;
}
}
if (found === false) {
focusFilter.call(this);
}
}
}
new Processing(() => {
if (focused instanceof HTMLElement) {
this.shadowRoot
.querySelectorAll(`[${ATTRIBUTE_PREFIX}focused]`)
.forEach((e) => {
e.removeAttribute(`${ATTRIBUTE_PREFIX}focused`);
});
focused.focus();
focused.setAttribute(`${ATTRIBUTE_PREFIX}focused`, true);
}
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
}
/**
* @private
*/
function filterOptions() {
new Processing(() => {
let filterValue;
switch (this.getOption("filter.position")) {
case FILTER_POSITION_INLINE:
if (this[inlineFilterElementSymbol] instanceof HTMLElement) {
filterValue = this[inlineFilterElementSymbol].value.toLowerCase();
} else {
return;
}
break;
case FILTER_POSITION_POPPER:
default:
if (this[popperFilterElementSymbol] instanceof HTMLInputElement) {
filterValue = this[popperFilterElementSymbol].value.toLowerCase();
} else {
return;
}
}
const options = this.getOption("options");
for (const [i, option] of Object.entries(options)) {
if (option.label.toLowerCase().indexOf(filterValue) === -1) {
this.setOption(`options.${i}.filtered`, "true");
} else {
this.setOption(`options.${i}.filtered`, undefined);
}
}
})
.run()
.then(() => {
new Processing(100, () => {
calcAndSetOptionsDimension.call(this);
focusFilter.call(this);
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
})
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
}
/**
* @private
* @param {Event} event
*/
function handleFilterKeyboardEvents(event) {
const shiftKey = event?.["shiftKey"];
switch (event?.["code"]) {
case "Tab":
activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN);
event.preventDefault();
break;
case "Escape":
toggle.call(this);
event.preventDefault();
break;
case "Tab" && shiftKey === true:
case "ArrowUp":
activateCurrentOption.call(this, FOCUS_DIRECTION_UP);
event.preventDefault();
break;
case "Tab" && !shiftKey:
case "ArrowDown":
activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN);
event.preventDefault();
break;
default:
if (
this.getOption("features.lazyLoad") === true &&
this[lazyLoadDoneSymbol] !== true
) {
this.click();
}
handleFilterKeyEvents.call(this);
}
}
/**
* Method handleFilterKeyEvents is used to handle filter key events.
* Debounce is used to prevent multiple calls.
*
* @function
* @name handleFilterKeyEvents
*
* @private
* @return {void} This method does not return anything.
*/
function handleFilterKeyEvents() {
if (this[keyFilterEventSymbol] instanceof DeadMansSwitch) {
try {
this[keyFilterEventSymbol].touch();
return;
} catch (e) {
delete this[keyFilterEventSymbol];
}
}
this[keyFilterEventSymbol] = new DeadMansSwitch(200, () => {
if (getFilterMode.call(this) !== FILTER_MODE_REMOTE) {
filterOptions.call(this);
} else {
filterFromRemote.call(this).catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
}
delete this[keyFilterEventSymbol];
});
}
/**
* @private
*/
function filterFromRemote() {
if (
!(this[inlineFilterElementSymbol] instanceof HTMLElement) &&
!(this[popperFilterElementSymbol] instanceof HTMLElement)
) {
return;
}
show.call(this);
const url = this.getOption("url");
if (!url) {
addAttributeToken(
this,
ATTRIBUTE_ERRORMESSAGE,
"Missing URL for Remote Filter.",
);
return;
}
let filterValue;
switch (this.getOption("filter.position")) {
case FILTER_POSITION_INLINE:
if (this[inlineFilterElementSymbol] instanceof HTMLElement) {
filterValue = this[inlineFilterElementSymbol].value.toLowerCase();
}
break;
case FILTER_POSITION_POPPER:
default:
if (this[popperFilterElementSymbol] instanceof HTMLInputElement) {
filterValue = this[popperFilterElementSymbol].value.toLowerCase();
}
}
return filterFromRemoteByValue.call(this, url, filterValue);
}
function formatURL(url, value) {
if (value === undefined || value === null || value === "") {
value = this.getOption("filter.defaultValue");
if (value === undefined || value === null || value === "") {
value = disabledRequestMarker.toString();
}
}
const formatter = new Formatter({ filter: encodeURI(value) });
const openMarker = this.getOption("filter.marker.open");
let closeMarker = this.getOption("filter.marker.close");
if (!closeMarker) {
closeMarker = openMarker;
}
if (openMarker && closeMarker) {
formatter.setMarker(openMarker, closeMarker);
}
return formatter.format(url);
}
/**
* @private
* @param optionUrl
* @param value
* @returns {Promise<unknown>}
*/
function filterFromRemoteByValue(optionUrl, value) {
return new Processing(() => {
let url = formatURL.call(this, optionUrl, value);
if (url.indexOf(disabledRequestMarker.toString()) !== -1) {
return;
}
fetchIt
.call(this, url, {
disableHiding: true,
})
.then(() => {
checkOptionState.call(this);
show.call(this);
})
.catch((e) => {
throw e;
});
})
.run()
.catch((e) => {
throw e;
});
}
/**
* @param {Event} event
* @private
*/
function handleOptionKeyboardEvents(event) {
const shiftKey = event?.["shiftKey"];
switch (event?.["code"]) {
case "Escape":
toggle.call(this);
event.preventDefault();
break;
case "Enter":
case "Space":
const path = event.composedPath();
const element = path?.[0];
if (element instanceof HTMLElement) {
const input = element.getElementsByTagName("input");
if (!input) {
return;
}
fireEvent(input, "click");
}
event.preventDefault();
break;
case "Tab" && shiftKey === true:
case "ArrowUp":
activateCurrentOption.call(this, FOCUS_DIRECTION_UP);
event.preventDefault();
break;
case "Tab" && !shiftKey:
case "ArrowLeft":
case "ArrowRight":
// handled by tree select
break;
case "ArrowDown":
activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN);
event.preventDefault();
break;
default:
const p = event.composedPath();
if (p?.[0] instanceof HTMLInputElement) {
return;
}
focusFilter.call(this);
break;
}
}
/**
* @private
* @return {string}
*/
function getFilterMode() {
switch (this.getOption("filter.mode")) {
case FILTER_MODE_OPTIONS:
return FILTER_MODE_OPTIONS;
case FILTER_MODE_REMOTE:
return FILTER_MODE_REMOTE;
default:
return FILTER_MODE_DISABLED;
}
}
/**
* @private
*/
function blurFilter() {
if (!(this[inlineFilterElementSymbol] instanceof HTMLElement)) {
return;
}
if (getFilterMode.call(this) === FILTER_MODE_DISABLED) {
return;
}
this[popperFilterContainerElementSymbol].classList.remove("active");
this[popperFilterContainerElementSymbol].blur();
this[inlineFilterElementSymbol].classList.remove("active");
this[inlineFilterElementSymbol].blur();
}
/**
* @private
* @param focusOptions
*/
function focusPopperFilter(focusOptions) {
this[popperFilterContainerElementSymbol].classList.remove("d-none");
this[popperFilterElementSymbol].classList.add("active");
this[inlineFilterElementSymbol].classList.remove("active");
this[inlineFilterElementSymbol].classList.add("d-none");
if (!(this[popperFilterElementSymbol] instanceof HTMLElement)) {
addAttributeToken(
this,
ATTRIBUTE_ERRORMESSAGE,
"Missing Popper Filter Element.",
);
return;
}
// visibility is set to visible, because focus() does not work on invisible elements
// and the class definition is assigned later in the processing
setTimeout(() => {
if (focusOptions === undefined || focusOptions === null) {
this[popperFilterElementSymbol].focus();
} else {
this[popperFilterElementSymbol].focus(focusOptions);
}
}, 100);
}
/**
* @private
* @param focusOptions
*/
function focusInlineFilter(focusOptions) {
const options = this.getOption("options");
if (
(!isArray(options) || options.length === 0) &&
getFilterMode.call(this) !== FILTER_MODE_REMOTE
) {
return;
}
this[popperFilterContainerElementSymbol].classList.add("d-none");
this[inlineFilterElementSymbol].classList.add("active");
this[inlineFilterElementSymbol].classList.remove("d-none");
// visibility is set to visible, because focus() does not work on invisible elements
// and the class definition is assigned later in the processing
setTimeout(() => {
if (focusOptions === undefined || focusOptions === null) {
this[inlineFilterElementSymbol].focus();
} else {
this[inlineFilterElementSymbol].focus(focusOptions);
}
}, 100);
}
/**
* @private
*/
function focusFilter(focusOptions) {
if (getFilterMode.call(this) === FILTER_MODE_DISABLED) {
this[popperFilterContainerElementSymbol].classList.add("d-none");
this[inlineFilterElementSymbol].classList.add("d-none");
return;
}
if (this.getOption("filter.position") === FILTER_POSITION_INLINE) {
return focusInlineFilter.call(this, focusOptions);
}
return focusPopperFilter.call(this, focusOptions);
}
/**
* @private
* @return {array}
* @throws {Error} no shadow-root is defined
* @throws {Error} unsupported type
*/
function gatherState() {
const type = this.getOption("type");
if (["radio", "checkbox"].indexOf(type) === -1) {
throw new Error("unsupported type");
}
if (!this.shadowRoot) {
throw new Error("no shadow-root is defined");
}
const selection = [];
const elements = this.shadowRoot.querySelectorAll(
`input[type=${type}]:checked`,
);
for (const e of elements) {
selection.push({
label: getSelectionLabel.call(this, e.value),
value: e.value,
});
}
setSelection
.call(this, selection)
.then(() => {})
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
if (this.getOption("features.closeOnSelect") === true) {
toggle.call(this);
}
return this;
}
/**
* @private
* @throws {Error} no shadow-root is defined
* @throws {Error} unsupported type
*/
function clearSelection() {
const type = this.getOption("type");
if (["radio", "checkbox"].indexOf(type) === -1) {
throw new Error("unsupported type");
}
if (!this.shadowRoot) {
throw new Error("no shadow-root is defined");
}
setSelection
.call(this, [])
.then(() => {})
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
}
/**
* @private
*/
function areOptionsAvailableAndInit() {
// prevent multiple calls
if (this[areOptionsAvailableAndInitSymbol] === undefined) {
this[areOptionsAvailableAndInitSymbol] = 0;
}
if (this[areOptionsAvailableAndInitSymbol] > 0) {
this[areOptionsAvailableAndInitSymbol]--;
return true;
}
this[areOptionsAvailableAndInitSymbol]++;
const options = this.getOption("options");
if (
options === undefined ||
options === null ||
(isArray(options) && options.length === 0)
) {
setStatusOrRemoveBadges.call(this, "empty");
// hide.call(this);
let msg = this.getOption("labels.no-options-available");
if (
this.getOption("url") !== null &&
this.getOption("features.lazyLoad") === true &&
this[lazyLoadDoneSymbol] !== true
) {
msg = this.getOption("labels.click-to-load-options");
}
this.setOption("messages.control", msg);
this.setOption("messages.summary", "");
if (this.getOption("features.emptyValueIfNoOptions") === true) {
this.value = "";
}
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, noOptionsAvailableMessage);
return false;
}
const selections = this.getOption("selection");
if (
selections === undefined ||
selections === null ||
selections.length === 0
) {
this.setOption(
"messages.control",
this.getOption("labels.select-an-option"),
);
} else {
this.setOption("messages.control", "");
}
this.setOption("messages.summary", setSummaryAndControlText.call(this));
let updated = false;
let valueCounter = 1;
for (const option of options) {
if (option?.visibility === undefined) {
option.visibility = "visible";
updated = true;
}
if (option?.value === undefined && option?.label === undefined) {
option.value = `${valueCounter++}`;
option.label = option.value;
updated = true;
continue;
}
if (option?.value === undefined) {
option.value = option.label;
updated = true;
}
if (option?.label === undefined) {
option.label = option.value;
updated = true;
}
}
if (updated) {
this.setOption("options", options);
}
setStatusOrRemoveBadges.call(this);
removeAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, noOptionsAvailableMessage);
return true;
}
/**
* @private
* @throws {Error} no shadow-root is defined
*/
function checkOptionState() {
if (!this.shadowRoot) {
throw new Error("no shadow-root is defined");
}
const elements = this.shadowRoot.querySelectorAll(
`[${ATTRIBUTE_ROLE}=option] input`,
);
let selection = this.getOption("selection");
if (!isArray(selection)) {
selection = [];
}
const checkedValues = selection.map((a) => {
return a.value;
});
for (const e of elements) {
if (checkedValues.indexOf(e.value) !== -1) {
if (e.checked !== true) e.checked = true;
} else {
if (e.checked !== false) e.checked = false;
}
}
}
/**
* @private
* @param {*} value
* @return {Object}
*/
function convertValueToSelection(value) {
const selection = [];
if (isString(value)) {
value = value
.split(",")
.map((a) => {
return a.trim();
})
.filter((a) => {
return a !== "";
});
}
if (isString(value) || isInteger(value)) {
selection.push({
label: getSelectionLabel.call(this, value),
value: value,
});
} else if (isArray(value)) {
for (const v of value) {
selection.push({
label: getSelectionLabel.call(this, v),
value: v,
});
}
value = value.join(",");
} else {
throw new Error("unsupported type");
}
return {
selection: selection,
value: value,
};
}
/**
* @private
* @param {array} selection
* @return {string}
*/
function convertSelectionToValue(selection) {
const value = [];
if (isArray(selection)) {
for (const obj of selection) {
const v = obj?.["value"];
if (v !== undefined) value.push(v);
}
}
if (value.length === 0) {
return "";
} else if (value.length === 1) {
return value.pop();
}
return value.join(",");
}
/**
* @private
* @param {array} selection
* @return {Promise}
* @throws {Error} no shadow-root is defined
*/
function setSelection(selection) {
if (isString(selection)) {
const result = convertValueToSelection.call(this, selection);
selection = result?.selection;
} else if (selection === undefined) {
selection = [];
}
validateArray(selection);
for (let i = 0; i < selection.length; i++) {
var l = getSelectionLabel.call(this, selection[i].value);
if (l === selection[i].value) {
l = selection[i].label;
}
selection[i] = {
label: l,
value: selection[i].value,
};
}
this.setOption("selection", selection);
checkOptionState.call(this);
try {
this?.setFormValue(this.value);
} catch (e) {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
}
fireCustomEvent(this, "monster-selected", {
selection,
});
return new Processing(() => {
const CLASSNAME = "selected";
if (!this.shadowRoot) {
throw new Error("no shadow-root is defined");
}
const notSelected = this.shadowRoot.querySelectorAll(":not(:checked)");
if (notSelected) {
notSelected.forEach((node) => {
const parent = node.closest(`[${ATTRIBUTE_ROLE}=option]`);
if (parent) {
parent.classList.remove(CLASSNAME);
}
});
}
const selected = this.shadowRoot.querySelectorAll(":checked");
if (selected) {
selected.forEach((node) => {
const parent = node.closest(`[${ATTRIBUTE_ROLE}=option]`);
if (parent) {
parent.classList.add(CLASSNAME);
}
});
}
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
}
/**
* @private
* @param {string} url
* @return {Promise}
* @throws {TypeError} the result cannot be parsed
* @throws {TypeError} unsupported response
*/
function fetchData(url) {
const self = this;
if (!url) url = this.getOption("url");
if (!url) return Promise.resolve();
const fetchOptions = this.getOption("fetch", {});
let delayWatch = false;
// if fetch short time, do not show loading badge, because of flickering
requestAnimationFrame(() => {
if (delayWatch === true) return;
setStatusOrRemoveBadges.call(this, "loading");
delayWatch = true;
});
url = formatURL.call(this, url);
self[isLoadingSymbol] = true;
const global = getGlobal();
return global
.fetch(url, fetchOptions)
.then((response) => {
self[isLoadingSymbol] = false;
delayWatch = true;
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.text();
}
throw new TypeError(`unsupported response ${contentType}`);
})
.then((text) => {
try {
return Promise.resolve(JSON.parse(String(text)));
} catch (e) {
throw new TypeError("the result cannot be parsed, check the URL");
}
})
.catch((e) => {
self[isLoadingSymbol] = false;
delayWatch = true;
throw e;
});
}
/**
* @private
*/
function hide() {
this[popperElementSymbol].style.display = "none";
setStatusOrRemoveBadges.call(this, "closed");
removeAttributeToken(this[controlElementSymbol], "class", "open");
}
/**
* @private
*/
function show() {
if (this.getOption("disabled", undefined) === true) {
return;
}
if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) {
return;
}
focusFilter.call(this);
const lazyLoadFlag =
this.getOption("features.lazyLoad") && this[lazyLoadDoneSymbol] !== true;
if (lazyLoadFlag === true) {
this[lazyLoadDoneSymbol] = true;
setStatusOrRemoveBadges.call(this, "loading");
new Processing(200, () => {
this.fetch()
.then(() => {
checkOptionState.call(this);
requestAnimationFrame(() => {
show.call(this);
});
})
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
setStatusOrRemoveBadges.call(this, "error");
});
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
setStatusOrRemoveBadges.call(this, "error");
});
return;
}
const hasPopperFilterFlag =
this.getOption("filter.position") === FILTER_POSITION_POPPER &&
getFilterMode.call(this) !== FILTER_MODE_DISABLED;
const options = getOptionElements.call(this);
if (options.length === 0 && hasPopperFilterFlag === false) {
return;
}
this[popperElementSymbol].style.visibility = "hidden";
this[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK;
setStatusOrRemoveBadges.call(this, "open");
addAttributeToken(this[controlElementSymbol], "class", "open");
new Processing(() => {
calcAndSetOptionsDimension.call(this);
focusFilter.call(this);
this[popperElementSymbol].style.removeProperty("visibility");
updatePopper.call(this);
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
}
/**
* @private
*/
function toggle() {
if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) {
hide.call(this);
} else {
show.call(this);
}
}
/**
* @private
* @fires monster-selection-removed
* @fires monster-selection-cleared
*/
function initEventHandler() {
const self = this;
/**
* @param {Event} event
*/
self[clearOptionEventHandler] = (event) => {
const element = findTargetElementFromEvent(
event,
ATTRIBUTE_ROLE,
"remove-badge",
);
if (element instanceof HTMLElement) {
const badge = findClosestByAttribute(element, ATTRIBUTE_ROLE, "badge");
if (badge instanceof HTMLElement) {
const value = badge.getAttribute(`${ATTRIBUTE_PREFIX}value`);
let selection = self.getOption("selection");
selection = selection.filter((b) => {
return value !== b.value;
});
setSelection
.call(self, selection)
.then(() => {
fireCustomEvent(self, "monster-selection-removed", {
value,
});
})
.catch((e) => {
addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, e.message);
});
}
}
};
/**
* @param {Event} event
*/
self[closeEventHandler] = (event) => {
const path = event.composedPath();
for (const [, element] of Object.entries(path)) {
if (element === self) {
return;
}
}
hide.call(self);
};
/**
* @param {Event} event
*/
self[inputEventHandler] = (event) => {
const path = event.composedPath();
const element = path?.[0];
if (element instanceof HTMLElement) {
if (
element.hasAttribute(ATTRIBUTE_ROLE) &&
element.getAttribute(ATTRIBUTE_ROLE) === "option-control"
) {
fireCustomEvent(self, "monster-change", {
type: event.type,
value: element.value,
checked: element.checked,
});
} else if (
element.hasAttribute(ATTRIBUTE_ROLE) &&
element.getAttribute(ATTRIBUTE_ROLE) === "filter"
) {
}
}
};
/**
* @param {Event} event
*/
self[changeEventHandler] = (event) => {
gatherState.call(self);
fireCustomEvent(self, "monster-changed", event?.detail);
};
self[keyEventHandler] = (event) => {
const path = event.composedPath();
const element = path.shift();
let role;
if (element instanceof HTMLElement) {
if (element.hasAttribute(ATTRIBUTE_ROLE)) {
role = element.getAttribute(ATTRIBUTE_ROLE);
} else if (element === this) {
show.call(this);
// focusFilter.call(self);
} else {
const e = element.closest(`[${ATTRIBUTE_ROLE}]`);
if (e instanceof HTMLElement && e.hasAttribute(ATTRIBUTE_ROLE)) {
role = e.getAttribute(ATTRIBUTE_ROLE);
}
}
} else {
return;
}
switch (role) {
case "filter":
handleFilterKeyboardEvents.call(self, event);
break;
case "option-label":
case "option-control":
case "option":
handleOptionKeyboardEvents.call(self, event);
break;
case "control":
case "toggle":
handleToggleKeyboardEvents.call(self, event);
break;
}
};
const types = self.getOption("toggleEventType", ["click"]);
for (const [, type] of Object.entries(types)) {
self[controlElementSymbol]
.querySelector(`[${ATTRIBUTE_ROLE}="container"]`)
.addEventListener(type, function (event) {
const element = findTargetElementFromEvent(
event,
ATTRIBUTE_ROLE,
"remove-badge",
);
if (element instanceof HTMLElement) {
return;
}
toggle.call(self);
});
self[controlElementSymbol]
.querySelector(`[${ATTRIBUTE_ROLE}="status-or-remove-badges"]`)
.addEventListener(type, function (event) {
if (self.getOption("disabled", undefined) === true) {
return;
}
const path = event.composedPath();
const element = path?.[0];
if (element instanceof HTMLElement) {
const control = element.closest(
`[${ATTRIBUTE_ROLE}="status-or-remove-badges"]`,
);
if (control instanceof HTMLElement) {
if (control.classList.contains("clear")) {
clearSelection.call(self);
fireCustomEvent(self, "monster-selection-cleared", {});
} else {
const element = findTargetElementFromEvent(
event,
ATTRIBUTE_ROLE,
"remove-badge",
);
if (element instanceof HTMLElement) {
return;
}
toggle.call(self);
}
}
}
});
// badge, selection
self.addEventListener(type, self[clearOptionEventHandler]);
}
self.addEventListener("monster-change", self[changeEventHandler]);
self.addEventListener("input", self[inputEventHandler]);
self.addEventListener("keydown", self[keyEventHandler]);
return self;
}
/**
* @private
* @return {Select}
*/
function setStatusOrRemoveBadges(suggestion) {
requestAnimationFrame(() => {
const selection = this.getOption("selection");
const clearAllFlag =
isArray(selection) &&
selection.length > 0 &&
this.getOption("features.clearAll") === true;
const current = this.getOption("classes.statusOrRemoveBadge");
if (suggestion === "error") {
if (current !== "error") {
this.setOption("classes.statusOrRemoveBadge", "error");
}
return;
}
if (this[isLoadingSymbol] === true) {
if (current !== "loading") {
this.setOption("classes.statusOrRemoveBadge", "loading");
}
return;
}
if (suggestion === "loading") {
if (current !== "loading") {
this.setOption("classes.statusOrRemoveBadge", "loading");
}
return;
}
if (clearAllFlag) {
if (current !== "clear") {
this.setOption("classes.statusOrRemoveBadge", "clear");
}
return;
}
if (this[controlElementSymbol].classList.contains("open")) {
if (current !== "open") {
this.setOption("classes.statusOrRemoveBadge", "open");
}
return;
}
const options = this.getOption("options");
if (
options === undefined ||
options === null ||
(isArray(options) && options.length === 0)
) {
if (current !== "empty") {
this.setOption("classes.statusOrRemoveBadge", "empty");
}
return;
}
if (suggestion) {
if (current !== suggestion) {
this.setOption("classes.statusOrRemoveBadge", suggestion);
}
return;
}
});
}
/**
* @private
* @return {Select}
* @throws {Error} no shadow-root is defined
*/
function initControlReferences() {
if (!this.shadowRoot) {
throw new Error("no shadow-root is defined");
}
this[controlElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=control]`,
);
this[selectionElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=selection]`,
);
this[containerElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=container]`,
);
this[popperElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=popper]`,
);
this[inlineFilterElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=filter][name="inline-filter"]`,
);
this[popperFilterElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=filter][name="popper-filter"]`,
);
this[popperFilterContainerElementSymbol] =
this[popperFilterElementSymbol].parentElement;
this[optionsElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=options]`,
);
this[noOptionsAvailableElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="no-options"]`,
);
this[statusOrRemoveBadgesElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=status-or-remove-badges]`,
);
}
/**
* @private
*/
function updatePopper() {
if (this[popperElementSymbol].style.display !== STYLE_DISPLAY_MODE_BLOCK) {
return;
}
if (this.getOption("disabled", false) === true) {
return;
}
new Processing(() => {
calcAndSetOptionsDimension.call(this);
positionPopper.call(
this,
this[controlElementSymbol],
this[popperElementSymbol],
this.getOption("popper", {}),
);
})
.run()
.catch((e) => {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message);
});
return this;
}
/**
* @private
* @return {string}
*/
function getTemplate() {
// language=HTML
return `
<template id="options">
<div data-monster-role="option" tabindex="-1"
data-monster-attributes="
data-monster-filtered path:options.filtered,
data-monster-visibility path:options.visibility">
<label part="option">
<input data-monster-role="option-control"
data-monster-attributes="
type path:type,
role path:role,
value path:options | index:value,
name path:name,
part path:type | prefix:option- | suffix: form,
class path:options.class
" tabindex="-1">
<div data-monster-replace="path:options | index:label"
part="option-label"></div>
</label>
</div>
</template>
<template id="selection">
<div data-monster-role="badge"
part="badge"
data-monster-attributes="
data-monster-value path:selection | index:value,
class path:classes | index:badge,
part path:type | suffix:-option | prefix: form-" tabindex="-1">
<div data-monster-replace="path:selection | index:label" part="badge-label"
data-monster-role="badge-label"></div>
<div part="remove-badge" data-monster-select-this
data-monster-attributes="class path:features.clear | ?::hidden "
data-monster-role="remove-badge" tabindex="-1"></div>
</div>
</template>
<slot class="hidden"></slot>
<div data-monster-role="control" part="control" tabindex="0">
<div data-monster-role="container">
\${selected}
</div>
<div data-monster-role="popper" part="popper" tabindex="-1" class="monster-color-primary-1">
<div class="option-filter-control" role="search">
<input type="text" role="searchbox"
part="popper-filter" name="popper-filter"
data-monster-role="filter"
autocomplete="off"
tabindex="0">
</div>
<div part="content" class="flex" data-monster-replace="path:content">
<div part="options" data-monster-role="options" data-monster-insert="options path:options"
tabindex="-1"></div>
</div>
<div part="no-options" data-monster-role="no-options"
data-monster-replace="path:messages.emptyOptions"></div>
</div>
<div part="status-or-remove-badges" data-monster-role="status-or-remove-badges"
data-monster-attributes="class path:classes.statusOrRemoveBadge"></div>
</div>
`;
}
registerCustomElement(Select);