Files
l5rx-chiaroscuro/system/scripts/misc/l5r5e-multiselect.js

426 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DropdownMixin } from "./l5r5e-dropdown-mixin.js";
const { AbstractMultiSelectElement } = foundry.applications.elements;
/**
* A custom `<l5r5e-multi-select>` form element providing Select2-style chip multi-selection.
*
* Stores **multiple string values** from a fixed option list, shown as removable chips inside
* the input box. A live-search input filters the dropdown as the user types. Use this when a
* field holds an unordered collection of values (e.g. a set of skills, tags, or abilities).
* For storing a single value — predefined or free-text — use {@link L5R5eHtmlComboBoxElement} instead.
*
* The element's `value` getter returns a comma-separated string (e.g. `"fire,water"`),
* which is what `FormData` will read on submission. `_getValue()` returns a plain Array,
* which is what `FormDataExtended` will use.
*
* Pre-selection on render is handled via the `value` attribute on the element — NOT via
* `{{selectOptions selected=...}}`, which cannot handle comma-separated strings. Use
* `{{selectOptions}}` without `selected` purely to render the available options, and let
* the `value` attribute drive pre-selection. Since `getAttribute()` always returns a string,
* passing a `Set` or `Array` via Handlebars will not work correctly — always pass a
* comma-separated string to `value=`.
*
* Prefer {@link L5r5eSetField} + `{{formGroup}}` when wiring this into a DataModel — the
* field handles the full round-trip automatically.
*
* @example
* ```hbs
* {{!-- Use value= (comma-separated string) for pre-selection, not selectOptions selected= --}}
* <l5r5e-multi-select name="elements" value="{{data.elements}}">
* {{selectOptions choices localize=true}}
* </l5r5e-multi-select>
* ```
*
* @example
* // Static factory — use only when building outside of Foundry's field/template system:
* const el = L5r5eHtmlMultiSelectElement.create({
* name: "elements",
* options: [{ value: "fire", label: "Fire" }, { value: "water", label: "Water" }],
* value: "fire,water", // comma-separated pre-selection
* });
* form.appendChild(el);
*
* // Reading the value back:
* el.value; // "fire,water" — comma-separated string, compatible with FormData
* el._getValue(); // ["fire","water"] — array, compatible with FormDataExtended
*/
export class L5r5eHtmlMultiSelectElement extends DropdownMixin(
AbstractMultiSelectElement,
{ multiSelect: true, debounceMs: 150 }
) {
/** @override */
static tagName = "l5r5e-multi-select";
/** @type {HTMLDivElement} — outer box containing chips, input, clear button */
#selectionBox;
/** @type {HTMLDivElement} — chips are injected here */
#chipList;
/** @type {HTMLSpanElement} — auto-sizing wrapper around the search input */
#inputSizer;
/** @type {HTMLButtonElement} — trailing clear-all button */
#clearButton;
/** @type {Set<string>} */
#disabledValues = new Set();
/** @type {Map<string, string>} */
#tooltips = new Map();
/**
* Returns a comma-separated string
* FormData reads this via field.value.
* @override
*/
get value() {
return Array.from(this._value).join(",");
}
/** @override */
set value(val) {
this._value.clear();
const values = Array.isArray(val) ? val : String(val).split(",").filter(Boolean);
for (const v of values) {
this._value.add(v);
}
this._internals.setFormValue(this.value);
this._refresh();
}
/**
* Return an array so FormDataExtended.object[name] matches Foundry's own
* HTMLMultiSelectElement — both field.value (string) and .object (array) are correct.
* @override
* @protected
*/
_getValue() {
return Array.from(this._value);
}
/**
* Accept either an array or comma-separated string when Foundry calls _setValue().
* @override
* @protected
*/
_setValue(val) {
const values = Array.isArray(val) ? val : String(val).split(",").filter(Boolean);
if (values.some(v => 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 values) {
this._value.add(v);
}
}
/** @override */
_initialize() {
super._initialize(); // fills this._choices, this._value, this._options
for (const option of this.querySelectorAll("option")) {
if (option.disabled)
this.#disabledValues.add(option.value);
if (option.title)
this.#tooltips.set(option.value, option.title);
}
if (this.hasAttribute("value")) {
this._setValue(this.getAttribute("value"));
}
}
/* -------------------------------------------- */
/* Element Lifecycle */
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Ask mixin to build <input> + <ul>, then re-home them into our structure.
const mixinWrapper = this._buildDropdownElements({
placeholder: this.getAttribute("placeholder")
?? game.i18n.localize("l5r5e.multiselect.placeholder"),
});
const searchInput = mixinWrapper.querySelector("input.input");
const dropdownList = mixinWrapper.querySelector("ul.dropdown");
// Selection box
this.#selectionBox = document.createElement("div");
this.#selectionBox.classList.add("selection-box");
// Chip list
this.#chipList = document.createElement("div");
this.#chipList.classList.add("chip-list");
// Auto-sizing sizer — CSS grid trick: ::after mirrors data-value, input shares the cell
this.#inputSizer = document.createElement("span");
this.#inputSizer.classList.add("input-sizer");
this.#inputSizer.dataset.value = "";
this.#inputSizer.append(searchInput);
// Clear-all button
this.#clearButton = document.createElement("button");
this.#clearButton.type = "button";
this.#clearButton.classList.add("clear-btn");
this.#clearButton.setAttribute("aria-label",
game.i18n.localize("l5r5e.multiselect.clear_all") ?? "Clear all");
this.#clearButton.textContent = "×";
this.#clearButton.hidden = true;
this.#selectionBox.append(this.#chipList, this.#inputSizer, this.#clearButton);
// Container: selection box + dropdown must share the same positioned ancestor.
const container = document.createElement("div");
container.classList.add("multi-select-container");
container.append(this.#selectionBox, dropdownList);
this._primaryInput = searchInput;
return [container];
}
/** @override */
_activateListeners() {
this._activateDropdownListeners();
const signal = this.abortSignal;
this.#selectionBox.addEventListener("mousedown", this.#onBoxMouseDown.bind(this), { signal });
this.#chipList.addEventListener("click", this.#onChipClick.bind(this), { signal });
this.#clearButton.addEventListener("click", this.#onClearAll.bind(this), { signal });
// stop the clear button from opening the selection box when pressing it
this.#clearButton.addEventListener("mousedown", (event) => {event.preventDefault(); event.stopPropagation();}, {signal});
this._dropdownInput.addEventListener("input", () => this.#updateInputSizer(), { signal });
}
/** @override */
_toggleDisabled(disabled) {
this._toggleDropdownDisabled(disabled);
if (this.#selectionBox) {
this.#selectionBox.classList.toggle("disabled", disabled);
}
if (this.#chipList) {
this._refresh(); // re-render chips so × appears/disappears
}
}
/** @override */
_refresh() {
const values = Array.from(this._value);
this._internals.setFormValue(values.length ? values.join(",") : "");
this.#renderChips(values);
// Clear button: only visible when editable and something is selected.
if (this.#clearButton) {
this.#clearButton.hidden = (!this.editable || values.length === 0);
}
this.#updateInputSizer();
this._dropdownRefresh();
}
/** @param {string[]} values */
#renderChips(values) {
if (!this.#chipList){
return
}
this.#chipList.replaceChildren(...values.map(id => this.#buildChip(id)));
}
/** @param {string} id */
#buildChip(id) {
const chip = document.createElement("span");
chip.classList.add("chip");
chip.dataset.key = id;
const label = document.createElement("span");
label.classList.add("chip-label");
label.textContent = this._choices[id] ?? id;
chip.append(label);
// Only add × when the element is editable (not disabled, not readonly).
if (this.editable) {
const remove = document.createElement("span");
remove.classList.add("chip-remove");
remove.setAttribute("aria-label", `Remove ${this._choices[id] ?? id}`);
remove.setAttribute("aria-hidden", "true");
remove.textContent = "×";
chip.append(remove);
}
return chip;
}
/** Mirror typed text into the sizer span so CSS sizes the input correctly. */
#updateInputSizer() {
if (!this.#inputSizer || !this._dropdownInput)
return;
const input = this._dropdownInput;
const text = input.value || input.placeholder || "";
this.#inputSizer.dataset.value = text;
}
/** @override */
_getDropdownOptions() {
const makeOption = (option, group = null) => ({
value: option.value,
label: this._choices[option.value] ?? option.innerText,
group,
disabled: this.#disabledValues.has(option.value),
tooltip: this.#tooltips.get(option.value) ?? "",
});
return this._options.flatMap(child => {
if (child instanceof HTMLOptGroupElement) {
return [...child.querySelectorAll("option")]
.filter(option => option.value)
.map(option => makeOption(option, child.label));
}
if (child instanceof HTMLOptionElement && child.value) {
return makeOption(child);
}
return [];
});
}
/** @override */
_isOptionSelected(value) {
return this._value.has(value);
}
/** @override */
_onDropdownPick(option) {
const inValue = this._value.has(option.value);
const inChoices = option.value in this._choices;
if(!(inValue || inChoices))
return;
if (inValue) {
this._value.delete(option.value);
}
else if(inChoices) {
this._value.add(option.value);
}
this._internals.setFormValue(this.value);
this._refresh();
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
}
#onBoxMouseDown(event) {
// Fully block interaction when not editable.
if (!this.editable) {
event.preventDefault();
return;
}
if (event.target.classList.contains("chip-remove"))
return;
if (event.target === this._dropdownInput)
return;
event.preventDefault();
this._dropdownInput?.focus();
}
#onChipClick(event) {
if (!event.target.classList.contains("chip-remove") || !this.editable)
return;
const chip = event.target.closest(".chip");
if (!chip)
return;
this._value.delete(chip.dataset.key);
this._internals.setFormValue(this.value);
this._refresh();
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._dropdownInput?.focus();
}
#onClearAll(event) {
event.preventDefault();
if (!this.editable)
return;
this._value.clear();
this._internals.setFormValue("");
this._refresh();
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
}
/* -------------------------------------------- */
/* Static Factory */
/* -------------------------------------------- */
static create(config) {
const groups = prepareSelectOptionGroups(config);
const element = document.createElement(L5r5eHtmlMultiSelectElement.tagName);
element.name = config.name;
foundry.applications.fields.setInputAttributes(element, config);
if (config.hideDisabledOptions) {
element.toggleAttribute("hidedisabledoptions", true);
}
for (const groupEntry of groups) {
let parent = element;
if (groupEntry.group) {
parent = _appendOptgroup(groupEntry.group, element);
}
for (const groupOption of groupEntry.options){
_appendOption(groupOption, parent);
}
}
return element;
}
}
/* -------------------------------------------- */
/* Module Helpers */
/* -------------------------------------------- */
function prepareSelectOptionGroups(config) {
const result = foundry.applications.fields.prepareSelectOptionGroups(config);
config.options.filter(option => option?.disabled || option?.tooltip).forEach(special => {
result.forEach(group => {
group.options.forEach(groupOption => {
if (groupOption.value === special.value) {
groupOption.disabled = special.disabled;
groupOption.tooltip = special.tooltip;
}
});
});
});
return result;
}
function _appendOptgroup(label, parent) {
const element = document.createElement("optgroup");
element.label = label;
parent.appendChild(element);
return element;
}
function _appendOption(option, parent) {
const { value, label, selected, disabled, rule, tooltip } = option;
if (value !== undefined && label !== undefined) {
const element = document.createElement("option");
element.value = value;
element.innerText = label;
if (selected) {
element.toggleAttribute("selected", true);
}
if (disabled) {
element.toggleAttribute("disabled", true);
}
if (tooltip) {
element.setAttribute("title", tooltip);
}
parent.appendChild(element);
}
if (rule) {
parent.insertAdjacentHTML("beforeend", "<hr>");
}
}