Implement search select component
This commit is contained in:
@@ -1,20 +1,35 @@
|
||||
import {
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
disableElementsWhenTrue,
|
||||
disableElementsWhenValueNotEqual,
|
||||
} from "./utils.js";
|
||||
import { getEl, disableElementsWhenTrue } from "./utils.js";
|
||||
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_games",
|
||||
source_value: "dataset.platform",
|
||||
target: "#id_platform",
|
||||
target_value: "value",
|
||||
},
|
||||
];
|
||||
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
||||
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
||||
// react to its custom "search-select:change" event instead of syncing a select.
|
||||
document.addEventListener("search-select:change", (event) => {
|
||||
if (event.detail.name !== "games") return;
|
||||
|
||||
// (a) Auto-fill platform from the clicked option's data-platform.
|
||||
const last = event.detail.last;
|
||||
const platformId = last && last.data ? last.data.platform : "";
|
||||
if (platformId) {
|
||||
const platformEl = getEl("#id_platform");
|
||||
if (platformEl) platformEl.value = platformId;
|
||||
}
|
||||
|
||||
// (b) Refresh #id_related_purchase for the currently selected games.
|
||||
const query = event.detail.values
|
||||
.map((value) => "games=" + encodeURIComponent(value))
|
||||
.join("&");
|
||||
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" })
|
||||
.then((response) => {
|
||||
if (response.status === 204) return null;
|
||||
return response.text();
|
||||
})
|
||||
.then((html) => {
|
||||
if (html === null) return;
|
||||
const target = getEl("#id_related_purchase");
|
||||
if (target) target.outerHTML = html;
|
||||
});
|
||||
});
|
||||
|
||||
function setupElementHandlers() {
|
||||
disableElementsWhenTrue("#id_type", "game", [
|
||||
@@ -27,5 +42,4 @@ document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* SearchSelect widget — a search box paired with a dropdown of options.
|
||||
* Single/multi select; chosen items render as removable pills, each backed by a
|
||||
* hidden <input> so existing Django form validation keeps working.
|
||||
*
|
||||
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
|
||||
* each widget guarded with el._ssInit.
|
||||
*
|
||||
* The pill / option class strings below are kept byte-identical to the Python
|
||||
* Pill / SearchSelect components so Tailwind generates the classes and
|
||||
* server-rendered and JS-created pills are indistinguishable.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var PILL_CLASS =
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
|
||||
"bg-brand/15 text-heading";
|
||||
var PILL_REMOVE_CLASS =
|
||||
"ml-1 text-body hover:text-heading font-bold cursor-pointer";
|
||||
var OPTION_ROW_CLASS =
|
||||
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
|
||||
|
||||
var DEBOUNCE_MS = 500;
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll("[data-search-select]").forEach(function (el) {
|
||||
if (el._ssInit) return;
|
||||
el._ssInit = true;
|
||||
initWidget(el);
|
||||
});
|
||||
}
|
||||
|
||||
function initWidget(container) {
|
||||
var search = container.querySelector("[data-ss-search]");
|
||||
var options = container.querySelector("[data-ss-options]");
|
||||
var pills = container.querySelector("[data-ss-pills]");
|
||||
if (!search || !options || !pills) return;
|
||||
|
||||
var name = container.getAttribute("data-name");
|
||||
var searchUrl = container.getAttribute("data-search-url");
|
||||
var multi = container.getAttribute("data-multi") === "true";
|
||||
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
|
||||
var syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||
|
||||
var noResults = options.querySelector("[data-ss-no-results]");
|
||||
var debounceTimer = null;
|
||||
|
||||
function showPanel() {
|
||||
options.classList.remove("hidden");
|
||||
}
|
||||
function hidePanel() {
|
||||
if (!alwaysVisible) options.classList.add("hidden");
|
||||
}
|
||||
|
||||
function setNoResults(visible) {
|
||||
if (noResults) noResults.classList.toggle("hidden", !visible);
|
||||
}
|
||||
|
||||
// ── Render server-fetched rows into the panel ──
|
||||
function renderRows(items) {
|
||||
options.querySelectorAll("[data-ss-option]").forEach(function (r) {
|
||||
r.remove();
|
||||
});
|
||||
items.slice(0, itemsScroll).forEach(function (item) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
});
|
||||
setNoResults(items.length === 0);
|
||||
showPanel();
|
||||
}
|
||||
|
||||
function buildRow(option) {
|
||||
var row = document.createElement("div");
|
||||
row.setAttribute("data-ss-option", "");
|
||||
row.setAttribute("data-value", option.value);
|
||||
row.setAttribute("data-label", option.label);
|
||||
row.className = OPTION_ROW_CLASS;
|
||||
var data = option.data || {};
|
||||
Object.keys(data).forEach(function (key) {
|
||||
row.setAttribute("data-" + key, data[key]);
|
||||
});
|
||||
row.textContent = option.label;
|
||||
row._ssOption = option;
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Client-side filter of pre-rendered rows ──
|
||||
function filterRows(q) {
|
||||
var lower = q.toLowerCase();
|
||||
var anyVisible = false;
|
||||
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
|
||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
var match = label.indexOf(lower) !== -1;
|
||||
item.style.display = match ? "" : "none";
|
||||
if (match) anyVisible = true;
|
||||
});
|
||||
setNoResults(!anyVisible);
|
||||
showPanel();
|
||||
}
|
||||
|
||||
function runSearch() {
|
||||
var q = search.value.trim();
|
||||
if (searchUrl && q) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function () {
|
||||
fetch(searchUrl + "?q=" + encodeURIComponent(q), {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(renderRows)
|
||||
.catch(function () {
|
||||
setNoResults(true);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
filterRows(q);
|
||||
}
|
||||
}
|
||||
|
||||
search.addEventListener("focus", runSearch);
|
||||
search.addEventListener("input", runSearch);
|
||||
|
||||
// ── Option click → select ──
|
||||
options.addEventListener("click", function (e) {
|
||||
var row = e.target.closest("[data-ss-option]");
|
||||
if (!row) return;
|
||||
var option = optionFromRow(row);
|
||||
selectOption(option);
|
||||
});
|
||||
|
||||
function optionFromRow(row) {
|
||||
if (row._ssOption) return row._ssOption;
|
||||
var data = {};
|
||||
Object.keys(row.dataset).forEach(function (key) {
|
||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||
data[key] = row.dataset[key];
|
||||
}
|
||||
});
|
||||
return {
|
||||
value: row.getAttribute("data-value"),
|
||||
label: row.getAttribute("data-label"),
|
||||
data: data,
|
||||
};
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
if (multi) {
|
||||
if (!pills.querySelector('input[value="' + cssEscape(option.value) + '"]')) {
|
||||
addPill(option);
|
||||
}
|
||||
} else {
|
||||
pills.innerHTML = "";
|
||||
addPill(option);
|
||||
search.value = option.label;
|
||||
hidePanel();
|
||||
}
|
||||
emitChange(option);
|
||||
}
|
||||
|
||||
function addPill(option) {
|
||||
pills.appendChild(buildPill(option));
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
}
|
||||
|
||||
function buildPill(option) {
|
||||
var pill = document.createElement("span");
|
||||
pill.className = PILL_CLASS;
|
||||
pill.setAttribute("data-pill", "");
|
||||
pill.setAttribute("data-value", option.value);
|
||||
var data = option.data || {};
|
||||
Object.keys(data).forEach(function (key) {
|
||||
pill.setAttribute("data-" + key, data[key]);
|
||||
});
|
||||
pill.appendChild(document.createTextNode(option.label));
|
||||
var remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.setAttribute("data-pill-remove", "");
|
||||
remove.className = PILL_REMOVE_CLASS;
|
||||
remove.setAttribute("aria-label", "Remove");
|
||||
remove.textContent = "×";
|
||||
pill.appendChild(remove);
|
||||
return pill;
|
||||
}
|
||||
|
||||
function buildHidden(value) {
|
||||
var input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
return input;
|
||||
}
|
||||
|
||||
// ── Pill × → remove ──
|
||||
pills.addEventListener("click", function (e) {
|
||||
var removeBtn = e.target.closest("[data-pill-remove]");
|
||||
if (!removeBtn) return;
|
||||
var pill = removeBtn.closest("[data-pill]");
|
||||
if (!pill) return;
|
||||
var value = pill.getAttribute("data-value");
|
||||
pill.remove();
|
||||
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
|
||||
if (hidden) hidden.remove();
|
||||
emitChange(null);
|
||||
});
|
||||
|
||||
function currentValues() {
|
||||
return Array.prototype.map.call(
|
||||
pills.querySelectorAll('input[type="hidden"]'),
|
||||
function (input) {
|
||||
return input.value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function emitChange(last) {
|
||||
var values = currentValues();
|
||||
if (syncUrl) syncToUrl(values);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("search-select:change", {
|
||||
bubbles: true,
|
||||
detail: { name: name, values: values, last: last },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function syncToUrl(values) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
values.forEach(function (v) {
|
||||
params.append(name, v);
|
||||
});
|
||||
var qs = params.toString();
|
||||
history.replaceState(null, "", qs ? "?" + qs : window.location.pathname);
|
||||
}
|
||||
|
||||
// On init, restore from URL params if the server supplied no selected pills.
|
||||
if (syncUrl && !pills.querySelector("[data-pill]")) {
|
||||
var initial = new URLSearchParams(window.location.search).getAll(name);
|
||||
initial.forEach(function (v) {
|
||||
addPill({ value: v, label: v, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Close panel on outside click ──
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!container.contains(e.target)) hidePanel();
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal escape for use inside an attribute-value selector. */
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Forward-looking hook (parallels readSelectableFilters): write each widget's
|
||||
// current values to a data-values JSON attribute.
|
||||
window.readSearchSelect = function (form) {
|
||||
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
||||
var pills = container.querySelector("[data-ss-pills]");
|
||||
var values = pills
|
||||
? Array.prototype.map.call(
|
||||
pills.querySelectorAll('input[type="hidden"]'),
|
||||
function (input) {
|
||||
return input.value;
|
||||
}
|
||||
)
|
||||
: [];
|
||||
container.setAttribute("data-values", JSON.stringify(values));
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
})();
|
||||
Reference in New Issue
Block a user