Files
Foundry-VTT-Docker/resources/app/client-esm/applications/elements/multi-select.mjs
2025-01-04 00:34:03 +01:00

362 lines
10 KiB
JavaScript

import AbstractFormInputElement from "./form-element.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
/**
* An abstract base class designed to standardize the behavior for a multi-select UI component.
* Multi-select components return an array of values as part of form submission.
* Different implementations may provide different experiences around how inputs are presented to the user.
* @extends {AbstractFormInputElement<Set<string>>}
*/
export class AbstractMultiSelectElement extends AbstractFormInputElement {
constructor() {
super();
this._value = new Set();
this._initialize();
}
/**
* Predefined <option> and <optgroup> elements which were defined in the original HTML.
* @type {(HTMLOptionElement|HTMLOptGroupElement)[]}
* @protected
*/
_options;
/**
* An object which maps option values to displayed labels.
* @type {Record<string, string>}
* @protected
*/
_choices = {};
/* -------------------------------------------- */
/**
* Preserve existing <option> and <optgroup> elements which are defined in the original HTML.
* @protected
*/
_initialize() {
this._options = [...this.children];
for ( const option of this.querySelectorAll("option") ) {
if ( !option.value ) continue; // Skip predefined options which are already blank
this._choices[option.value] = option.innerText;
if ( option.selected ) {
this._value.add(option.value);
option.selected = false;
}
}
}
/* -------------------------------------------- */
/**
* Mark a choice as selected.
* @param {string} value The value to add to the chosen set
*/
select(value) {
const exists = this._value.has(value);
if ( !exists ) {
if ( !(value in this._choices) ) {
throw new Error(`"${value}" is not an option allowed by this multi-select element`);
}
this._value.add(value);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
}
/* -------------------------------------------- */
/**
* Mark a choice as un-selected.
* @param {string} value The value to delete from the chosen set
*/
unselect(value) {
const exists = this._value.has(value);
if ( exists ) {
this._value.delete(value);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
return Array.from(this._value);
}
/** @override */
_setValue(value) {
if ( !Array.isArray(value) ) {
throw new Error("The value assigned to a multi-select element must be an array.");
}
if ( value.some(v => !(v in this._choices)) ) {
throw new Error("The values assigned to a multi-select element must all be valid options.");
}
this._value.clear();
for ( const v of value ) this._value.add(v);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow using a select element as the input mechanism.
*
* @example Multi-Select HTML Markup
* ```html
* <multi-select name="select-many-things">
* <optgroup label="Basic Options">
* <option value="foo">Foo</option>
* <option value="bar">Bar</option>
* <option value="baz">Baz</option>
* </optgroup>
* <optgroup label="Advanced Options">
* <option value="fizz">Fizz</option>
* <option value="buzz">Buzz</option>
* </optgroup>
* </multi-select>
* ```
*/
export class HTMLMultiSelectElement extends AbstractMultiSelectElement {
/** @override */
static tagName = "multi-select";
/**
* A select element used to choose options.
* @type {HTMLSelectElement}
*/
#select;
/**
* A display element which lists the chosen options.
* @type {HTMLDivElement}
*/
#tags;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create select element
this.#select = this._primaryInput = document.createElement("select");
this.#select.insertAdjacentHTML("afterbegin", '<option value=""></option>');
this.#select.append(...this._options);
this.#select.disabled = !this.editable;
// Create a div element for display
this.#tags = document.createElement("div");
this.#tags.className = "tags input-element-tags";
return [this.#tags, this.#select];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
// Update the displayed tags
const tags = Array.from(this._value).map(id => {
return HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable);
});
this.#tags.replaceChildren(...tags);
// Disable selected options
for ( const option of this.#select.querySelectorAll("option") ) {
option.disabled = this._value.has(option.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#select.addEventListener("change", this.#onChangeSelect.bind(this));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
}
/* -------------------------------------------- */
/**
* Handle changes to the Select input, marking the selected option as a chosen value.
* @param {Event} event The change event on the select element
*/
#onChangeSelect(event) {
event.preventDefault();
event.stopImmediatePropagation();
const select = event.currentTarget;
if ( !select.value ) return; // Ignore selection of the blank value
this.select(select.value);
select.value = "";
}
/* -------------------------------------------- */
/**
* Handle click events on a tagged value, removing it from the chosen set.
* @param {PointerEvent} event The originating click event on a chosen tag
*/
#onClickTag(event) {
event.preventDefault();
if ( !event.target.classList.contains("remove") ) return;
if ( !this.editable ) return;
const tag = event.target.closest(".tag");
this.unselect(tag.dataset.key);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#select.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLMultiSelectElement using provided configuration data.
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
* @returns {HTMLMultiSelectElement}
*/
static create(config) {
return foundry.applications.fields.createMultiSelectInput(config);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow as a grid of input checkbox elements.
*
* @example Multi-Checkbox HTML Markup
* ```html
* <multi-checkbox name="check-many-boxes">
* <optgroup label="Basic Options">
* <option value="foo">Foo</option>
* <option value="bar">Bar</option>
* <option value="baz">Baz</option>
* </optgroup>
* <optgroup label="Advanced Options">
* <option value="fizz">Fizz</option>
* <option value="buzz">Buzz</option>
* </optgroup>
* </multi-checkbox>
* ```
*/
export class HTMLMultiCheckboxElement extends AbstractMultiSelectElement {
/** @override */
static tagName = "multi-checkbox";
/**
* The checkbox elements used to select inputs
* @type {HTMLInputElement[]}
*/
#checkboxes;
/* -------------------------------------------- */
/** @override */
_buildElements() {
this.#checkboxes = [];
const children = [];
for ( const option of this._options ) {
if ( option instanceof HTMLOptGroupElement ) children.push(this.#buildGroup(option));
else children.push(this.#buildOption(option));
}
return children;
}
/* -------------------------------------------- */
/**
* Translate an input <optgroup> element into a <fieldset> of checkboxes.
* @param {HTMLOptGroupElement} optgroup The originally configured optgroup
* @returns {HTMLFieldSetElement} The created fieldset grouping
*/
#buildGroup(optgroup) {
// Create fieldset group
const group = document.createElement("fieldset");
group.classList.add("checkbox-group");
const legend = document.createElement("legend");
legend.innerText = optgroup.label;
group.append(legend);
// Add child options
for ( const option of optgroup.children ) {
if ( option instanceof HTMLOptionElement ) {
group.append(this.#buildOption(option));
}
}
return group;
}
/* -------------------------------------------- */
/**
* Build an input <option> element into a <label class="checkbox"> element.
* @param {HTMLOptionElement} option The originally configured option
* @returns {HTMLLabelElement} The created labeled checkbox element
*/
#buildOption(option) {
const label = document.createElement("label");
label.classList.add("checkbox");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = option.value;
checkbox.checked = this._value.has(option.value);
checkbox.disabled = this.disabled;
label.append(checkbox, option.innerText);
this.#checkboxes.push(checkbox);
return label;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
for ( const checkbox of this.#checkboxes ) {
checkbox.checked = this._value.has(checkbox.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
for ( const checkbox of this.#checkboxes ) {
checkbox.addEventListener("change", this.#onChangeCheckbox.bind(this));
}
}
/* -------------------------------------------- */
/**
* Handle changes to a checkbox input, marking the selected option as a chosen value.
* @param {Event} event The change event on the checkbox input element
*/
#onChangeCheckbox(event) {
event.preventDefault();
event.stopImmediatePropagation();
const checkbox = event.currentTarget;
if ( checkbox.checked ) this.select(checkbox.value);
else this.unselect(checkbox.value);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
for ( const checkbox of this.#checkboxes ) {
checkbox.disabled = disabled;
}
}
}