Search select JavaScript improvements
This commit is contained in:
+217
-198
@@ -21,158 +21,190 @@
|
||||
* and data-* attributes — so all markup and Tailwind class strings live in one
|
||||
* place (the Python components), never duplicated here.
|
||||
*/
|
||||
(function () {
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
var DEBOUNCE_MS = 100;
|
||||
const DEBOUNCE_MS = 100;
|
||||
|
||||
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
||||
// These modifiers are mutually exclusive with value pills — selecting
|
||||
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
||||
// INCLUDES_ONLY) coexist with value pills.
|
||||
var PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll("[data-search-select]").forEach(function (element) {
|
||||
const initAll = () => {
|
||||
document.querySelectorAll("[data-search-select]").forEach(element => {
|
||||
if (element._searchSelectInit) return;
|
||||
element._searchSelectInit = true;
|
||||
initWidget(element);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function initWidget(container) {
|
||||
var search = container.querySelector("[data-search-select-search]");
|
||||
var options = container.querySelector("[data-search-select-options]");
|
||||
var pills = container.querySelector("[data-search-select-pills]");
|
||||
const initWidget = (container) => {
|
||||
const search = container.querySelector("[data-search-select-search]");
|
||||
const options = container.querySelector("[data-search-select-options]");
|
||||
const pills = container.querySelector("[data-search-select-pills]");
|
||||
if (!search || !options || !pills) return;
|
||||
|
||||
var name = container.getAttribute("data-name");
|
||||
var searchUrl = container.getAttribute("data-search-url");
|
||||
var isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||
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 prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||
var syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||
const name = container.getAttribute("data-name");
|
||||
const searchUrl = container.getAttribute("data-search-url");
|
||||
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||
const multi = container.getAttribute("data-multi") === "true";
|
||||
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||
const syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||
|
||||
var noResults = options.querySelector("[data-search-select-no-results]");
|
||||
var debounceTimer = null;
|
||||
var pendingRequest = null; // in-flight AbortController, so newer queries win
|
||||
var hasPrefetched = false;
|
||||
const noResults = options.querySelector("[data-search-select-no-results]");
|
||||
let debounceTimer = null;
|
||||
let pendingRequest = null; // in-flight AbortController, so newer queries win
|
||||
let hasPrefetched = false;
|
||||
|
||||
function hasVisibleContent() {
|
||||
var optionRows = options.querySelectorAll("[data-search-select-option]");
|
||||
for (var i = 0; i < optionRows.length; i++) {
|
||||
const hasVisibleContent = () => {
|
||||
const optionRows = options.querySelectorAll("[data-search-select-option]");
|
||||
for (let i = 0; i < optionRows.length; i++) {
|
||||
if (optionRows[i].style.display !== "none") return true;
|
||||
}
|
||||
if (noResults && !noResults.classList.contains("hidden")) return true;
|
||||
if (options.querySelector("[data-search-select-modifier-option]")) return true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
function showPanel() {
|
||||
const showPanel = () => {
|
||||
if (alwaysVisible || hasVisibleContent()) {
|
||||
options.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
function hidePanel() {
|
||||
};
|
||||
const hidePanel = () => {
|
||||
if (!alwaysVisible) options.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
function setNoResults(visible) {
|
||||
const setNoResults = (visible) => {
|
||||
if (!noResults) return;
|
||||
noResults.classList.toggle("hidden", !visible);
|
||||
if (visible) showPanel();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Highlight tracking (filter mode) ──
|
||||
var highlightedRow = null;
|
||||
let highlightedRow = null;
|
||||
|
||||
function highlightOption(row) {
|
||||
const highlightOption = (row) => {
|
||||
clearHighlight();
|
||||
if (!row) return;
|
||||
row.setAttribute("data-search-select-highlighted", "");
|
||||
highlightedRow = row;
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
};
|
||||
|
||||
function clearHighlight() {
|
||||
const clearHighlight = () => {
|
||||
if (highlightedRow) {
|
||||
highlightedRow.removeAttribute("data-search-select-highlighted");
|
||||
highlightedRow = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getVisibleOptions() {
|
||||
var all = options.querySelectorAll("[data-search-select-option]");
|
||||
return Array.prototype.filter.call(all, function (row) {
|
||||
return row.style.display !== "none";
|
||||
});
|
||||
}
|
||||
const getVisibleOptions = () => {
|
||||
const all = options.querySelectorAll("[data-search-select-option]");
|
||||
return Array.from(all).filter(row => row.style.display !== "none");
|
||||
};
|
||||
|
||||
function autoHighlight(query) {
|
||||
var visible = getVisibleOptions();
|
||||
const autoHighlight = (query) => {
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
var lower = query.toLowerCase();
|
||||
const lower = query.toLowerCase();
|
||||
// 1. Starts-with match
|
||||
for (var i = 0; i < visible.length; i++) {
|
||||
var label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && label.startsWith(lower)) {
|
||||
highlightOption(visible[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 2. Substring match (fuzzy-lite)
|
||||
for (var j = 0; j < visible.length; j++) {
|
||||
var subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && subLabel.indexOf(lower) !== -1) {
|
||||
for (let j = 0; j < visible.length; j++) {
|
||||
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && subLabel.includes(lower)) {
|
||||
highlightOption(visible[j]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 3. Fallback: first visible option
|
||||
highlightOption(visible[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Get active values in both form and filter modes
|
||||
const getSelectedValues = () => {
|
||||
const vals = new Set();
|
||||
pills.querySelectorAll('input[type="hidden"]').forEach(input => {
|
||||
vals.add(input.value);
|
||||
});
|
||||
pills.querySelectorAll("[data-pill]").forEach(pill => {
|
||||
const val = pill.getAttribute("data-value");
|
||||
if (val) vals.add(val);
|
||||
});
|
||||
return vals;
|
||||
};
|
||||
|
||||
// ── Render server-fetched rows into the panel ──
|
||||
function renderRows(items) {
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
|
||||
const renderRows = (items) => {
|
||||
const selectedVals = getSelectedValues();
|
||||
const preservedOptions = [];
|
||||
|
||||
// Extract existing option data for currently selected values before removing
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(row => {
|
||||
const val = row.getAttribute("data-value");
|
||||
if (selectedVals.has(val)) {
|
||||
preservedOptions.push(optionFromRow(row));
|
||||
}
|
||||
row.remove();
|
||||
});
|
||||
items.slice(0, itemsScroll).forEach(function (item) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
|
||||
const renderedValues = new Set();
|
||||
|
||||
// Render preserved options first (to keep them at the top)
|
||||
preservedOptions.forEach(opt => {
|
||||
options.insertBefore(buildRow(opt), noResults || null);
|
||||
renderedValues.add(String(opt.value));
|
||||
});
|
||||
showPanel();
|
||||
|
||||
// Render newly fetched items (excluding already rendered preserved ones)
|
||||
// Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
|
||||
items.forEach(item => {
|
||||
if (!renderedValues.has(String(item.value))) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
renderedValues.add(String(item.value));
|
||||
}
|
||||
});
|
||||
|
||||
showPanel();
|
||||
};
|
||||
|
||||
// ── Clone a server-rendered <template> prototype by name. The server emits
|
||||
// the mode-appropriate prototypes, so the JS never names a class. ──
|
||||
function cloneTemplate(name) {
|
||||
var template = container.querySelector('template[data-search-select-template="' + name + '"]');
|
||||
const cloneTemplate = (name) => {
|
||||
const template = container.querySelector(`template[data-search-select-template="${name}"]`);
|
||||
return template
|
||||
? template.content.firstElementChild.cloneNode(true)
|
||||
: null;
|
||||
}
|
||||
};
|
||||
|
||||
function setLabel(node, label) {
|
||||
var slot = node.querySelector("[data-search-select-label]");
|
||||
const setLabel = (node, label) => {
|
||||
const slot = node.querySelector("[data-search-select-label]");
|
||||
if (slot) slot.textContent = label;
|
||||
}
|
||||
};
|
||||
|
||||
function applyData(node, data) {
|
||||
data = data || {};
|
||||
Object.keys(data).forEach(function (key) {
|
||||
node.setAttribute("data-" + key, data[key]);
|
||||
const applyData = (node, data = {}) => {
|
||||
Object.keys(data).forEach(key => {
|
||||
node.setAttribute(`data-${key}`, data[key]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build an option row by cloning the "row" template (the same prototype the
|
||||
// server renders, so fetched and pre-rendered rows are identical).
|
||||
function buildRow(option) {
|
||||
var row = cloneTemplate("row");
|
||||
const buildRow = (option) => {
|
||||
const row = cloneTemplate("row");
|
||||
if (!row) return document.createComment("ss-row");
|
||||
row.setAttribute("data-value", option.value);
|
||||
row.setAttribute("data-label", option.label);
|
||||
@@ -180,59 +212,57 @@
|
||||
setLabel(row, option.label);
|
||||
row._searchSelectOption = option;
|
||||
return row;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Client-side filter of the currently loaded rows. Returns the number of
|
||||
// visible rows so the caller decides whether to show the no-results node. ──
|
||||
function filterRows(query) {
|
||||
var lower = query.toLowerCase();
|
||||
var visibleCount = 0;
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(function (item) {
|
||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
var match = label.indexOf(lower) !== -1;
|
||||
const filterRows = (query) => {
|
||||
const lower = query.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(item => {
|
||||
const label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
const match = label.includes(lower);
|
||||
item.style.display = match ? "" : "none";
|
||||
if (match) visibleCount += 1;
|
||||
});
|
||||
return visibleCount;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Fetch matching rows from the server. The previous in-flight request is
|
||||
// aborted so a slower earlier response can never overwrite a newer one. ──
|
||||
function fetchFromServer(query) {
|
||||
const fetchFromServer = (query) => {
|
||||
if (pendingRequest) pendingRequest.abort();
|
||||
pendingRequest = new AbortController();
|
||||
var url = searchUrl + "?q=" + encodeURIComponent(query);
|
||||
if (prefetch && !query) url += "&limit=" + prefetch;
|
||||
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
|
||||
if (prefetch && !query) url += `&limit=${prefetch}`;
|
||||
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
||||
.then(function (response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function (items) {
|
||||
.then(response => response.json())
|
||||
.then(items => {
|
||||
pendingRequest = null;
|
||||
renderRows(items);
|
||||
// Re-apply the live query: the box may hold more text than was sent.
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
autoHighlight(search.value.trim());
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (error && error.name === "AbortError") return; // superseded
|
||||
.catch(error => {
|
||||
if (error?.name === "AbortError") return; // superseded
|
||||
pendingRequest = null;
|
||||
setNoResults(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Called on every keystroke. With a search_url, filter the loaded window
|
||||
// instantly (zero latency) and debounce a server request for the rest;
|
||||
// no-results stays hidden until the response decides it, to avoid a flash
|
||||
// over an incomplete window. Without a search_url the loaded set is complete,
|
||||
// so the client-side filter is authoritative.
|
||||
function runSearch() {
|
||||
var query = search.value.trim();
|
||||
const runSearch = () => {
|
||||
const query = search.value.trim();
|
||||
if (searchUrl) {
|
||||
filterRows(query);
|
||||
setNoResults(false);
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function () {
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchFromServer(query);
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
@@ -240,13 +270,13 @@
|
||||
}
|
||||
autoHighlight(query);
|
||||
showPanel();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Single-select combobox: the search box shows the committed label;
|
||||
// focusing clears it to search, blurring restores it (or deselects). ──
|
||||
if (!multi) container._searchSelectLabel = search.value;
|
||||
|
||||
search.addEventListener("focus", function () {
|
||||
search.addEventListener("focus", () => {
|
||||
if (!multi) {
|
||||
// Hide the committed label so the box becomes a fresh search field.
|
||||
search.value = "";
|
||||
@@ -269,15 +299,17 @@
|
||||
}
|
||||
showPanel();
|
||||
});
|
||||
search.addEventListener("input", function () {
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
clearHighlight();
|
||||
if (!multi) container._searchSelectDirty = true;
|
||||
runSearch();
|
||||
});
|
||||
|
||||
if (!multi) {
|
||||
search.addEventListener("blur", function () {
|
||||
search.addEventListener("blur", () => {
|
||||
// Defer so an option click (which fires before blur settles) wins.
|
||||
setTimeout(function () {
|
||||
setTimeout(() => {
|
||||
if (container._searchSelectDirty && search.value.trim() === "") {
|
||||
// User intentionally cleared the box → deselect.
|
||||
pills.innerHTML = "";
|
||||
@@ -293,10 +325,10 @@
|
||||
}
|
||||
|
||||
// ── Keyboard navigation (both form and filter modes) ──
|
||||
search.addEventListener("keydown", function (event) {
|
||||
var key = event.key;
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
|
||||
var visible = getVisibleOptions();
|
||||
search.addEventListener("keydown", (event) => {
|
||||
const { key } = event;
|
||||
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
if (key === "Escape") hidePanel();
|
||||
return;
|
||||
@@ -305,17 +337,17 @@
|
||||
if (key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
var downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(downIdx + 1) % visible.length]);
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
var upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
|
||||
} else if (key === "Enter") {
|
||||
if (highlightedRow) {
|
||||
event.preventDefault();
|
||||
var option = optionFromRow(highlightedRow);
|
||||
const option = optionFromRow(highlightedRow);
|
||||
if (isFilter) {
|
||||
addFilterPill(option, "include");
|
||||
search.value = "";
|
||||
@@ -332,24 +364,24 @@
|
||||
});
|
||||
|
||||
// Clicking an option must not blur the input before the click selects.
|
||||
options.addEventListener("mousedown", function (event) {
|
||||
options.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// ── Option click → select (form mode) or include/exclude (filter mode) ──
|
||||
options.addEventListener("click", function (event) {
|
||||
options.addEventListener("click", (event) => {
|
||||
if (isFilter) {
|
||||
handleFilterOptionClick(event);
|
||||
return;
|
||||
}
|
||||
var row = event.target.closest("[data-search-select-option]");
|
||||
const row = event.target.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
selectOption(optionFromRow(row));
|
||||
});
|
||||
|
||||
function handleFilterOptionClick(event) {
|
||||
const handleFilterOptionClick = (event) => {
|
||||
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
||||
var modifierRow = event.target.closest("[data-search-select-modifier-option]");
|
||||
const modifierRow = event.target.closest("[data-search-select-modifier-option]");
|
||||
if (modifierRow) {
|
||||
setModifier(
|
||||
modifierRow.getAttribute("data-search-select-modifier-option"),
|
||||
@@ -358,85 +390,84 @@
|
||||
return;
|
||||
}
|
||||
// Include / exclude button on a value row.
|
||||
var button = event.target.closest("[data-search-select-action]");
|
||||
const button = event.target.closest("[data-search-select-action]");
|
||||
if (button) {
|
||||
var row = button.closest("[data-search-select-option]");
|
||||
const row = button.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
|
||||
return;
|
||||
}
|
||||
// Click on the option row itself → include.
|
||||
var optionRow = event.target.closest("[data-search-select-option]");
|
||||
const optionRow = event.target.closest("[data-search-select-option]");
|
||||
if (optionRow) {
|
||||
addFilterPill(optionFromRow(optionRow), "include");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
|
||||
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
||||
// persist alongside value pills.
|
||||
function addFilterPill(option, kind) {
|
||||
var modPill = pills.querySelector("[data-search-select-modifier]");
|
||||
const addFilterPill = (option, kind) => {
|
||||
const modPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modPill) {
|
||||
var modVal = modPill.getAttribute("data-search-select-modifier");
|
||||
if (PRESENCE_MODIFIERS.indexOf(modVal) !== -1) {
|
||||
const modVal = modPill.getAttribute("data-search-select-modifier");
|
||||
if (PRESENCE_MODIFIERS.includes(modVal)) {
|
||||
clearModifier();
|
||||
}
|
||||
}
|
||||
var existing = pills.querySelector(
|
||||
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
|
||||
const existing = pills.querySelector(
|
||||
`[data-pill][data-value="${cssEscape(option.value)}"]`
|
||||
);
|
||||
if (existing) existing.remove();
|
||||
pills.appendChild(buildFilterValuePill(option, kind));
|
||||
search.value = "";
|
||||
emitChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
function buildFilterValuePill(option, kind) {
|
||||
var pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
|
||||
const buildFilterValuePill = (option, kind) => {
|
||||
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
|
||||
pill.setAttribute("data-value", option.value);
|
||||
pill.setAttribute("data-label", option.label);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
}
|
||||
};
|
||||
|
||||
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
||||
// value pills — they are mutually exclusive. Non-presence modifiers
|
||||
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
||||
function setModifier(modifierValue, label) {
|
||||
const setModifier = (modifierValue, label) => {
|
||||
// Remove any existing modifier pill to avoid duplicates.
|
||||
clearModifierPill();
|
||||
if (PRESENCE_MODIFIERS.indexOf(modifierValue) !== -1) {
|
||||
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
||||
pills.innerHTML = "";
|
||||
}
|
||||
var pill = cloneTemplate("pill-modifier");
|
||||
const pill = cloneTemplate("pill-modifier");
|
||||
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||
setLabel(pill, label);
|
||||
pills.insertBefore(pill, pills.firstChild);
|
||||
container.setAttribute("data-modifier", modifierValue);
|
||||
hidePanel();
|
||||
emitChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove the modifier pill and its container attribute. Safe to call when
|
||||
// there is no modifier pill (no-op). Does not touch value pills.
|
||||
function clearModifierPill() {
|
||||
var modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
const clearModifierPill = () => {
|
||||
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modifierPill) modifierPill.remove();
|
||||
container.removeAttribute("data-modifier");
|
||||
}
|
||||
};
|
||||
|
||||
function clearModifier() {
|
||||
const clearModifier = () => {
|
||||
clearModifierPill();
|
||||
}
|
||||
};
|
||||
|
||||
function optionFromRow(row) {
|
||||
const optionFromRow = (row) => {
|
||||
if (row._searchSelectOption) return row._searchSelectOption;
|
||||
var data = {};
|
||||
Object.keys(row.dataset).forEach(function (key) {
|
||||
const data = {};
|
||||
Object.keys(row.dataset).forEach(key => {
|
||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||
data[key] = row.dataset[key];
|
||||
}
|
||||
@@ -444,13 +475,13 @@
|
||||
return {
|
||||
value: row.getAttribute("data-value"),
|
||||
label: row.getAttribute("data-label"),
|
||||
data: data,
|
||||
data,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
const selectOption = (option) => {
|
||||
if (multi) {
|
||||
if (!pills.querySelector('input[value="' + cssEscape(option.value) + '"]')) {
|
||||
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
|
||||
addPill(option);
|
||||
}
|
||||
} else {
|
||||
@@ -464,36 +495,36 @@
|
||||
hidePanel();
|
||||
}
|
||||
emitChange(option);
|
||||
}
|
||||
};
|
||||
|
||||
function addPill(option) {
|
||||
var pill = buildPill(option);
|
||||
const addPill = (option) => {
|
||||
const pill = buildPill(option);
|
||||
if (pill) pills.appendChild(pill);
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
}
|
||||
};
|
||||
|
||||
function buildPill(option) {
|
||||
var pill = cloneTemplate("pill");
|
||||
const buildPill = (option) => {
|
||||
const pill = cloneTemplate("pill");
|
||||
if (!pill) return null;
|
||||
pill.setAttribute("data-value", option.value);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
}
|
||||
};
|
||||
|
||||
function buildHidden(value) {
|
||||
var input = document.createElement("input");
|
||||
const buildHidden = (value) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Pill × → remove ──
|
||||
pills.addEventListener("click", function (event) {
|
||||
var removeButton = event.target.closest("[data-pill-remove]");
|
||||
pills.addEventListener("click", (event) => {
|
||||
const removeButton = event.target.closest("[data-pill-remove]");
|
||||
if (!removeButton) return;
|
||||
var pill = removeButton.closest("[data-pill]");
|
||||
const pill = removeButton.closest("[data-pill]");
|
||||
if (!pill) return;
|
||||
if (isFilter) {
|
||||
// Filter pills have no hidden input.
|
||||
@@ -505,86 +536,79 @@
|
||||
emitChange(null);
|
||||
return;
|
||||
}
|
||||
var value = pill.getAttribute("data-value");
|
||||
const value = pill.getAttribute("data-value");
|
||||
pill.remove();
|
||||
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
|
||||
const 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;
|
||||
}
|
||||
);
|
||||
}
|
||||
const currentValues = () => {
|
||||
return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value);
|
||||
};
|
||||
|
||||
function emitChange(last) {
|
||||
var values = currentValues();
|
||||
const emitChange = (last) => {
|
||||
const values = currentValues();
|
||||
if (syncUrl) syncToUrl(values);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("search-select:change", {
|
||||
bubbles: true,
|
||||
detail: { name: name, values: values, last: last },
|
||||
detail: { name, values, last },
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function syncToUrl(values) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
const syncToUrl = (values) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
values.forEach(function (v) {
|
||||
values.forEach(v => {
|
||||
params.append(name, v);
|
||||
});
|
||||
var qs = params.toString();
|
||||
history.replaceState(null, "", qs ? "?" + qs : window.location.pathname);
|
||||
}
|
||||
const 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) {
|
||||
const initial = new URLSearchParams(window.location.search).getAll(name);
|
||||
initial.forEach(v => {
|
||||
addPill({ value: v, label: v, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Close panel on outside click ──
|
||||
document.addEventListener("click", function (event) {
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!container.contains(event.target)) hidePanel();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Minimal escape for use inside an attribute-value selector. */
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
const cssEscape = (value) => String(value).replace(/["\\]/g, "\\$&");
|
||||
|
||||
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||
// widgets expose data-included / data-excluded / data-modifier for the filter
|
||||
// bar to read.
|
||||
window.readSearchSelect = function (form) {
|
||||
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
||||
var pills = container.querySelector("[data-search-select-pills]");
|
||||
window.readSearchSelect = (form) => {
|
||||
form.querySelectorAll("[data-search-select]").forEach(container => {
|
||||
const pills = container.querySelector("[data-search-select-pills]");
|
||||
if (container.getAttribute("data-search-select-mode") === "filter") {
|
||||
var included = [];
|
||||
var excluded = [];
|
||||
var modifier = "";
|
||||
const included = [];
|
||||
const excluded = [];
|
||||
let modifier = "";
|
||||
if (pills) {
|
||||
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
|
||||
var pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||
pills.querySelectorAll("[data-pill]").forEach(pill => {
|
||||
const pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||
if (pillModifier) {
|
||||
modifier = pillModifier; // last modifier pill wins
|
||||
return; // skip value extraction for this pill
|
||||
}
|
||||
var value = pill.getAttribute("data-value");
|
||||
var label = pill.getAttribute("data-label") || "";
|
||||
const value = pill.getAttribute("data-value");
|
||||
const label = pill.getAttribute("data-label") || "";
|
||||
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
||||
excluded.push({id: value, label: label});
|
||||
excluded.push({ id: value, label });
|
||||
} else {
|
||||
included.push({id: value, label: label});
|
||||
included.push({ id: value, label });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -594,13 +618,8 @@
|
||||
else container.removeAttribute("data-modifier");
|
||||
return;
|
||||
}
|
||||
var values = pills
|
||||
? Array.prototype.map.call(
|
||||
pills.querySelectorAll('input[type="hidden"]'),
|
||||
function (input) {
|
||||
return input.value;
|
||||
}
|
||||
)
|
||||
const values = pills
|
||||
? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
|
||||
: [];
|
||||
container.setAttribute("data-values", JSON.stringify(values));
|
||||
});
|
||||
|
||||
@@ -133,6 +133,15 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
self.assertLess(options, option_row)
|
||||
self.assertLess(option_row, no_results)
|
||||
|
||||
def test_prefetch_attribute_and_defaults(self):
|
||||
# Default prefetch is 0 in SearchSelect
|
||||
html_default = SearchSelect(name="t")
|
||||
self.assertIn('data-prefetch="0"', html_default)
|
||||
|
||||
# Custom prefetch is rendered
|
||||
html_custom = SearchSelect(name="t", prefetch=42)
|
||||
self.assertIn('data-prefetch="42"', html_custom)
|
||||
|
||||
|
||||
class FilterSelectComponentTest(unittest.TestCase):
|
||||
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
|
||||
|
||||
Reference in New Issue
Block a user