UNPKG

@schukai/monster

Version:

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

1,495 lines (1,356 loc) 37 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } 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.", }; } }