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