Updating the compendium filter to make it more snappy

This commit is contained in:
Litasa
2026-02-27 04:15:10 +00:00
parent aa203c546c
commit 2dd9ee19e9
17 changed files with 2327 additions and 639 deletions

View File

@@ -0,0 +1,257 @@
import { DropdownMixin } from "./l5r5e-dropdown-mixin.js";
const { AbstractFormInputElement } = foundry.applications.elements;
/**
* A custom `<l5r5e-combo-box>` combining a free-text input with a filterable option dropdown.
*
* Stores a **single string value** — either a predefined option's `value` attribute, or
* whatever the user typed freely. Use this when a field holds exactly one value, whether
* chosen from a list or entered manually (e.g. a weapon name, a title, a custom skill).
* For storing multiple values from a list, use {@link L5r5eHtmlMultiSelectElement} instead.
*
* Picking a predefined option stores its `value` attribute while displaying its human-readable
* label. Free-typing stores the typed string as both value and label. Fires `input` on every
* keystroke and `change` only on commit (blur or Enter) to avoid triggering sheet re-renders
* mid-typing. Picking from the dropdown fires both `input` and `change` immediately.
*
* Use `{{selectOptions}}` without `selected=` to render the available options, and set the
* current value via the `value` attribute on the element directly.
*
* @example
* ```hbs
* {{!-- Pass current value via the element's `value` attribute, not selectOptions selected= --}}
* <l5r5e-combo-box name="weapon" value="{{data.weapon}}">
* {{selectOptions choices localize=true}}
* </l5r5e-combo-box>
* ```
*
* @example
* // Programmatic update — query the element by its name attribute, then set value directly.
* // The visible input label updates automatically to match the selected option's label.
* const el = document.querySelector("l5r5e-combo-box[name='weapon']");
* el.value = "axe"; // input shows "Battleaxe", el.value returns "axe"
*
* // Free-text entry (no matching option) — value and label are both set to the typed string:
* el.value = "naginata"; // input shows "naginata", el.value returns "naginata"
*/
export class L5R5eHtmlComboBoxElement extends DropdownMixin(
AbstractFormInputElement,
{ multiSelect: false, debounceMs: 150, clearOnClose: false }
) {
/**
* The label currently shown in the text input. Differs from `_value` when a predefined
* option is selected (value = option's value attribute, label = option's display text).
* @type {string}
*/
_label = "";
/** @override */
static tagName = "l5r5e-combo-box";
/** @override */
static observedAttributes = ["disabled", "placeholder", "value"];
/**
* Flat descriptor list built once in _initialize(), mirrors the light-DOM options.
* @type {{ value: string, label: string, group: string|null }[]}
*/
#options = [];
/**
* Value snapshot taken when the input receives focus.
* Used by the blur handler to decide whether to fire a change event.
* @type {string}
*/
#valueAtFocus = "";
/* -------------------------------------------- */
/* Accessors */
/* -------------------------------------------- */
/** @override */
get value() {
return this._value ?? "";
}
set value(value) {
const match = this.#options.find(option => option.value === String(value ?? ""));
if (match) {
this._value = match.value;
this._label = match.label;
}
else {
this._value = String(value ?? "");
this._label = this._value;
}
this._internals.setFormValue(this._value);
if (this._dropdownInput) {
this._dropdownInput.value = this._label;
}
this.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
}
/**
* AbstractFormInputElement does not have an _initialize() hook, so we override
* connectedCallback to snapshot options from the light DOM before _buildElements()
* is called by super.connectedCallback().
*
* We keep this minimal: just build the flat #options list so _getDropdownOptions()
* and the value setter can look options up by value.
* @override
*/
connectedCallback() {
this.#snapshotOptions();
this.#resolveInitialValue();
super.connectedCallback();
}
#snapshotOptions() {
const makeOption = (option, group = null) => ({
value: option.value,
label: option.innerText,
group,
});
this.#options = [...this.children].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 [];
});
}
#resolveInitialValue() {
// Honour a `value` attribute if set, otherwise find a selected option.
const attrValue = this.getAttribute("value");
const initial = attrValue ?? this.#options.find(option => option.selected)?.value ?? "";
const match = this.#options.find(option => option.value === initial);
if (match) {
this._value = match.value;
this._label = match.label;
} else {
this._value = initial;
this._label = initial;
}
}
/* -------------------------------------------- */
/* Element Lifecycle */
/* -------------------------------------------- */
/** @override */
_buildElements() {
const wrapper = this._buildDropdownElements({
placeholder: this.getAttribute("placeholder") ?? "",
});
this._dropdownInput.value = this._label || this._value || "";
this._primaryInput = this._dropdownInput;
return [wrapper];
}
/** @override */
_activateListeners() {
const signal = this.abortSignal;
this._activateDropdownListeners();
// Prevent the inner <input>'s own native change from reaching Foundry's form handler.
// We dispatch our own change at commit time only (see below).
this._dropdownInput.addEventListener("change", (e) => e.stopPropagation(), { signal });
// Free typing: update value and fire `input` immediately.
// Do NOT fire `change` here — Foundry's form handler re-renders the sheet on
// every `change` event, which would destroy the element mid-typing.
// `change` is fired at commit time: on blur (if value changed) or Enter.
this._dropdownInput.addEventListener("input", () => {
const typed = this._dropdownInput.value;
this._label = typed;
this._value = typed;
this._internals.setFormValue(typed);
this.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
// DropdownMixin's #onInput fires the debounced dropdown re-render.
}, { signal });
// Commit on blur if the value has changed since the element was focused.
this._dropdownInput.addEventListener("focus", () => {
this.#valueAtFocus = this._value;
}, { signal });
this._dropdownInput.addEventListener("blur", () => {
if (this._value !== this.#valueAtFocus) {
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
}
}, { signal });
// Commit on Enter for free-text values (not picked from the dropdown).
this._dropdownInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
this._dropdownInput.blur();
}
}, { signal });
}
/** @override */
_toggleDisabled(disabled) {
this._toggleDropdownDisabled(disabled);
// Add/remove .disabled on the wrapper so CSS can dim the whole control.
const wrapper = this.querySelector(".wrapper");
if (wrapper) {
wrapper.classList.toggle("disabled", disabled);
}
}
/** @override */
attributeChangedCallback(attrName, oldValue, newValue) {
super.attributeChangedCallback(attrName, oldValue, newValue);
if (attrName === "disabled") {
this._toggleDropdownDisabled(newValue !== null);
}
}
/** @override */
_refresh() {
if (!this._dropdownInput)
return;
this._dropdownInput.value = this._label ?? "";
this._internals.setFormValue(this._value ?? "");
this._dropdownRefresh();
}
/* -------------------------------------------- */
/* DropdownMixin Contract */
/* -------------------------------------------- */
/** @override */
_getDropdownOptions() {
return this.#options;
}
/** @override */
_isOptionSelected(value) {
return this._value === value;
}
/**
* Commit the picked option as the current value and close the dropdown.
* @override
*/
_onDropdownPick(option) {
this._value = option.value;
this._label = option.label;
this._dropdownInput.value = option.label;
this._internals.setFormValue(option.value);
this._dropdownInput.blur();
this.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
}
}

