@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
370 lines (332 loc) • 8.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 { 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);