Updating the compendium filter to make it more snappy
This commit is contained in:
257
system/scripts/misc/l5r5e-combo-box.js
Normal file
257
system/scripts/misc/l5r5e-combo-box.js
Normal 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 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user