@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
1,495 lines (1,356 loc) • 37 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 } from "../../constants.mjs";
import { addAttributeToken } from "../../dom/attributes.mjs";
import { ATTRIBUTE_ERRORMESSAGE } from "../../dom/constants.mjs";
import { CustomControl } from "../../dom/customcontrol.mjs";
import {
assembleMethodSymbol,
registerCustomElement,
} from "../../dom/customelement.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { getLocaleOfDocument } from "../../dom/locale.mjs";
import { Formatter } from "../../text/formatter.mjs";
import { isFunction, isObject, isString } from "../../types/is.mjs";
import { validateArray } from "../../types/validate.mjs";
import { Pathfinder } from "../../data/pathfinder.mjs";
import { CommonStyleSheet } from "../stylesheet/common.mjs";
import { ButtonStyleSheet } from "../stylesheet/button.mjs";
import { FormStyleSheet } from "../stylesheet/form.mjs";
import { VariantSelectStyleSheet } from "./stylesheet/variant-select.mjs";
import { Select } from "./select.mjs";
export { VariantSelect };
/**
* @private
* @type {symbol}
*/
const controlElementSymbol = Symbol("controlElement");
/**
* @private
* @type {symbol}
*/
const dimensionsElementSymbol = Symbol("dimensionsElement");
/**
* @private
* @type {symbol}
*/
const messageElementSymbol = Symbol("messageElement");
/**
* @private
* @type {symbol}
*/
const variantsSymbol = Symbol("variants");
/**
* @private
* @type {symbol}
*/
const dimensionValuesSymbol = Symbol("dimensionValues");
/**
* @private
* @type {symbol}
*/
const layoutSymbol = Symbol("layout");
/**
* @private
* @type {symbol}
*/
const selectControlSymbol = Symbol("selectControl");
/**
* @private
* @type {symbol}
*/
const combinedSelectSymbol = Symbol("combinedSelect");
/**
* @private
* @type {symbol}
*/
const resizeObserverSymbol = Symbol("resizeObserver");
/**
* @private
* @type {symbol}
*/
const lastValiditySymbol = Symbol("lastValidity");
/**
* @private
* @type {symbol}
*/
const initGuardSymbol = Symbol("initGuard");
/**
* VariantSelect
*
* @fragments /fragments/components/form/variant-select/
*
* @example /examples/components/form/variant-select-buttons Button layout
* @example /examples/components/form/variant-select-row-selects Row selects
* @example /examples/components/form/variant-select-combined-select Combined select
* @example /examples/components/form/variant-select-label-template Label templates
* @example /examples/components/form/variant-select-values-map Values map
* @example /examples/components/form/variant-select-messages-off Messages off
* @example /examples/components/form/variant-select-remote Remote data
*
* @summary A control to pick valid variant combinations (e.g., color/size).
* @since 4.64.0
* @fires monster-variant-select-change
* @fires monster-variant-select-valid
* @fires monster-variant-select-invalid
* @fires monster-variant-select-lazy-load
* @fires monster-variant-select-lazy-loaded
* @fires monster-variant-select-lazy-error
*/
class VariantSelect extends CustomControl {
/**
* This method is called by the `instanceof` operator.
* @return {symbol}
*/
static get [instanceSymbol]() {
return Symbol.for(
"@schukai/monster/components/form/variant-select@@instance",
);
}
/**
* 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}
*
* @property {Array<Object>} dimensions Dimension definitions ({key, label, presentation, valueTemplate, labelTemplate})
* @property {Array<Object>|Object|null} dimensions.values Optional label map for a dimension
* @property {Array<Object>} data Variant items
* @property {string|null} url Endpoint to fetch variants
* @property {Object} fetch Fetch options (method, headers, body)
* @property {Object} mapping Data mapping for fetched results
* @property {string} mapping.selector="*" Path to array in fetched data
* @property {string} mapping.valueTemplate Template for variant value
* @property {string} mapping.labelTemplate Template for combined variant label
* @property {Function|null} mapping.filter Filter function
* @property {Object} layout Layout options
* @property {boolean} layout.autoSelect=true Use select when buttons do not fit
* @property {number} layout.buttonMinWidth=84 Minimum width used to estimate button layout
* @property {Object} features Feature toggles
* @property {boolean} features.messages=true Show status message
* @property {Object} messages Message text
* @property {string} messages.valid Message for valid selection
* @property {string} messages.invalid Message for invalid selection
* @property {string} messages.incomplete Message for incomplete selection
* @property {Object} actions Callback actions
* @property {Function} actions.onchange called on any selection change
* @property {Function} actions.onvalid called when a valid variant is selected
* @property {Function} actions.oninvalid called when selection becomes invalid
* @property {Function} actions.onlazyload called before fetching variants
* @property {Function} actions.onlazyloaded called after fetching variants
* @property {Function} actions.onlazyerror called on fetch errors
* @property {Object} classes CSS classes
* @property {string} classes.option Base class for option buttons
* @property {string} classes.optionSelected Class for selected options
* @property {string} classes.optionDisabled Class for disabled options
*/
get defaults() {
return Object.assign({}, super.defaults, {
templates: {
main: getTemplate(),
},
dimensions: [],
data: [],
url: null,
fetch: {
method: "GET",
headers: {
accept: "application/json",
},
body: null,
},
mapping: {
selector: "*",
valueTemplate: "",
labelTemplate: "",
filter: null,
},
layout: {
autoSelect: false,
buttonMinWidth: 84,
combineSelect: true,
},
features: {
messages: true,
rowSelects: false,
combinedSelect: false,
},
messages: getTranslations(),
selection: {},
value: null,
classes: {
option: "monster-button-outline-primary",
optionSelected: "is-selected",
optionDisabled: "is-disabled",
},
actions: {
onchange: null,
onvalid: null,
oninvalid: null,
onlazyload: null,
onlazyloaded: null,
onlazyerror: null,
},
});
}
/**
* @return {VariantSelect}
*/
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
initControlReferences.call(this);
initEventHandler.call(this);
initResizeObserver.call(this);
refreshData.call(this);
return this;
}
/**
* @return {CSSStyleSheet[]}
*/
static getCSSStyleSheet() {
return [
CommonStyleSheet,
ButtonStyleSheet,
FormStyleSheet,
VariantSelectStyleSheet,
];
}
/**
* @return {string}
*/
static getTag() {
return "monster-variant-select";
}
/**
* @return {boolean}
*/
static get formAssociated() {
return true;
}
/**
* @return {string|null}
*/
get value() {
return this.getOption("value");
}
/**
* @param {string|null} value
*/
set value(value) {
this.setOption("value", value);
if (typeof this.setFormValue === "function") {
this.setFormValue(value ?? "");
}
}
/**
* Reload the data from `data` or `url`.
*/
refresh() {
refreshData.call(this);
}
}
/**
* @private
*/
function initControlReferences() {
this[controlElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=control]",
);
this[dimensionsElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=dimensions]",
);
this[messageElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=message]",
);
this[variantsSymbol] = [];
this[dimensionValuesSymbol] = new Map();
this[layoutSymbol] = new Map();
this[selectControlSymbol] = new Map();
this[combinedSelectSymbol] = null;
}
/**
* @private
*/
function initEventHandler() {
this.shadowRoot.addEventListener("click", (event) => {
const button = event.target?.closest("button[data-variant-dimension]");
if (!(button instanceof HTMLButtonElement)) {
return;
}
const key = button.getAttribute("data-variant-dimension");
const value = button.getAttribute("data-variant-value");
if (!key) {
return;
}
handleSelectionChange.call(this, key, value);
});
}
/**
* @private
*/
function initResizeObserver() {
if (typeof ResizeObserver !== "function") {
return;
}
if (!(this[controlElementSymbol] instanceof HTMLElement)) {
return;
}
this[resizeObserverSymbol] = new ResizeObserver(() => {
render.call(this);
});
this[resizeObserverSymbol].observe(this[controlElementSymbol]);
}
/**
* @private
*/
function refreshData() {
const url = this.getOption("url");
if (isString(url) && url !== "") {
void fetchData.call(this);
return;
}
buildFromData.call(this);
}
/**
* @private
*/
async function fetchData() {
const url = this.getOption("url");
if (!isString(url) || url === "") {
return;
}
const action = this.getOption("actions.onlazyload");
if (isFunction(action)) {
action.call(this);
}
fireCustomEvent(this, "monster-variant-select-lazy-load", {});
let response = null;
try {
response = await fetch(url, buildFetchOptions.call(this));
if (!response.ok) {
throw new Error("failed to fetch variants");
}
} catch (e) {
handleFetchError.call(this, e);
return;
}
let json = null;
try {
json = await response.json();
} catch (e) {
handleFetchError.call(this, e);
return;
}
const selector = this.getOption("mapping.selector", "*");
let data = json;
try {
if (selector !== "*" && selector !== "") {
data = new Pathfinder(json).getVia(selector);
}
} catch (e) {
handleFetchError.call(this, e);
return;
}
if (!Array.isArray(data)) {
handleFetchError.call(this, new Error("variant data is not an array"));
return;
}
this.setOption("data", data);
buildFromData.call(this);
const doneAction = this.getOption("actions.onlazyloaded");
if (isFunction(doneAction)) {
doneAction.call(this, data);
}
fireCustomEvent(this, "monster-variant-select-lazy-loaded", { data });
}
/**
* @private
* @param {Error} error
*/
function handleFetchError(error) {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${error}`);
const action = this.getOption("actions.onlazyerror");
if (isFunction(action)) {
action.call(this, error);
}
fireCustomEvent(this, "monster-variant-select-lazy-error", { error });
}
/**
* @private
* @return {Object}
*/
function buildFetchOptions() {
const fetchOptions = Object.assign({}, this.getOption("fetch", {}));
if (!fetchOptions.method) {
fetchOptions.method = "GET";
}
return fetchOptions;
}
/**
* @private
*/
function buildFromData() {
this[initGuardSymbol] = true;
const dimensions = this.getOption("dimensions", []);
try {
validateArray(dimensions);
} catch (e) {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
return;
}
const data = this.getOption("data", []);
const mapping = this.getOption("mapping", {});
const filter = mapping?.filter;
let items = Array.isArray(data) ? data.slice() : [];
if (isFunction(filter)) {
items = items.filter((item) => filter(item));
}
const variants = [];
const valueTemplate = mapping?.valueTemplate;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
const dims = {};
let hasMissing = false;
for (const def of dimensions) {
const key = def?.key;
if (!isString(key) || key === "") {
hasMissing = true;
break;
}
const value = getDimensionValue(item, def);
if (value === null || value === undefined || value === "") {
hasMissing = true;
break;
}
dims[key] = String(value);
}
if (hasMissing) {
continue;
}
let value = null;
if (isString(valueTemplate) && valueTemplate !== "") {
value = new Formatter(item).format(valueTemplate);
} else if (item?.value !== undefined) {
value = item.value;
} else if (item?.id !== undefined) {
value = item.id;
} else {
value = `${i}`;
}
variants.push({
value: String(value),
data: item,
dimensions: dims,
});
}
this[variantsSymbol] = variants;
buildDimensionValues.call(this, dimensions, variants);
applyInitialSelection.call(this, dimensions, variants);
render.call(this);
this[initGuardSymbol] = false;
}
/**
* @private
* @param {Array} dimensions
* @param {Array} variants
*/
function buildDimensionValues(dimensions, variants) {
this[dimensionValuesSymbol].clear();
for (const def of dimensions) {
const key = def?.key;
if (!isString(key) || key === "") {
continue;
}
const labelMap = new Map();
appendValuesFromDefinition(def, labelMap);
for (const variant of variants) {
const value = variant.dimensions[key];
if (!labelMap.has(String(value))) {
const label = getDimensionLabel(variant.data, def, value);
labelMap.set(value, label);
}
}
this[dimensionValuesSymbol].set(key, labelMap);
}
}
/**
* @private
* @param {Array} dimensions
* @param {Array} variants
*/
function applyInitialSelection(dimensions, variants) {
const selection = Object.assign({}, this.getOption("selection", {}));
const value = this.getOption("value");
if (isString(value) && value !== "") {
const match = variants.find((variant) => variant.value === value);
if (match) {
for (const def of dimensions) {
const key = def?.key;
if (key) {
selection[key] = match.dimensions[key];
}
}
}
}
this.setOption("selection", selection);
updateSelectedVariant.call(this);
}
/**
* @private
*/
function render() {
if (!(this[dimensionsElementSymbol] instanceof HTMLElement)) {
return;
}
const dimensions = this.getOption("dimensions", []);
const containerWidth = getContainerWidth.call(this);
const valuesMap = this[dimensionValuesSymbol];
const selection = this.getOption("selection", {});
this[dimensionsElementSymbol].innerHTML = "";
this[selectControlSymbol].clear();
this[layoutSymbol].clear();
this[combinedSelectSymbol] = null;
const useCombinedSelect = shouldUseCombinedSelect.call(
this,
dimensions,
valuesMap,
containerWidth,
);
if (useCombinedSelect) {
renderCombinedSelect.call(this, dimensions, valuesMap, selection);
updateUI.call(this);
updateMessage.call(this);
return;
}
for (const def of dimensions) {
const key = def?.key;
if (!isString(key) || key === "") {
continue;
}
const label = def?.label || key;
const valueMap = valuesMap.get(key) || new Map();
const row = document.createElement("div");
row.setAttribute("data-monster-role", "dimension");
row.setAttribute("data-variant-dimension", key);
row.setAttribute("part", "dimension");
const labelNode = document.createElement("div");
labelNode.setAttribute("data-monster-role", "dimension-label");
labelNode.textContent = label;
labelNode.setAttribute("part", "dimension-label");
const controls = document.createElement("div");
controls.setAttribute("data-monster-role", "dimension-controls");
controls.setAttribute("part", "dimension-controls");
const mode = resolvePresentationMode.call(
this,
def,
valueMap.size,
containerWidth,
);
this[layoutSymbol].set(key, mode);
if (mode === "select") {
const select = document.createElement(Select.getTag());
select.setAttribute("data-monster-options", "{}");
select.setAttribute("part", "dimension-select");
select.addEventListener("monster-change", (event) => {
const value = event?.detail?.value ?? "";
if (value === "") {
handleSelectionChange.call(this, key, null);
} else {
handleSelectionChange.call(this, key, value);
}
});
controls.appendChild(select);
this[selectControlSymbol].set(key, select);
configureSelect.call(this, select, valueMap, selection?.[key] ?? "");
} else {
const options = document.createElement("div");
options.setAttribute("data-monster-role", "options");
options.setAttribute("part", "dimension-options");
for (const [value, optionLabel] of valueMap.entries()) {
const button = document.createElement("button");
button.type = "button";
button.className = this.getOption("classes.option");
button.textContent = optionLabel;
button.setAttribute("data-variant-dimension", key);
button.setAttribute("data-variant-value", value);
button.setAttribute("part", "dimension-option");
options.appendChild(button);
}
controls.appendChild(options);
}
row.appendChild(labelNode);
row.appendChild(controls);
this[dimensionsElementSymbol].appendChild(row);
}
updateUI.call(this);
updateMessage.call(this);
}
/**
* @private
* @param {Object} def
* @param {number} count
* @param {number} width
* @return {string}
*/
function resolvePresentationMode(def, count, width) {
const presentation = def?.presentation;
if (presentation === "select" || presentation === "buttons") {
if (
presentation === "select" &&
this.getOption("features.rowSelects") !== true
) {
return "buttons";
}
return presentation;
}
if (this.getOption("features.rowSelects") !== true) {
return "buttons";
}
const auto = this.getOption("layout.autoSelect") !== false;
if (!auto) {
return "buttons";
}
const minWidth = Number(this.getOption("layout.buttonMinWidth", 84));
if (!Number.isFinite(minWidth) || minWidth <= 0) {
return "buttons";
}
return count * minWidth > width ? "select" : "buttons";
}
/**
* @private
* @return {number}
*/
function getContainerWidth() {
if (!(this[controlElementSymbol] instanceof HTMLElement)) {
return 320;
}
const width = this[controlElementSymbol].getBoundingClientRect().width;
if (!Number.isFinite(width) || width <= 0) {
return 320;
}
return width;
}
/**
* @private
* @param {Map} values
* @return {Array}
*/
function buildSelectOptions(values) {
const options = [];
for (const [value, label] of values.entries()) {
options.push({ label, value });
}
return options;
}
/**
* @private
* @param {string} key
* @param {string|null} value
*/
function handleSelectionChange(key, value) {
const selection = Object.assign({}, this.getOption("selection", {}));
const nextValue = value === null ? null : String(value);
if (selection[key] === nextValue) {
selection[key] = null;
} else {
selection[key] = nextValue;
}
normalizeSelection.call(this, selection, key);
this.setOption("selection", selection);
updateSelectedVariant.call(this);
updateUI.call(this);
emitSelectionEvents.call(this);
updateMessage.call(this);
}
/**
* @private
* @param {Object} selection
* @param {string|null} fixedKey
*/
function normalizeSelection(selection, fixedKey) {
const dimensions = this.getOption("dimensions", []);
const keys = dimensions
.map((def) => def?.key)
.filter((key) => isString(key) && key !== "");
if (keys.length === 0) {
return;
}
const fixedKeys =
isString(fixedKey) && selection[fixedKey] != null ? [fixedKey] : [];
const candidateKeys = keys.filter(
(key) => !fixedKeys.includes(key) && selection[key] != null,
);
const best = findBestSelection.call(
this,
selection,
fixedKeys,
candidateKeys,
);
for (const key of candidateKeys) {
if (!best.includes(key)) {
selection[key] = null;
}
}
}
/**
* @private
* @param {Object} selection
* @param {Array} fixedKeys
* @param {Array} candidateKeys
* @return {Array}
*/
function findBestSelection(selection, fixedKeys, candidateKeys) {
const total = 1 << candidateKeys.length;
let best = [];
for (let mask = 0; mask < total; mask += 1) {
const subset = [];
for (let i = 0; i < candidateKeys.length; i += 1) {
if (mask & (1 << i)) {
subset.push(candidateKeys[i]);
}
}
const testSelection = buildSelectionForKeys(selection, fixedKeys, subset);
if (hasVariantMatch.call(this, testSelection)) {
if (subset.length > best.length) {
best = subset;
}
}
}
return best;
}
/**
* @private
* @param {Object} selection
* @param {Array} fixedKeys
* @param {Array} subsetKeys
* @return {Object}
*/
function buildSelectionForKeys(selection, fixedKeys, subsetKeys) {
const result = {};
for (const key of fixedKeys) {
result[key] = selection[key];
}
for (const key of subsetKeys) {
result[key] = selection[key];
}
return result;
}
/**
* @private
* @param {Object} selection
* @return {boolean}
*/
function hasVariantMatch(selection) {
const variants = this[variantsSymbol] || [];
for (const variant of variants) {
if (matchesSelection(variant, selection)) {
return true;
}
}
return false;
}
/**
* @private
*/
function updateSelectedVariant() {
const selection = this.getOption("selection", {});
const dimensions = this.getOption("dimensions", []);
const keys = dimensions
.map((def) => def?.key)
.filter((key) => isString(key) && key !== "");
let complete = true;
for (const key of keys) {
if (!isString(selection?.[key]) || selection[key] === "") {
complete = false;
break;
}
}
if (!complete) {
this.value = null;
return;
}
const variants = this[variantsSymbol] || [];
const match = variants.find((variant) =>
matchesSelection(variant, selection),
);
this.value = match ? match.value : null;
}
/**
* @private
*/
function updateUI() {
const selection = this.getOption("selection", {});
const dimensions = this.getOption("dimensions", []);
const valuesMap = this[dimensionValuesSymbol];
const combined = this[combinedSelectSymbol];
if (combined instanceof Select && combined.shadowRoot) {
updateCombinedSelect.call(this);
return;
}
for (const def of dimensions) {
const key = def?.key;
if (!isString(key) || key === "") {
continue;
}
const available = getAvailableValues.call(this, key, selection);
const mode = this[layoutSymbol].get(key);
if (mode === "select") {
const select = this[selectControlSymbol].get(key);
if (select instanceof Select && select.shadowRoot) {
const allValues = valuesMap.get(key) || new Map();
const options = [];
for (const [value, label] of allValues.entries()) {
if (available.has(value)) {
options.push({ label, value });
}
}
select.setOption("options", options);
select.value = selection?.[key] ?? "";
}
continue;
}
const buttons = this.shadowRoot.querySelectorAll(
`button[data-variant-dimension=\"${key}\"]`,
);
for (const button of buttons) {
const value = button.getAttribute("data-variant-value");
const isSelected = selection?.[key] === value;
const isAvailable = available.has(value);
button.classList.toggle(
this.getOption("classes.optionSelected"),
isSelected,
);
button.classList.toggle(
this.getOption("classes.optionDisabled"),
!isAvailable,
);
button.setAttribute("aria-pressed", isSelected ? "true" : "false");
button.setAttribute("aria-disabled", isAvailable ? "false" : "true");
button.setAttribute(
"data-variant-disabled",
isAvailable ? "false" : "true",
);
}
}
}
/**
* @private
* @param {Select} select
* @param {Map} valueMap
* @param {string} selectedValue
*/
function configureSelect(select, valueMap, selectedValue) {
const init = () => {
if (!select.shadowRoot) {
return false;
}
select.setOption("filter.mode", "disabled");
select.setOption("features.lazyLoad", false);
select.setOption("options", buildSelectOptions(valueMap));
select.value = selectedValue;
return true;
};
if (init()) {
return;
}
let attempts = 0;
const retry = () => {
attempts += 1;
if (init() || attempts > 10) {
return;
}
requestAnimationFrame(retry);
};
requestAnimationFrame(retry);
}
/**
* @private
* @param {Array} dimensions
* @param {Map} valuesMap
* @param {number} containerWidth
* @return {boolean}
*/
function shouldUseCombinedSelect(dimensions, valuesMap, containerWidth) {
const combine =
this.getOption("layout.combineSelect") !== false &&
this.getOption("features.combinedSelect") === true;
if (!combine) {
return false;
}
for (const def of dimensions) {
const key = def?.key;
if (!isString(key) || key === "") {
continue;
}
const valueMap = valuesMap.get(key) || new Map();
const mode = resolvePresentationMode.call(
this,
def,
valueMap.size,
containerWidth,
);
if (mode !== "select") {
return false;
}
}
return true;
}
/**
* @private
* @param {Array} dimensions
* @param {Map} valuesMap
* @param {Object} selection
*/
function renderCombinedSelect(dimensions, valuesMap, selection) {
const select = document.createElement(Select.getTag());
select.setAttribute("data-monster-options", "{}");
select.setAttribute("part", "combined-select");
select.setAttribute("data-monster-role", "combined-select");
select.style.width = "100%";
select.addEventListener("monster-change", (event) => {
const value = event?.detail?.value ?? "";
if (value === "") {
this.setOption("selection", {});
this.value = null;
updateMessage.call(this);
emitSelectionEvents.call(this);
return;
}
applyVariantSelection.call(this, value);
});
this[dimensionsElementSymbol].appendChild(select);
this[combinedSelectSymbol] = select;
configureCombinedSelect.call(this, select, selection);
}
/**
* @private
* @param {Select} select
* @param {Object} selection
*/
function configureCombinedSelect(select, selection) {
const init = () => {
if (!select.shadowRoot) {
return false;
}
select.setOption("filter.mode", "options");
select.setOption("filter.position", "popper");
select.setOption("features.lazyLoad", false);
select.setOption("options", buildCombinedOptions.call(this));
select.value = this.getOption("value") ?? "";
return true;
};
if (init()) {
return;
}
let attempts = 0;
const retry = () => {
attempts += 1;
if (init() || attempts > 10) {
return;
}
requestAnimationFrame(retry);
};
requestAnimationFrame(retry);
}
/**
* @private
*/
function updateCombinedSelect() {
const select = this[combinedSelectSymbol];
if (!(select instanceof Select) || !select.shadowRoot) {
return;
}
select.setOption("options", buildCombinedOptions.call(this));
select.value = this.getOption("value") ?? "";
}
/**
* @private
* @return {Array}
*/
function buildCombinedOptions() {
const variants = this[variantsSymbol] || [];
const dimensions = this.getOption("dimensions", []);
const valuesMap = this[dimensionValuesSymbol];
const labelTemplate = this.getOption("mapping.labelTemplate", "");
const options = [];
for (const variant of variants) {
let label = "";
if (isString(labelTemplate) && labelTemplate !== "") {
label = new Formatter(variant.data).format(labelTemplate);
} else {
label = buildVariantLabel(variant, dimensions, valuesMap);
}
options.push({
label,
value: variant.value,
});
}
return options;
}
/**
* @private
* @param {Object} variant
* @param {Array} dimensions
* @param {Map} valuesMap
* @return {string}
*/
function buildVariantLabel(variant, dimensions, valuesMap) {
const parts = [];
for (const def of dimensions) {
const key = def?.key;
if (!isString(key) || key === "") {
continue;
}
const labelName = def?.label || key;
const value = variant.dimensions?.[key];
const labelMap = valuesMap.get(key) || new Map();
const labelValue = labelMap.get(String(value)) ?? value;
parts.push(`${labelName}: ${labelValue}`);
}
return parts.join(", ");
}
/**
* @private
* @param {string} value
*/
function applyVariantSelection(value) {
const variants = this[variantsSymbol] || [];
const match = variants.find((entry) => entry.value === value);
if (!match) {
this.setOption("selection", {});
this.value = null;
updateMessage.call(this);
emitSelectionEvents.call(this);
return;
}
this.setOption("selection", Object.assign({}, match.dimensions));
this.value = match.value;
updateMessage.call(this);
emitSelectionEvents.call(this);
}
/**
* @private
*/
function emitSelectionEvents() {
if (this[initGuardSymbol]) {
return;
}
const selection = this.getOption("selection", {});
const value = this.getOption("value");
const variants = this[variantsSymbol] || [];
const variant = variants.find((entry) => entry.value === value) || null;
const detail = { selection, value, variant };
const changeAction = this.getOption("actions.onchange");
if (isFunction(changeAction)) {
changeAction.call(this, detail);
}
fireCustomEvent(this, "monster-variant-select-change", detail);
const isValid = isString(value) && value !== "";
if (this[lastValiditySymbol] === isValid) {
return;
}
this[lastValiditySymbol] = isValid;
if (isValid) {
const action = this.getOption("actions.onvalid");
if (isFunction(action)) {
action.call(this, detail);
}
fireCustomEvent(this, "monster-variant-select-valid", detail);
return;
}
const action = this.getOption("actions.oninvalid");
if (isFunction(action)) {
action.call(this, detail);
}
fireCustomEvent(this, "monster-variant-select-invalid", detail);
}
/**
* @private
*/
function updateMessage() {
if (this.getOption("features.messages") === false) {
if (this[messageElementSymbol] instanceof HTMLElement) {
this[messageElementSymbol].textContent = "";
}
return;
}
if (!(this[messageElementSymbol] instanceof HTMLElement)) {
return;
}
const selection = this.getOption("selection", {});
const value = this.getOption("value");
const dimensions = this.getOption("dimensions", []);
const keys = dimensions
.map((def) => def?.key)
.filter((key) => isString(key) && key !== "");
let complete = true;
for (const key of keys) {
if (!isString(selection?.[key]) || selection[key] === "") {
complete = false;
break;
}
}
let message = "";
if (!complete) {
message = this.getOption("messages.incomplete");
} else if (!isString(value) || value === "") {
message = this.getOption("messages.invalid");
} else {
message = this.getOption("messages.valid");
}
this[messageElementSymbol].textContent = message || "";
}
/**
* @private
* @param {string} key
* @param {Object} selection
* @return {Set}
*/
function getAvailableValues(key, selection) {
const variants = this[variantsSymbol] || [];
const available = new Set();
for (const variant of variants) {
if (matchesSelection(variant, selection, key)) {
available.add(variant.dimensions[key]);
}
}
return available;
}
/**
* @private
* @param {Object} variant
* @param {Object} selection
* @param {string|null} ignoreKey
* @return {boolean}
*/
function matchesSelection(variant, selection, ignoreKey = null) {
for (const [key, value] of Object.entries(selection || {})) {
if (ignoreKey && key === ignoreKey) {
continue;
}
if (value === null || value === undefined || value === "") {
continue;
}
if (variant.dimensions[key] !== value) {
return false;
}
}
return true;
}
/**
* @private
* @param {Object} item
* @param {Object} def
* @return {string|null}
*/
function getDimensionValue(item, def) {
const template = def?.valueTemplate;
if (isString(template) && template !== "") {
return new Formatter(item).format(template);
}
const key = def?.key;
if (!isString(key) || key === "") {
return null;
}
try {
return new Pathfinder(item).getVia(key);
} catch (_e) {
return item?.[key];
}
}
/**
* @private
* @param {Object} item
* @param {Object} def
* @param {string} fallback
* @return {string}
*/
function getDimensionLabel(item, def, fallback) {
const template = def?.labelTemplate;
if (isString(template) && template !== "") {
return new Formatter(item).format(template);
}
return fallback;
}
/**
* @private
* @param {Object} def
* @param {Map} labelMap
*/
function appendValuesFromDefinition(def, labelMap) {
const values = def?.values;
if (!values) {
return;
}
if (Array.isArray(values)) {
for (const entry of values) {
const value = entry?.value;
if (!isString(value) || value === "") {
continue;
}
const label = entry?.label ?? value;
labelMap.set(String(value), String(label));
}
return;
}
if (isObject(values)) {
for (const [value, label] of Object.entries(values)) {
if (!isString(value) || value === "") {
continue;
}
labelMap.set(String(value), String(label ?? value));
}
}
}
/**
* @private
* @return {string}
*/
function getTemplate() {
// language=HTML
return `
<div data-monster-role="control" part="control">
<div data-monster-role="dimensions" part="dimensions"></div>
<div data-monster-role="message" part="message"></div>
</div>
`;
}
registerCustomElement(VariantSelect);
/**
* @private
* @returns {object}
*/
function getTranslations() {
const locale = getLocaleOfDocument();
switch (locale.language) {
case "de":
return {
valid: "Auswahl ist gueltig.",
invalid: "Diese Variantenkombination existiert nicht.",
incomplete: "Bitte alle Varianten auswaehlen.",
};
case "es":
return {
valid: "La seleccion es valida.",
invalid: "Esta combinacion no existe.",
incomplete: "Seleccione todas las opciones.",
};
case "zh":
return {
valid: "选择有效。",
invalid: "该组合不存在。",
incomplete: "请完成所有选项。",
};
case "hi":
return {
valid: "चयन मान्य है।",
invalid: "यह संयोजन उपलब्ध नहीं है।",
incomplete: "कृपया सभी विकल्प चुनें।",
};
case "bn":
return {
valid: "নির্বাচন বৈধ।",
invalid: "এই সংযোজনটি নেই।",
incomplete: "অনুগ্রহ করে সব বিকল্প নির্বাচন করুন।",
};
case "pt":
return {
valid: "Selecao valida.",
invalid: "Esta combinacao nao existe.",
incomplete: "Selecione todas as opcoes.",
};
case "ru":
return {
valid: "Выбор корректен.",
invalid: "Такой комбинации нет.",
incomplete: "Выберите все опции.",
};
case "ja":
return {
valid: "選択は有効です。",
invalid: "この組み合わせは存在しません。",
incomplete: "すべての項目を選択してください。",
};
case "pa":
return {
valid: "ਚੋਣ ਠੀਕ ਹੈ।",
invalid: "ਇਹ ਸੰਯੋਜਨ ਉਪਲਬਧ ਨਹੀਂ ਹੈ।",
incomplete: "ਕਿਰਪਾ ਕਰਕੇ ਸਾਰੇ ਵਿਕਲਪ ਚੁਣੋ।",
};
case "mr":
return {
valid: "निवड वैध आहे.",
invalid: "हा संयोजन उपलब्ध नाही.",
incomplete: "कृपया सर्व पर्याय निवडा.",
};
case "fr":
return {
valid: "Selection valide.",
invalid: "Cette combinaison n'existe pas.",
incomplete: "Veuillez choisir toutes les options.",
};
case "it":
return {
valid: "Selezione valida.",
invalid: "Questa combinazione non esiste.",
incomplete: "Seleziona tutte le opzioni.",
};
case "nl":
return {
valid: "Selectie is geldig.",
invalid: "Deze combinatie bestaat niet.",
incomplete: "Selecteer alle opties.",
};
case "sv":
return {
valid: "Valet ar giltigt.",
invalid: "Denna kombination finns inte.",
incomplete: "Valj alla alternativ.",
};
case "pl":
return {
valid: "Wybor jest poprawny.",
invalid: "Ta kombinacja nie istnieje.",
incomplete: "Wybierz wszystkie opcje.",
};
case "da":
return {
valid: "Valget er gyldigt.",
invalid: "Denne kombination findes ikke.",
incomplete: "Vaelg alle muligheder.",
};
case "fi":
return {
valid: "Valinta on kelvollinen.",
invalid: "Tallaista yhdistelmaa ei ole.",
incomplete: "Valitse kaikki vaihtoehdot.",
};
case "no":
return {
valid: "Valget er gyldig.",
invalid: "Denne kombinasjonen finnes ikke.",
incomplete: "Velg alle alternativer.",
};
case "cs":
return {
valid: "Vyber je platny.",
invalid: "Tato kombinace neexistuje.",
incomplete: "Vyberte vsechny moznosti.",
};
default:
return {
valid: "Selection is valid.",
invalid: "This combination does not exist.",
incomplete: "Please select all options.",
};
}
}