View File

@@ -0,0 +1,492 @@
/**
* @typedef {Object} DropdownMixinConfig
* @property {boolean} [multiSelect=false]
* When true the dropdown toggles selection and stays open after a pick.
* When false a pick commits the value and closes (combo-box mode).
* @property {number} [debounceMs=150]
* Trailing-debounce delay in milliseconds for dropdown re-renders while typing.
*/
/**
* DropdownMixin
*
* Adds a fully custom `<ul role="listbox">` dropdown to any AbstractFormInputElement
* subclass. Handles UI only — building, opening, closing, rendering options, and keyboard
* navigation. Has no opinion about how options are stored or how values are managed.
*
* ### Contract: three methods the host must implement
*
* - `_getDropdownOptions()`
* Return `{ value, label, group, disabled, tooltip }[]` — the full unfiltered list.
*
* - `_isOptionSelected(value)` → boolean
* Return whether a given value is currently selected.
*
* - `_onDropdownPick(option)` → void
* Called when the user picks an option. The mixin handles post-pick behaviour
* (close in single mode, re-render in multi mode) after this returns.
*
* ### Host responsibilities
* - Call `this._buildDropdownElements({ placeholder })` inside `_buildElements()`
* and include the returned wrapper in the returned array.
* - Call `this._activateDropdownListeners()` inside `_activateListeners()`.
* - Call `this._toggleDropdownDisabled(disabled)` inside `_toggleDisabled()`.
* - Call `this._dropdownRefresh()` inside `_refresh()` to keep checkmarks in sync.
*
* @param {typeof AbstractFormInputElement} Base
* @param {DropdownMixinConfig} [mixinConfig={}]
* @return {typeof Base}
*/
export function DropdownMixin(Base, mixinConfig = {}) {
const {
multiSelect = false,
debounceMs = 150,
/**
* When true, closing the dropdown clears the search input text.
* Use true for multi-select (search is transient) and false for
* combo-box (the input IS the value and must persist after close).
*/
clearOnClose = true,
} = mixinConfig;
return class DropdownMixinElement extends Base {
/* --------------------------------------------------------- */
/* Private Fields */
/* --------------------------------------------------------- */
/** @type {HTMLInputElement} */
#searchInput;
/** @type {HTMLUListElement} */
#list;
/** @type {boolean} */
#open = false;
/**
* Snapshot of #open at the moment a focus event fires.
* Lets us distinguish a fresh focus (should open) from a click
* on an already-focused input (should toggle closed).
* @type {boolean}
*/
#wasOpenOnFocus = false;
/** @type {number} */
#activeIndex = -1;
/** @type {HTMLLIElement[]} — flat list of pickable <li>s, excludes group headers */
#optionElements = [];
/** @type {Function} */
#debouncedOpen;
/**
* Per-instance key so multiple elements on the same page never share a timer.
* @type {string}
*/
#debounceId = `l5r5e-dropdown-${foundry.utils.randomID()}`;
/**
* The search input element. The host should assign this to `this._primaryInput`.
* @return {HTMLInputElement}
*/
get _dropdownInput() {
return this.#searchInput;
}
/**
* Build the search `<input>` + `<ul>` dropdown inside a positioned wrapper div.
* Include the returned element in the array returned from `_buildElements()`.
*
* @param {object} [opts]
* @param {string} [opts.placeholder=""]
* @return {HTMLDivElement}
*/
_buildDropdownElements({ placeholder = "" } = {}) {
const wrapper = document.createElement("div");
wrapper.classList.add("wrapper");
this.#searchInput = document.createElement("input");
this.#searchInput.type = "text";
this.#searchInput.classList.add("input");
this.#searchInput.setAttribute("autocomplete", "off");
this.#searchInput.setAttribute("role", "combobox");
this.#searchInput.setAttribute("aria-autocomplete", "list");
this.#searchInput.setAttribute("aria-expanded", "false");
this.#searchInput.setAttribute("aria-haspopup", "listbox");
if (placeholder) {
this.#searchInput.setAttribute("placeholder", placeholder);
}
this.#list = document.createElement("ul");
this.#list.classList.add("dropdown");
this.#list.setAttribute("role", "listbox");
if (multiSelect) {
this.#list.setAttribute("aria-multiselectable", "true");
}
this.#list.hidden = true;
this.#syncOffset();
wrapper.append(this.#searchInput, this.#list);
return wrapper;
}
/** Attach dropdown event listeners. Call inside `_activateListeners()`. */
_activateDropdownListeners() {
const signal = this.abortSignal;
this.#debouncedOpen = game.l5r5e.HelpersL5r5e.debounce(
this.#debounceId,
(query) => this.#openDropdown(query),
debounceMs,
false // trailing — fires after the user pauses
);
this.#searchInput.addEventListener("mousedown", this.#onMouseDown.bind(this), { signal });
this.#searchInput.addEventListener("focus", this.#onFocus.bind(this), { signal });
this.#searchInput.addEventListener("input", this.#onInput.bind(this), { signal });
this.#searchInput.addEventListener("keydown", this.#onKeydown.bind(this), { signal });
this.#searchInput.addEventListener("blur", this.#onBlur.bind(this), { signal });
}
/**
* Enable or disable the search input. Call inside `_toggleDisabled()`.
* @param {boolean} disabled
*/
_toggleDropdownDisabled(disabled) {
if (this.#searchInput) {
this.#searchInput.disabled = disabled;
}
}
/* --------------------------------------------------------- */
/* Refresh */
/* --------------------------------------------------------- */
/**
* Re-render the open dropdown in place so checkmarks and disabled states
* stay in sync with the current value. Call inside `_refresh()`.
*/
_dropdownRefresh() {
if (this.#open) {
this.#renderOptions(this.#filter(this.#searchInput?.value ?? ""));
}
}
/**
* @abstract
* @return {{ value: string, label: string, group: string|null, disabled?: boolean, tooltip?: string }[]}
*/
_getDropdownOptions() {
throw new Error(`${this.constructor.name} must implement _getDropdownOptions()`);
}
/**
* @abstract
* @param {string} value
* @return {boolean}
*/
_isOptionSelected(value) {
throw new Error(`${this.constructor.name} must implement _isOptionSelected()`);
}
/**
* @abstract
* @param {{ value: string, label: string }} option
*/
_onDropdownPick(option) {
throw new Error(`${this.constructor.name} must implement _onDropdownPick()`);
}
/**
* Case-insensitive label filter over `_getDropdownOptions()`.
* Returns all options when query is empty.
* @param {string} query
* @return {object[]}
*/
#filter(query) {
const all = this._getDropdownOptions();
if (!query) {
return all;
}
const lower = query.toLowerCase();
return all.filter(option => option.label.toLowerCase().includes(lower));
}
#openDropdown(query = "") {
this.#renderOptions(this.#filter(query));
this.#list.hidden = false;
this.#searchInput.setAttribute("aria-expanded", "true");
this.#open = true;
this.#activeIndex = -1;
this._onDropdownOpened();
}
/**
* Called when the dropdown opens. Override in host classes to snapshot
* the current value so _onDropdownClosed can compare against it.
* Default is a no-op.
* @protected
*/
_onDropdownOpened() {}
#closeDropdown() {
this.#list.hidden = true;
this.#searchInput.setAttribute("aria-expanded", "false");
this.#open = false;
this.#wasOpenOnFocus = false;
this.#activeIndex = -1;
this.#optionElements = [];
// In combo-box mode the input IS the value, so we leave it intact.
// In multi-select mode the search text is transient and should reset.
if (clearOnClose && this.#searchInput) {
this.#searchInput.value = "";
}
// Notify the host that the dropdown has closed. Hosts can override this
// to fire a single change event after a multi-pick session.
this._onDropdownClosed();
}
/**
* Called when the dropdown closes. Override in host classes to fire a
* consolidated change event after a multi-pick session.
* Default is a no-op.
* @protected
*/
_onDropdownClosed() {}
#renderOptions(options) {
this.#list.innerHTML = "";
this.#optionElements = [];
// hideDisabledOptions is a host-level attribute, read via the DOM.
const visible = this.hasAttribute("hidedisabledoptions")
? options.filter(o => !o.disabled)
: options;
if (!visible.length) {
const empty = document.createElement("li");
empty.classList.add("no-results");
empty.textContent = game.i18n.localize("l5r5e.multiselect.no_results") ?? "No matches";
empty.setAttribute("aria-disabled", "true");
this.#list.append(empty);
return;
}
// Bucket into groups, preserving insertion order.
const groups = new Map();
const ungrouped = [];
for (const option of visible) {
if (option.group) {
if (!groups.has(option.group)) groups.set(option.group, []);
groups.get(option.group).push(option);
} else {
ungrouped.push(option);
}
}
for (const [label, options] of groups) {
const header = document.createElement("li");
header.classList.add("group");
header.setAttribute("role", "presentation");
header.textContent = label;
this.#list.append(header);
for (const option of options) {
this.#list.append(this.#buildOptionEl(option));
}
}
for (const option of ungrouped){
this.#list.append(this.#buildOptionEl(option));
}
}
#buildOptionEl(option) {
const selected = this._isOptionSelected(option.value);
const disabled = !!option.disabled;
const li = document.createElement("li");
li.classList.add("option");
if (selected) {
li.classList.add("selected");
}
if (disabled) {
li.classList.add("disabled");
}
li.setAttribute("role", "option");
li.setAttribute("aria-selected", String(selected));
li.setAttribute("aria-disabled", String(disabled));
li.dataset.value = option.value;
if (multiSelect) {
const check = document.createElement("span");
check.classList.add("checkmark");
check.setAttribute("aria-hidden", "true");
li.append(check);
}
const labelEl = document.createElement("span");
labelEl.classList.add("label");
labelEl.textContent = option.label;
li.append(labelEl);
if (selected && multiSelect) {
li.title = game.i18n.localize("l5r5e.multiselect.already_in_filter");
} else if (disabled && option.tooltip) {
li.title = option.tooltip;
}
if (!disabled) {
li.addEventListener("mouseenter", () => {
for (const element of this.#optionElements) {
element.classList.remove("active");
}
this.#activeIndex = this.#optionElements.indexOf(li);
li.classList.add("active");
});
li.addEventListener("mouseleave", () => {
li.classList.remove("active");
this.#activeIndex = -1;
});
li.addEventListener("mousedown", (event) => {
event.preventDefault(); // keep focus on the search input
this.#pick(option);
});
}
this.#optionElements.push(li);
return li;
}
#pick(option) {
this._onDropdownPick(option);
if (multiSelect) {
// Stay open — re-render so checkmarks reflect the new state.
this.#renderOptions(this.#filter(this.#searchInput.value));
} else {
this.#closeDropdown();
}
}
#moveHighlight(direction) {
if (!this.#optionElements.length) {
return;
}
const prev = this.#optionElements[this.#activeIndex];
if (prev) {
prev.classList.remove("active");
}
this.#activeIndex = Math.max(
-1,
Math.min(this.#optionElements.length - 1, this.#activeIndex + direction)
);
const next = this.#optionElements[this.#activeIndex];
if (next) {
next.classList.add("active");
next.scrollIntoView({ block: "nearest" });
}
}
#syncOffset() {
if (!this.#searchInput || !this.#list) {
return;
}
const offset = this.#searchInput.offsetLeft;
this.#list.style.left = `${offset}px`;
this.#list.style.right = `-${offset}px`;
}
/**
* Handles click-to-toggle behaviour.
*
* Three cases:
* 1. Dropdown is open → close it. preventDefault stops blur firing, which
* would otherwise trigger a second close via #onBlur.
* 2. Dropdown is closed AND input is already focused → open immediately.
* In this case focus will NOT fire again (input never blurred after the
* previous preventDefault), so we must open here rather than in #onFocus.
* 3. Dropdown is closed AND input is not yet focused → do nothing; the
* browser will fire focus naturally and #onFocus will open the dropdown.
*/
#onMouseDown(event) {
this.#wasOpenOnFocus = this.#open;
if (this.#open) {
event.preventDefault();
this.#closeDropdown();
} else if (document.activeElement === this.#searchInput) {
// Case 2: already focused, focus won't re-fire — open directly.
this.#openDropdown(this.#searchInput.value);
}
}
#onFocus(event) {
// Only open if mousedown didn't already handle it (cases 1 & 2 above).
if (!this.#wasOpenOnFocus && document.activeElement === this.#searchInput) {
this.#openDropdown(this.#searchInput.value);
}
}
#onInput(event) {
this.#debouncedOpen(this.#searchInput.value);
}
/**
* Close the dropdown when focus genuinely leaves the component.
*
* event.relatedTarget is the element that is RECEIVING focus. If it is
* inside our host element (e.g. the clear button, a chip remove span that
* managed to steal focus) we leave the dropdown open. Only when focus moves
* completely outside do we close — and we do so synchronously, so that
* _onDropdownClosed fires its change event BEFORE the browser hands control
* to whatever the user clicked. This eliminates the 100 ms race where Foundry
* could read stale FormData between the blur and a deferred close.
*/
#onBlur(event) {
if (this.contains(event.relatedTarget)) {
return;
}
this.#closeDropdown();
}
#onKeydown(event) {
if (!this.#open) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
this.#openDropdown(this.#searchInput.value);
}
return;
}
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
this.#moveHighlight(1);
} break;
case "ArrowUp": {
event.preventDefault();
this.#moveHighlight(-1);
}break;
case "Enter": {
event.preventDefault();
if (this.#activeIndex >= 0) {
const li = this.#optionElements[this.#activeIndex];
const option = this._getDropdownOptions().find(option => option.value === li.dataset.value);
if (option) {
this.#pick(option);
}
} else {
this.#closeDropdown();
}
} break;
case "Escape": {
event.preventDefault();
this.#closeDropdown();
} break;
case "Tab": {
this.#closeDropdown();
} break;
}
}
};
}

