Files
l5rx-chiaroscuro/system/scripts/misc/l5r5e-combo-box.js

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