Wire filter-mode behavior into search_select.js
Dispatch on data-ss-mode: in filter mode, value rows (server-rendered or fetched via buildRow) carry +/- buttons that add include/exclude pills, and pinned modifier pseudo-options set a lone, mutually-exclusive modifier pill. Pill removal handles the modifier flag; filter pills carry no hidden inputs. Extend readSearchSelect to serialise filter widgets into data-included / data-excluded / data-modifier (the shape the filter bar consumes), leaving form widgets' data-values path unchanged. JS class strings mirror the FilterSelect constants. https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
@@ -6,12 +6,18 @@
|
|||||||
* focus clears it to search, picking an option fills it), with a lone hidden
|
* focus clears it to search, picking an option fills it), with a lone hidden
|
||||||
* <input> carrying the value. Both keep hidden inputs so Django validation works.
|
* <input> carrying the value. Both keep hidden inputs so Django validation works.
|
||||||
*
|
*
|
||||||
|
* Filter mode (data-ss-mode="filter", rendered by FilterSelect): value rows
|
||||||
|
* carry +/− buttons that add include (✓) / exclude (✗) pills, plus pinned
|
||||||
|
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
|
||||||
|
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||||
|
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||||
|
*
|
||||||
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
|
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
|
||||||
* each widget guarded with el._ssInit.
|
* each widget guarded with el._ssInit.
|
||||||
*
|
*
|
||||||
* The pill / option class strings below are kept byte-identical to the Python
|
* The pill / option class strings below are kept byte-identical to the Python
|
||||||
* Pill / SearchSelect components so Tailwind generates the classes and
|
* Pill / SearchSelect / FilterSelect components so Tailwind generates the classes
|
||||||
* server-rendered and JS-created pills are indistinguishable.
|
* and server-rendered and JS-created rows/pills are indistinguishable.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -24,6 +30,26 @@
|
|||||||
var OPTION_ROW_CLASS =
|
var OPTION_ROW_CLASS =
|
||||||
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
|
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
|
||||||
|
|
||||||
|
// Filter-mode class strings — byte-identical to the FilterSelect constants in
|
||||||
|
// common/components/search_select.py.
|
||||||
|
var FILTER_INCLUDE_PILL_CLASS =
|
||||||
|
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
|
||||||
|
"bg-brand/15 text-heading";
|
||||||
|
var FILTER_EXCLUDE_PILL_CLASS =
|
||||||
|
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
|
||||||
|
"bg-red-500/15 text-red-600 line-through decoration-red-400";
|
||||||
|
var FILTER_MODIFIER_PILL_CLASS =
|
||||||
|
"inline-flex items-center px-2 py-0.5 text-sm rounded " +
|
||||||
|
"bg-amber-500/15 text-amber-600 cursor-pointer";
|
||||||
|
var FILTER_OPTION_ROW_CLASS =
|
||||||
|
"flex items-center justify-between px-2 py-1 rounded text-sm " +
|
||||||
|
"hover:bg-neutral-secondary-strong cursor-pointer";
|
||||||
|
var FILTER_OPTION_LABEL_CLASS = "truncate";
|
||||||
|
var FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0";
|
||||||
|
var FILTER_ACTION_BUTTON_CLASS =
|
||||||
|
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded " +
|
||||||
|
"border border-default-medium hover:bg-brand hover:text-white hover:border-brand";
|
||||||
|
|
||||||
var DEBOUNCE_MS = 500;
|
var DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
function initAll() {
|
function initAll() {
|
||||||
@@ -42,6 +68,7 @@
|
|||||||
|
|
||||||
var name = container.getAttribute("data-name");
|
var name = container.getAttribute("data-name");
|
||||||
var searchUrl = container.getAttribute("data-search-url");
|
var searchUrl = container.getAttribute("data-search-url");
|
||||||
|
var isFilter = container.getAttribute("data-ss-mode") === "filter";
|
||||||
var multi = container.getAttribute("data-multi") === "true";
|
var multi = container.getAttribute("data-multi") === "true";
|
||||||
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||||
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
|
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
|
||||||
@@ -76,6 +103,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRow(option) {
|
function buildRow(option) {
|
||||||
|
if (isFilter) return buildFilterOptionRow(option);
|
||||||
var row = document.createElement("div");
|
var row = document.createElement("div");
|
||||||
row.setAttribute("data-ss-option", "");
|
row.setAttribute("data-ss-option", "");
|
||||||
row.setAttribute("data-value", option.value);
|
row.setAttribute("data-value", option.value);
|
||||||
@@ -90,6 +118,44 @@
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Filter-mode value row: label + include/exclude buttons (mirrors the
|
||||||
|
// Python _filter_option_row so fetched and server-rendered rows match). ──
|
||||||
|
function buildFilterOptionRow(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 = FILTER_OPTION_ROW_CLASS;
|
||||||
|
var data = option.data || {};
|
||||||
|
Object.keys(data).forEach(function (key) {
|
||||||
|
row.setAttribute("data-" + key, data[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
var labelSpan = document.createElement("span");
|
||||||
|
labelSpan.className = FILTER_OPTION_LABEL_CLASS;
|
||||||
|
labelSpan.textContent = option.label;
|
||||||
|
|
||||||
|
var buttons = document.createElement("span");
|
||||||
|
buttons.className = FILTER_OPTION_BUTTONS_CLASS;
|
||||||
|
buttons.appendChild(buildActionButton("include", "+", "Include"));
|
||||||
|
buttons.appendChild(buildActionButton("exclude", "−", "Exclude"));
|
||||||
|
|
||||||
|
row.appendChild(labelSpan);
|
||||||
|
row.appendChild(buttons);
|
||||||
|
row._ssOption = option;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActionButton(action, symbol, title) {
|
||||||
|
var button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.setAttribute("data-ss-action", action);
|
||||||
|
button.className = FILTER_ACTION_BUTTON_CLASS;
|
||||||
|
button.title = title;
|
||||||
|
button.textContent = symbol;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Client-side filter of the currently loaded rows. Returns the number of
|
// ── 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. ──
|
// visible rows so the caller decides whether to show the no-results node. ──
|
||||||
function filterRows(query) {
|
function filterRows(query) {
|
||||||
@@ -200,14 +266,96 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Option click → select ──
|
// ── Option click → select (form mode) or include/exclude (filter mode) ──
|
||||||
options.addEventListener("click", function (e) {
|
options.addEventListener("click", function (e) {
|
||||||
|
if (isFilter) {
|
||||||
|
handleFilterOptionClick(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var row = e.target.closest("[data-ss-option]");
|
var row = e.target.closest("[data-ss-option]");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
var option = optionFromRow(row);
|
selectOption(optionFromRow(row));
|
||||||
selectOption(option);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleFilterOptionClick(e) {
|
||||||
|
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
||||||
|
var modifierRow = e.target.closest("[data-ss-modifier-option]");
|
||||||
|
if (modifierRow) {
|
||||||
|
setModifier(
|
||||||
|
modifierRow.getAttribute("data-ss-modifier-option"),
|
||||||
|
modifierRow.getAttribute("data-label")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Include / exclude button on a value row.
|
||||||
|
var button = e.target.closest("[data-ss-action]");
|
||||||
|
if (!button) return;
|
||||||
|
var row = button.closest("[data-ss-option]");
|
||||||
|
if (!row) return;
|
||||||
|
addFilterPill(optionFromRow(row), button.getAttribute("data-ss-action"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||||
|
// clears an active modifier — the two are mutually exclusive.
|
||||||
|
function addFilterPill(option, kind) {
|
||||||
|
clearModifier();
|
||||||
|
var existing = pills.querySelector(
|
||||||
|
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
|
||||||
|
);
|
||||||
|
if (existing) existing.remove();
|
||||||
|
pills.appendChild(buildFilterValuePill(option, kind));
|
||||||
|
emitChange(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilterValuePill(option, kind) {
|
||||||
|
var pill = document.createElement("span");
|
||||||
|
pill.className =
|
||||||
|
kind === "include" ? FILTER_INCLUDE_PILL_CLASS : FILTER_EXCLUDE_PILL_CLASS;
|
||||||
|
pill.setAttribute("data-pill", "");
|
||||||
|
pill.setAttribute("data-value", option.value);
|
||||||
|
pill.setAttribute("data-label", option.label);
|
||||||
|
pill.setAttribute("data-ss-type", kind);
|
||||||
|
var data = option.data || {};
|
||||||
|
Object.keys(data).forEach(function (key) {
|
||||||
|
pill.setAttribute("data-" + key, data[key]);
|
||||||
|
});
|
||||||
|
var symbol = kind === "include" ? "✓" : "✗";
|
||||||
|
pill.appendChild(document.createTextNode(symbol + " " + option.label));
|
||||||
|
pill.appendChild(buildRemoveButton());
|
||||||
|
return pill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the lone modifier pill, clearing all value pills (mutual exclusivity).
|
||||||
|
function setModifier(modifierValue, label) {
|
||||||
|
pills.innerHTML = "";
|
||||||
|
var pill = document.createElement("span");
|
||||||
|
pill.className = FILTER_MODIFIER_PILL_CLASS;
|
||||||
|
pill.setAttribute("data-pill", "");
|
||||||
|
pill.setAttribute("data-ss-modifier", modifierValue);
|
||||||
|
pill.appendChild(document.createTextNode(label));
|
||||||
|
pill.appendChild(buildRemoveButton());
|
||||||
|
pills.appendChild(pill);
|
||||||
|
container.setAttribute("data-modifier", modifierValue);
|
||||||
|
hidePanel();
|
||||||
|
emitChange(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearModifier() {
|
||||||
|
var modifierPill = pills.querySelector("[data-ss-modifier]");
|
||||||
|
if (modifierPill) modifierPill.remove();
|
||||||
|
container.removeAttribute("data-modifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoveButton() {
|
||||||
|
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 = "×";
|
||||||
|
return remove;
|
||||||
|
}
|
||||||
|
|
||||||
function optionFromRow(row) {
|
function optionFromRow(row) {
|
||||||
if (row._ssOption) return row._ssOption;
|
if (row._ssOption) return row._ssOption;
|
||||||
var data = {};
|
var data = {};
|
||||||
@@ -280,6 +428,16 @@
|
|||||||
if (!removeBtn) return;
|
if (!removeBtn) return;
|
||||||
var pill = removeBtn.closest("[data-pill]");
|
var pill = removeBtn.closest("[data-pill]");
|
||||||
if (!pill) return;
|
if (!pill) return;
|
||||||
|
if (isFilter) {
|
||||||
|
// Filter pills have no hidden input; a modifier pill also clears the
|
||||||
|
// container flag.
|
||||||
|
if (pill.hasAttribute("data-ss-modifier")) {
|
||||||
|
container.removeAttribute("data-modifier");
|
||||||
|
}
|
||||||
|
pill.remove();
|
||||||
|
emitChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var value = pill.getAttribute("data-value");
|
var value = pill.getAttribute("data-value");
|
||||||
pill.remove();
|
pill.remove();
|
||||||
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
|
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
|
||||||
@@ -336,11 +494,38 @@
|
|||||||
return String(value).replace(/["\\]/g, "\\$&");
|
return String(value).replace(/["\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward-looking hook (parallels readSelectableFilters): write each widget's
|
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||||
// current values to a data-values JSON attribute.
|
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||||
|
// widgets (parallel to readSelectableFilters) expose data-included /
|
||||||
|
// data-excluded / data-modifier for the filter bar to read.
|
||||||
window.readSearchSelect = function (form) {
|
window.readSearchSelect = function (form) {
|
||||||
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
||||||
var pills = container.querySelector("[data-ss-pills]");
|
var pills = container.querySelector("[data-ss-pills]");
|
||||||
|
if (container.getAttribute("data-ss-mode") === "filter") {
|
||||||
|
var included = [];
|
||||||
|
var excluded = [];
|
||||||
|
var modifier = "";
|
||||||
|
if (pills) {
|
||||||
|
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
|
||||||
|
var pillModifier = pill.getAttribute("data-ss-modifier");
|
||||||
|
if (pillModifier) {
|
||||||
|
modifier = pillModifier;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var value = pill.getAttribute("data-value");
|
||||||
|
if (pill.getAttribute("data-ss-type") === "exclude") {
|
||||||
|
excluded.push(value);
|
||||||
|
} else {
|
||||||
|
included.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
container.setAttribute("data-included", JSON.stringify(included));
|
||||||
|
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||||
|
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||||
|
else container.removeAttribute("data-modifier");
|
||||||
|
return;
|
||||||
|
}
|
||||||
var values = pills
|
var values = pills
|
||||||
? Array.prototype.map.call(
|
? Array.prototype.map.call(
|
||||||
pills.querySelectorAll('input[type="hidden"]'),
|
pills.querySelectorAll('input[type="hidden"]'),
|
||||||
|
|||||||
Reference in New Issue
Block a user