UNPKG

@schukai/monster

Version:

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

370 lines (332 loc) 8.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 { CustomControl } from "../../dom/customcontrol.mjs"; import { Observer } from "../../types/observer.mjs"; import { clone } from "../../util/clone.mjs"; import { isArray, isObject, isString, isNumber } from "../../types/is.mjs"; import { assembleMethodSymbol, registerCustomElement, attributeObserverSymbol, } from "../../dom/customelement.mjs"; import { fireCustomEvent, fireEvent, findTargetElementFromEvent, } from "../../dom/events.mjs"; import { ChecklistStyleSheet } from "./stylesheet/checklist.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE } from "../../dom/constants.mjs"; export { Checklist }; /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * Checklist control for displaying and tracking a list of items. * * @summary A checklist control with change events and form integration. */ class Checklist extends CustomControl { /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * * @property {Array} items The checklist items. * @property {boolean} disabled Disabled state. * @property {Object} templates Template definitions. * @property {string} templates.main Main template. */ get defaults() { return Object.assign({}, super.defaults, { items: [], disabled: false, templates: { main: getTemplate(), }, }); } /** * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); initAttributeObservers.call(this); normalizeItemsOption.call(this); syncFormValue.call(this); this.attachObserver( new Observer(() => { syncFormValue.call(this); }), ); } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [ChecklistStyleSheet]; } /** * @return {string} */ static getTag() { return "monster-checklist"; } /** * @returns {Array} */ get value() { return getCheckedIds.call(this); } /** * @param {Array} value */ set value(value) { if (!isArray(value)) { value = []; } setCheckedIds.call(this, value); } /** * @return {Array} */ getItems() { return clone(this.getOption("items", [])); } /** * @return {Array} */ getCheckedItems() { return this.getItems().filter((item) => Boolean(item?.checked)); } /** * @param {Array} items * @return {Checklist} */ setItems(items) { const normalized = normalizeItems(items); this.setOption("items", normalized.items); syncFormValue.call(this); return this; } } /** * @private */ function initControlReferences() { this[controlElementSymbol] = this.shadowRoot?.querySelector( "[data-monster-role=control]", ); } /** * @private */ function initEventHandler() { this.addEventListener("change", (event) => { const checkbox = findTargetElementFromEvent( event, "data-monster-role", "checkbox", ); if (!(checkbox instanceof HTMLInputElement)) { return; } if (this.getOption("disabled") === true) { event.preventDefault(); return; } const itemId = checkbox.getAttribute("data-item-id"); if (!itemId) { return; } const items = this.getOption("items", []); const updated = clone(items).map((item) => { if (item?.id === itemId) { return Object.assign({}, item, { checked: checkbox.checked }); } return item; }); this.setOption("items", updated); syncFormValue.call(this); fireEvent(this, "change"); fireCustomEvent(this, "monster-checklist-change", { id: itemId, checked: checkbox.checked, item: updated.find((item) => item?.id === itemId), items: clone(updated), value: getCheckedIds.call(this), }); }); } /** * @private */ function normalizeItemsOption() { if (applyItemsAttribute.call(this)) { return; } const normalized = normalizeItems(this.getOption("items", [])); if (normalized.changed) { this.setOption("items", normalized.items); } } /** * @private * @param {Array} items * @return {{items: Array, changed: boolean}} */ function normalizeItems(items) { const normalized = []; let changed = false; if (!isArray(items)) { return { items: [], changed: true }; } for (const [index, entry] of Object.entries(items)) { const idx = Number(index); let item = entry; if (isString(item) || isNumber(item)) { item = { id: `item-${idx}`, label: String(item), checked: false, disabled: false, }; changed = true; } else if (isObject(item)) { item = Object.assign({}, item, { id: item.id ?? `item-${idx}`, label: item.label ?? "", checked: Boolean(item.checked), disabled: Boolean(item.disabled), }); if (item.id !== entry.id || item.label !== entry.label) { changed = true; } } else { item = { id: `item-${idx}`, label: "", checked: false, disabled: false, }; changed = true; } normalized.push(item); } return { items: normalized, changed }; } /** * @private * @return {boolean} */ function applyItemsAttribute() { if (!this.hasAttribute("data-monster-option-items")) { return false; } const raw = this.getAttribute("data-monster-option-items"); if (!raw) { this.setOption("items", []); return true; } try { const parsed = JSON.parse(raw); if (!isArray(parsed)) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, "data-monster-option-items must be a JSON array", ); return true; } const normalized = normalizeItems(parsed); this.setOption("items", normalized.items); return true; } catch (error) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, error?.message || String(error), ); return true; } } /** * @private */ function initAttributeObservers() { this[attributeObserverSymbol]["data-monster-option-items"] = () => { applyItemsAttribute.call(this); syncFormValue.call(this); }; } /** * @private * @return {Array} */ function getCheckedIds() { const items = this.getOption("items", []); return items.filter((item) => item?.checked).map((item) => item.id); } /** * @private * @param {Array} ids */ function setCheckedIds(ids) { const items = this.getOption("items", []); const updated = clone(items).map((item) => Object.assign({}, item, { checked: ids.includes(item?.id) }), ); this.setOption("items", updated); syncFormValue.call(this); } /** * @private */ function syncFormValue() { const value = JSON.stringify(getCheckedIds.call(this)); if (typeof this.setFormValue === "function") { this.setFormValue(value); } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="item"> <label data-monster-role="item" part="item" data-monster-attributes="data-item-id path:item.id, class path:item.disabled | ?:disabled"> <input type="checkbox" data-monster-role="checkbox" part="checkbox" data-monster-attributes="data-item-id path:item.id, checked path:item.checked | ?:checked, disabled path:item.disabled | ?:disabled"> <span data-monster-role="label" part="label" data-monster-replace="path:item.label | default: "></span> </label> </template> <div data-monster-role="control" part="control"> <div data-monster-role="items" part="items" data-monster-insert="item path:items"></div> </div> `; } registerCustomElement(Checklist);