Add a shared searchable multiselect combobox for team/member pickers

Extracts the "Grant role" member picker's typeahead combobox into a
reusable static/js/searchable-select.js (opt in via data-searchable on
the widget), and reuses it for the news form's teams field -- a proper
multiselect with removable pills and filter-as-you-type, replacing the
plain checkbox list. Needed forwarding widget attrs through the shared
select template, which never passed them to the rendered <select>.
This commit is contained in:
2026-08-03 21:36:35 +02:00
parent 38947196c1
commit d9b4337319
6 changed files with 181 additions and 92 deletions

View File

@@ -3900,6 +3900,18 @@
}
}
}
.empty\:hidden {
&:empty {
display: none;
}
}
.hover\:opacity-100 {
&:hover {
@media (hover: hover) {
opacity: 100%;
}
}
}
.sm\:hidden {
@media (width >= 40rem) {
display: none;

View File

@@ -0,0 +1,152 @@
/*
* Progressive enhancement for a plain <select data-searchable> (single or
* `multiple`): a small typeahead combobox, a select2-alike without the
* dependency. Type to filter, click or Enter to pick; the hidden <select>
* still carries the actual value(s) the form submits, so this degrades to a
* normal dropdown with no JS. A `multiple` select gets removable chips for
* each pick instead of replacing its own value.
*
* Opt in with `data-searchable="true"` on the widget; `data-search-placeholder`
* sets the search input's placeholder (see management/forms.py).
*/
(() => {
function enhance(select) {
const isMultiple = select.multiple;
const options = Array.from(select.options).filter((option) => option.value !== "");
const wrapper = document.createElement("div");
wrapper.className = "relative";
const chips = document.createElement("div");
chips.className = "flex flex-wrap gap-1 empty:hidden mb-3";
const input = document.createElement("input");
input.type = "text";
input.className = "input input-bordered w-full";
input.placeholder = select.dataset.searchPlaceholder || "";
input.autocomplete = "off";
const list = document.createElement("ul");
list.className = "menu absolute z-10 mt-1 w-full rounded-box bg-base-100 shadow max-h-60 overflow-y-auto flex-nowrap hidden";
select.parentNode.insertBefore(wrapper, select);
if (isMultiple) {
wrapper.append(chips, input, list, select);
} else {
wrapper.append(input, list, select);
}
select.classList.add("hidden");
let highlighted = -1;
const renderChips = () => {
chips.innerHTML = "";
options.filter((option) => option.selected).forEach((option) => {
const chip = document.createElement("span");
chip.className = "badge badge-neutral gap-1";
chip.textContent = option.text;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "opacity-70 hover:opacity-100";
remove.setAttribute("aria-label", "Remove");
remove.textContent = "×";
remove.addEventListener("mousedown", (event) => {
// mousedown, not click: it fires before the input's blur.
event.preventDefault();
option.selected = false;
renderChips();
// Only refresh the dropdown if it was already open (actively
// searching) -- removing a chip must never pop it open on its
// own, with no way to close it again short of a page reload.
if (!list.classList.contains("hidden")) {
render(input.value);
}
select.dispatchEvent(new Event("change"));
});
chip.appendChild(remove);
chips.appendChild(chip);
});
};
const choose = (option) => {
if (isMultiple) {
option.selected = true;
renderChips();
input.value = "";
render("");
input.focus();
} else {
select.value = option.value;
input.value = option.text;
list.classList.add("hidden");
}
select.dispatchEvent(new Event("change"));
};
const render = (query) => {
const needle = query.trim().toLowerCase();
const candidates = isMultiple ? options.filter((option) => !option.selected) : options;
const matches = needle ? candidates.filter((option) => option.text.toLowerCase().includes(needle)) : candidates;
list.innerHTML = "";
matches.forEach((option) => {
const item = document.createElement("li");
const link = document.createElement("a");
link.textContent = option.text;
link.addEventListener("mousedown", (event) => {
event.preventDefault();
choose(option);
});
item.appendChild(link);
list.appendChild(item);
});
highlighted = -1;
list.classList.toggle("hidden", matches.length === 0);
return matches;
};
input.addEventListener("input", () => render(input.value));
input.addEventListener("focus", () => render(input.value));
input.addEventListener("blur", () => list.classList.add("hidden"));
input.addEventListener("keydown", (event) => {
const items = Array.from(list.querySelectorAll("a"));
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!items.length) return;
highlighted = event.key === "ArrowDown" ? (highlighted + 1) % items.length : (highlighted - 1 + items.length) % items.length;
items.forEach((item, index) => item.classList.toggle("menu-active", index === highlighted));
items[highlighted].scrollIntoView({ block: "nearest" });
} else if (event.key === "Enter" && highlighted >= 0 && items[highlighted]) {
event.preventDefault();
items[highlighted].dispatchEvent(new Event("mousedown"));
} else if (event.key === "Escape") {
list.classList.add("hidden");
} else if (isMultiple && event.key === "Backspace" && input.value === "") {
// Removes the most recently added chip, matching how tag inputs
// elsewhere (e.g. Gmail's "To" field) treat Backspace on empty text.
const selected = options.filter((option) => option.selected);
if (selected.length) {
selected[selected.length - 1].selected = false;
renderChips();
render("");
select.dispatchEvent(new Event("change"));
}
}
});
if (isMultiple) {
renderChips();
} else if (select.value) {
// Redisplayed after a validation error elsewhere in the form: keep
// whatever was already chosen visible in the text input.
const selected = options.find((option) => option.value === select.value);
if (selected) input.value = selected.text;
}
}
document.querySelectorAll("select[data-searchable]").forEach(enhance);
})();