View File

@@ -1,301 +1,424 @@
import { DropdownMixin } from "./l5r5e-dropdown-mixin.js";
const { AbstractMultiSelectElement } = foundry.applications.elements;
/**
* Provide a multi-select workflow using a select element as the input mechanism.
* It is a expanded copy of the HTMLMultiselect with support for disabling options
* and a clear all button. Also have support for hover-over information using titlea
* A custom `<l5r5e-multi-select>` form element providing Select2-style chip multi-selection.
*
* @example Multi-Select HTML Markup
* ```html
* <l5r5e-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>
* 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 AbstractMultiSelectElement {
constructor() {
super();
this.#setup();
}
export class L5r5eHtmlMultiSelectElement extends DropdownMixin(
AbstractMultiSelectElement,
{ multiSelect: true, debounceMs: 150 }
) {
/** @override */
static tagName = "l5r5e-multi-select";
/**
* A select element used to choose options.
* @type {HTMLSelectElement}
*/
#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();
/**
* A display element which lists the chosen options.
* @type {HTMLDivElement}
* Returns a comma-separated string
* FormData reads this via field.value.
* @override
*/
#tags;
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();
}
/**
* A button element which clear all the options.
* @type {HTMLButtonElement}
* Return an array so FormDataExtended.object[name] matches Foundry's own
* HTMLMultiSelectElement — both field.value (string) and .object (array) are correct.
* @override
* @protected
*/
#clearAll;
_getValue() {
return Array.from(this._value);
}
/**
* A Set containing the values that should always be disabled.
* @type {Set}
* Accept either an array or comma-separated string when Foundry calls _setValue().
* @override
* @protected
*/
#disabledValues;
/* -------------------------------------------- */
// We will call initialize twice (one in the parent constructor) then one in #setup
// required since when we want to build the elements we should to an initialize first
// and we cannot override _initialize since we don't have access to #disabledValues there
#setup() {
super._initialize();
this.#disabledValues = new Set();
for (const option of this.querySelectorAll("option")) {
if (option.value === "") {
option.label = game.i18n.localize("l5r5e.multiselect.empty_tag");
this._choices[option.value] = game.i18n.localize("l5r5e.multiselect.empty_tag");
}
if (option.disabled) {
this.#disabledValues.add(option.value);
}
_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() {
this.#setup();
// Create select element
this.#select = this._primaryInput = document.createElement("select");
this.#select.insertAdjacentHTML("afterbegin", `<option id="l5r5e-multiselect-placeholder" value="" disabled selected hidden>${game.i18n.localize("l5r5e.multiselect.placeholder")}</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";
// Create a clear all button
this.#clearAll = document.createElement("button");
this.#clearAll.textContent = "X";
return [this.#select, this.#clearAll, this.#tags];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
// Update the displayed tags
const tags = Array.from(this._value).map(id => {
return foundry.applications.elements.HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable);
// 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"),
});
this.#tags.replaceChildren(...tags);
const searchInput = mixinWrapper.querySelector("input.input");
const dropdownList = mixinWrapper.querySelector("ul.dropdown");
// Figure out if we are overflowing the tag div.
if($(this.#tags).css("max-height")) {
const numericMaxHeight = parseInt($(this.#tags).css("max-height"), 10);
if(numericMaxHeight) {
if($(this.#tags).prop("scrollHeight") > numericMaxHeight) {
this.#tags.classList.add("overflowing");
}
else {
this.#tags.classList.remove("overflowing");
}
}
}
// Selection box
this.#selectionBox = document.createElement("div");
this.#selectionBox.classList.add("selection-box");
// Disable selected options
const hideDisabled = game.settings.get(CONFIG.l5r5e.namespace, "compendium-hide-disabled-sources");
for (const option of this.#select) {
if (this._value.has(option.value)) {
option.disabled = true;
option.title = game.i18n.localize("l5r5e.multiselect.already_in_filter");
continue;
}
if (this.#disabledValues.has(option.value)) {
option.disabled = true;
option.hidden = hideDisabled;
continue;
}
option.disabled = false;
option.removeAttribute("title");
}
// 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.#select.addEventListener("change", this.#onChangeSelect.bind(this));
this.#clearAll.addEventListener("click", this.#onClickClearAll.bind(this));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
this._activateDropdownListeners();
const signal = this.abortSignal;
this.#tags.addEventListener("mouseleave", this.#onMouseLeave.bind(this));
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 });
}
#onMouseLeave(event) {
// Figure out if we are overflowing the tag div.
if($(this.#tags).css("max-height")) {
const numericMaxHeight = parseInt($(this.#tags).css("max-height"), 10);
if($(this.#tags).prop("scrollHeight") > numericMaxHeight) {
this.#tags.classList.add("overflowing");
}
else {
this.#tags.classList.remove("overflowing");
}
}
}
/* -------------------------------------------- */
/**
* 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.valueIndex === 0)
return; // Ignore placeholder
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);
}
/* -------------------------------------------- */
/**
* Handle clickling the clear all button
* @param {Event} event The originating click event on the clear all button
*/
#onClickClearAll(event) {
event.preventDefault();
var _this = this;
$(this.#tags).children().each(function () {
_this.unselect($(this).data("key"));
})
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#select.toggleAttribute("disabled", 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 */
/* -------------------------------------------- */
/**
* Create a HTML_l5r5e_MultiSelectElement using provided configuration data.
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
* @returns {L5r5eHtmlMultiSelectElement}
*/
static create(config) {
// Foundry creates either a select with tag multi-select or multi-checkboxes. We want a l5r5e-multi-select
// Copied the implementation from foundry.applications.fields.createMultiSelectInput with our required changes.
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);
}
//Setup the HTML
const select = document.createElement(L5r5eHtmlMultiSelectElement.tagName);
select.name = config.name;
foundry.applications.fields.setInputAttributes(select, config);
for (const group_entry of groups) {
let parent = select;
if (group_entry.group) {
parent = _appendOptgroupHtml(group_entry.group, select);
for (const groupEntry of groups) {
let parent = element;
if (groupEntry.group) {
parent = _appendOptgroup(groupEntry.group, element);
}
for (const option_entry of group_entry.options) {
_appendOptionHtml(option_entry, parent);
for (const groupOption of groupEntry.options){
_appendOption(groupOption, parent);
}
}
return select;
return element;
}
}
/** Stolen from foundry.applications.fields.prepareSelectOptionGroups: Needed to add support for tooltips
*
*/
/* -------------------------------------------- */
/* Module Helpers */
/* -------------------------------------------- */
function prepareSelectOptionGroups(config) {
const result = foundry.applications.fields.prepareSelectOptionGroups(config);
// Disable options based on input
config.options.filter((option) => option?.disabled || option?.tooltip).forEach((SpecialOption) => {
result.forEach((group) => {
group.options.forEach((option) => {
if (SpecialOption.value === option.value) {
option.disabled = SpecialOption.disabled;
option.tooltip = SpecialOption?.tooltip;
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;
}
/** Stolen from foundry.applications.fields
* Create and append an optgroup element to a parent select.
* @param {string} label
* @param {HTMLSelectElement} parent
* @returns {HTMLOptGroupElement}
* @internal
*/
function _appendOptgroupHtml(label, parent) {
const optgroup = document.createElement("optgroup");
optgroup.label = label;
parent.appendChild(optgroup);
return optgroup;
function _appendOptgroup(label, parent) {
const element = document.createElement("optgroup");
element.label = label;
parent.appendChild(element);
return element;
}
/** Stolen from foundry.applications.fields
* Create and append an option element to a parent select or optgroup.
* @param {FormSelectOption} option
* @param {HTMLSelectElement|HTMLOptGroupElement} parent
* @internal
*/
function _appendOptionHtml(option, parent) {
function _appendOption(option, parent) {
const { value, label, selected, disabled, rule, tooltip } = option;
if ((value !== undefined) && (label !== undefined)) {
const option_html = document.createElement("option");
option_html.value = value;
option_html.innerText = label;
if (value !== undefined && label !== undefined) {
const element = document.createElement("option");
element.value = value;
element.innerText = label;
if (selected) {
option_html.toggleAttribute("selected", true);
element.toggleAttribute("selected", true);
}
if (disabled) {
option_html.toggleAttribute("disabled", true);
element.toggleAttribute("disabled", true);
}
if (tooltip) {
option_html.setAttribute("title", tooltip);
element.setAttribute("title", tooltip);
}
parent.appendChild(option_html);
parent.appendChild(element);
}
if (rule) {
parent.insertAdjacentHTML("beforeend", "<hr>");