UNPKG

@schukai/monster

Version:

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

526 lines (470 loc) 13.2 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 { CustomControl } from "../../dom/customcontrol.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { fireCustomEvent, fireEvent } from "../../dom/events.mjs"; import { isArray, isObject, isString, isNumber } from "../../types/is.mjs"; import { Observer } from "../../types/observer.mjs"; import { clone } from "../../util/clone.mjs"; import { ChoiceCardsStyleSheet } from "./stylesheet/choice-cards.mjs"; export { ChoiceCards }; /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * @private * @type {symbol} */ const itemsElementSymbol = Symbol("itemsElement"); /** * A card-based single choice control. * * @fragments /fragments/components/form/choice-cards * * @example /examples/components/form/choice-cards-simple Simple card choices * * @since 4.137.0 * @copyright Volker Schukai * @summary A visual radio-card picker for choosing one option from a compact set. * @fires monster-selected * @fires monster-change * @fires monster-changed */ class ChoiceCards extends CustomControl { /** * 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|string|number>} items Choice items. * @property {string} items[].contentSlot Slot name for custom card content. * @property {string|null} value Current selected value. * @property {boolean} disabled Disabled state. * @property {Object} labels Accessible labels. * @property {string} labels.group Radio group label. * @property {Object} templates Template definitions. * @property {string} templates.main Main template. */ get defaults() { return Object.assign({}, super.defaults, { items: [], value: null, disabled: false, eventProcessing: false, labels: { group: "Choices", }, templates: { main: getTemplate(), }, }); } /** * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initItems.call(this); initEventHandler.call(this); render.call(this); syncFormValue.call(this); this.attachObserver( new Observer(() => { render.call(this); syncFormValue.call(this); }), ); } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [ChoiceCardsStyleSheet]; } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/form/choice-cards@@instance", ); } /** * @return {string} */ static getTag() { return "monster-choice-cards"; } /** * @return {string} */ get value() { return this.getOption("value"); } /** * @param {string|null|undefined} value */ set value(value) { const normalized = normalizeValue(value); if (this.getOption("value") !== normalized) { this.setOption("value", normalized); } syncValueAttribute.call(this, normalized); render.call(this); syncFormValue.call(this); } /** * @return {Array} */ getItems() { return clone(this.getOption("items", [])); } /** * @param {Array} items * @return {ChoiceCards} */ setItems(items) { this.setOption("items", normalizeItems(items)); render.call(this); return this; } /** * Select a choice by value. * * @param {string|null|undefined} value * @return {ChoiceCards} */ select(value) { if (this.getOption("disabled") === true || this.hasAttribute("disabled")) { return this; } const normalized = normalizeValue(value); const item = findItem.call(this, normalized); if (item?.disabled === true) { return this; } if (this.value === normalized) { return this; } this.value = normalized; const detail = { value: this.value, item: item || null, }; fireEvent(this, "change"); fireCustomEvent(this, "monster-selected", detail); fireCustomEvent(this, "monster-change", detail); fireCustomEvent(this, "monster-changed", detail); return this; } } /** * @private * @return {void} */ function initControlReferences() { this[controlElementSymbol] = this.shadowRoot?.querySelector( "[data-monster-role=control]", ); this[itemsElementSymbol] = this.shadowRoot?.querySelector( "[data-monster-role=items]", ); } /** * @private * @return {void} */ function initItems() { if (this.hasAttribute("value")) { this.setOption("value", normalizeValue(this.getAttribute("value"))); } const normalized = normalizeItems(this.getOption("items", [])); this.setOption("items", normalized); } /** * @private * @return {void} */ function initEventHandler() { this.addEventListener("click", (event) => { const button = findChoiceButton(event); if (!button) { return; } this.select(button.getAttribute("data-choice-value")); }); this.addEventListener("keydown", (event) => { if (this.getOption("disabled") === true || this.hasAttribute("disabled")) { return; } const buttons = getEnabledButtons.call(this); if (buttons.length === 0) { return; } const current = this.shadowRoot?.activeElement; let index = buttons.indexOf(current); if (index < 0) { index = Math.max( 0, buttons.findIndex( (button) => button.getAttribute("aria-checked") === "true", ), ); } if (event.key === " " || event.key === "Enter") { event.preventDefault(); this.select(buttons[index].getAttribute("data-choice-value")); return; } if (event.key === "Home") { event.preventDefault(); focusAndSelect.call(this, buttons[0]); return; } if (event.key === "End") { event.preventDefault(); focusAndSelect.call(this, buttons[buttons.length - 1]); return; } if (["ArrowRight", "ArrowDown"].includes(event.key)) { event.preventDefault(); focusAndSelect.call(this, buttons[(index + 1) % buttons.length]); return; } if (["ArrowLeft", "ArrowUp"].includes(event.key)) { event.preventDefault(); focusAndSelect.call( this, buttons[(index - 1 + buttons.length) % buttons.length], ); } }); } /** * @private * @param {HTMLButtonElement} button * @return {void} */ function focusAndSelect(button) { button.focus(); this.select(button.getAttribute("data-choice-value")); } /** * @private * @param {Event} event * @return {HTMLButtonElement|null} */ function findChoiceButton(event) { for (const node of event.composedPath()) { if ( node instanceof HTMLButtonElement && node.getAttribute("data-monster-role") === "choice" ) { return node; } } return null; } /** * @private * @return {HTMLButtonElement[]} */ function getEnabledButtons() { return Array.from( this.shadowRoot?.querySelectorAll('[data-monster-role="choice"]') || [], ).filter((button) => button.disabled !== true); } /** * @private * @return {void} */ function render() { if (!this[itemsElementSymbol]) { return; } const items = normalizeItems(this.getOption("items", [])); const value = normalizeValue(this.getOption("value")); const disabled = this.getOption("disabled") === true || this.hasAttribute("disabled"); const hasSelectedItem = items.some((item) => item.value === value); const firstEnabledIndex = items.findIndex((item) => item.disabled !== true); this[itemsElementSymbol].replaceChildren(); this[controlElementSymbol]?.setAttribute( "aria-label", this.getOption("labels.group", "Choices"), ); for (const [index, item] of items.entries()) { const selected = value !== null && item.value === value; const tabbable = selected || (hasSelectedItem === false && index === firstEnabledIndex); const button = document.createElement("button"); button.type = "button"; button.setAttribute("data-monster-role", "choice"); button.setAttribute("data-choice-value", item.value); button.setAttribute("part", "choice"); button.setAttribute("role", "radio"); button.setAttribute("aria-label", item.label); button.setAttribute("aria-checked", selected ? "true" : "false"); button.tabIndex = tabbable ? 0 : -1; button.disabled = disabled || item.disabled === true; button.classList.toggle("selected", selected); button.classList.toggle("custom-content", !!item.contentSlot); if (item.id) { button.setAttribute("data-choice-id", item.id); } const indicator = document.createElement("span"); indicator.setAttribute("data-monster-role", "indicator"); indicator.setAttribute("part", "indicator"); button.appendChild(indicator); if (item.contentSlot) { const content = document.createElement("span"); content.setAttribute("data-monster-role", "content"); content.setAttribute("part", "content"); const slot = document.createElement("slot"); slot.name = item.contentSlot; content.appendChild(slot); button.appendChild(content); } else { const visual = document.createElement("span"); visual.setAttribute("data-monster-role", "visual"); visual.setAttribute("part", "visual"); if (item.iconSlot) { const slot = document.createElement("slot"); slot.name = item.iconSlot; visual.appendChild(slot); } else if (item.icon) { visual.setAttribute("data-choice-icon", item.icon); } else { visual.classList.add("empty"); } button.appendChild(visual); const label = document.createElement("span"); label.setAttribute("data-monster-role", "label"); label.setAttribute("part", "label"); label.textContent = item.label; button.appendChild(label); } this[itemsElementSymbol].appendChild(button); } } /** * @private * @param {string|null} value * @return {object|null} */ function findItem(value) { return normalizeItems(this.getOption("items", [])).find( (item) => item.value === value, ); } /** * @private * @param {*} value * @return {string|null} */ function normalizeValue(value) { if (value === undefined || value === null || value === "") { return null; } return String(value); } /** * @private * @param {*} items * @return {Array} */ function normalizeItems(items) { if (!isArray(items)) { return []; } return items.map((entry, index) => { if (isString(entry) || isNumber(entry)) { return { id: `item-${index}`, value: String(entry), label: String(entry), icon: "", iconSlot: "", contentSlot: "", disabled: false, }; } if (!isObject(entry)) { return { id: `item-${index}`, value: `item-${index}`, label: "", icon: "", iconSlot: "", contentSlot: "", disabled: false, }; } const value = normalizeValue(entry.value ?? entry.id ?? `item-${index}`); return { id: String(entry.id ?? value ?? `item-${index}`), value: value ?? `item-${index}`, label: String(entry.label ?? value ?? ""), icon: String(entry.icon ?? ""), iconSlot: String(entry.iconSlot ?? ""), contentSlot: String(entry.contentSlot ?? entry.slot ?? ""), disabled: entry.disabled === true, }; }); } /** * @private * @return {void} */ function syncFormValue() { if (typeof this.setFormValue === "function") { this.setFormValue(this.value ?? ""); } } /** * @private * @param {string|null} value * @return {void} */ function syncValueAttribute(value) { if (value === null) { if (this.hasAttribute("value")) { this.removeAttribute("value"); } return; } if (this.getAttribute("value") !== value) { this.setAttribute("value", value); } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control" role="radiogroup"> <div data-monster-role="items" part="items"></div> </div> `; } registerCustomElement(ChoiceCards);