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,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;
}
}
};
}