import { DropdownMixin } from "./l5r5e-dropdown-mixin.js"; const { AbstractFormInputElement } = foundry.applications.elements; /** * A custom `` 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= --}} * * {{selectOptions choices localize=true}} * * ``` * * @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 '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 })); } }