257 lines
9.4 KiB
JavaScript
257 lines
9.4 KiB
JavaScript
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 }));
|
|
}
|
|
} |