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>} */ export class AbstractMultiSelectElement extends AbstractFormInputElement { constructor() { super(); this._value = new Set(); this._initialize(); } /** * Predefined elements which were defined in the original HTML. * @type {(HTMLOptionElement|HTMLOptGroupElement)[]} * @protected */ _options; /** * An object which maps option values to displayed labels. * @type {Record} * @protected */ _choices = {}; /* -------------------------------------------- */ /** * Preserve existing 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 * * * * * * * * * * * * ``` */ 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", ''); 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 & Omit} 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 * * * * * * * * * * * * ``` */ 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 element into a
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