Refactor SearchableDropdownComponent and integrate remote data loading with TomSelect

This commit is contained in:
Ahmed Ejaz
2026-01-25 11:14:49 +05:00
parent 03b7c07495
commit 77fe1fa6f9
11 changed files with 577 additions and 326 deletions

View File

@@ -2,10 +2,14 @@ import { Controller } from "stimulus";
import TomSelect from "tom-select/dist/esm/tom-select.complete";
export default class extends Controller {
static values = { options: Object, placeholder: String };
static values = {
options: Object,
placeholder: String,
remoteUrl: String,
};
connect(options = {}) {
this.control = new TomSelect(this.element, {
let tomSelectOptions = {
maxItems: 1,
maxOptions: null,
plugins: ["dropdown_input"],
@@ -16,7 +20,13 @@ export default class extends Controller {
},
...this.optionsValue,
...options,
});
};
if (this.remoteUrlValue) {
this.#addRemoteOptions(tomSelectOptions);
}
this.control = new TomSelect(this.element, tomSelectOptions);
}
disconnect() {
@@ -29,4 +39,144 @@ export default class extends Controller {
const optionsArray = [...this.element.options];
return optionsArray.find((option) => [null, ""].includes(option.value))?.text;
}
#addRemoteOptions(options) {
// --- Pagination & request state ---
this.page = 1;
this.hasMore = true;
this.loading = false;
this.lastQuery = "";
this.scrollAttached = false;
const buildUrl = (query) => {
const url = new URL(this.remoteUrlValue, window.location.origin);
url.searchParams.set("q", query);
url.searchParams.set("page", this.page);
return url;
};
/**
* Shared remote fetch handler.
* Owns:
* - request lifecycle
* - pagination state
* - loading UI when appending
*/
const fetchOptions = ({ query, append = false, callback }) => {
if (this.loading || !this.hasMore) {
callback?.();
return;
}
this.loading = true;
const dropdown = this.control?.dropdown_content;
const previousScrollTop = dropdown?.scrollTop;
let loader;
/**
* When appending (infinite scroll), TomSelect does NOT
* manage loading UI automatically — we must do it manually.
*/
if (append && dropdown) {
loader = this.control.render("loading");
dropdown.appendChild(loader);
this.control.wrapper.classList.add(this.control.settings.loadingClass);
}
fetch(buildUrl(query))
.then((response) => response.json())
.then((json) => {
/**
* Expected API shape:
* {
* results: [{ value, label }],
* pagination: { more: boolean }
* }
*/
this.hasMore = Boolean(json.pagination?.more);
this.page += 1;
const results = json.results || [];
if (append && dropdown) {
this.control.addOptions(results);
this.control.refreshOptions(false);
/**
* Preserve scroll position so newly appended
* options dont cause visual jumping.
*/
requestAnimationFrame(() => {
dropdown.scrollTop = previousScrollTop;
});
} else {
callback?.(results);
}
})
.catch(() => {
callback?.();
})
.finally(() => {
this.loading = false;
if (append && loader) {
this.control.wrapper.classList.remove(this.control.settings.loadingClass);
loader.remove();
}
});
};
options.load = function (query, callback) {
fetchOptions({ query, callback });
}.bind(this);
options.onType = function (query) {
if (query === this.lastQuery) return;
this.lastQuery = query;
this.page = 1;
this.hasMore = true;
}.bind(this);
options.onDropdownOpen = function () {
if (this.scrollAttached) return;
this.scrollAttached = true;
const dropdown = this.control.dropdown_content;
dropdown.addEventListener(
"scroll",
function () {
const nearBottom =
dropdown.scrollTop + dropdown.clientHeight >= dropdown.scrollHeight - 20;
if (nearBottom) {
this.#fetchNextPage();
}
}.bind(this),
);
}.bind(this);
options.onFocus = function () {
if (this.loading) return;
this.lastQuery = "";
this.control.load("", () => {});
}.bind(this);
options.valueField = "value";
options.labelField = "label";
options.searchField = "label";
this._fetchOptions = fetchOptions;
}
#fetchNextPage() {
if (this.loading || !this.hasMore) return;
this._fetchOptions({
query: this.lastQuery,
append: true,
});
}
}