mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-22 00:57:26 +00:00
183 lines
4.6 KiB
JavaScript
183 lines
4.6 KiB
JavaScript
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,
|
||
remoteUrl: String,
|
||
};
|
||
|
||
connect(options = {}) {
|
||
let tomSelectOptions = {
|
||
maxItems: 1,
|
||
maxOptions: null,
|
||
plugins: ["dropdown_input"],
|
||
allowEmptyOption: true, // Show blank option (option with empty value)
|
||
placeholder: this.placeholderValue || this.#emptyOption(),
|
||
onItemAdd: function () {
|
||
this.setTextboxValue("");
|
||
},
|
||
...this.optionsValue,
|
||
...options,
|
||
};
|
||
|
||
if (this.remoteUrlValue) {
|
||
this.#addRemoteOptions(tomSelectOptions);
|
||
}
|
||
|
||
this.control = new TomSelect(this.element, tomSelectOptions);
|
||
}
|
||
|
||
disconnect() {
|
||
if (this.control) this.control.destroy();
|
||
}
|
||
|
||
// private
|
||
|
||
#emptyOption() {
|
||
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 don’t 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,
|
||
});
|
||
}
|
||
}
|