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:
@@ -59,6 +59,7 @@
|
|||||||
name="{{ field.html_name }}"
|
name="{{ field.html_name }}"
|
||||||
id="{{ field.auto_id }}"
|
id="{{ field.auto_id }}"
|
||||||
{% if field.widget_type == "selectmultiple" %}multiple{% endif %}
|
{% if field.widget_type == "selectmultiple" %}multiple{% endif %}
|
||||||
|
{% for attr, value in field.field.widget.attrs.items %}{{ attr }}="{{ value }}" {% endfor %}
|
||||||
>
|
>
|
||||||
|
|
||||||
{% if field.widget_type == "selectmultiple" %}
|
{% if field.widget_type == "selectmultiple" %}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class ClubRoleAssignForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ClubRole
|
model = ClubRole
|
||||||
fields = ["member", "role"]
|
fields = ["member", "role"]
|
||||||
|
widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})}
|
||||||
|
|
||||||
def __init__(self, *args, club=None, **kwargs):
|
def __init__(self, *args, club=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -155,7 +156,10 @@ class NewsForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = News
|
model = News
|
||||||
fields = ["title", "teams", "visibility", "body"]
|
fields = ["title", "teams", "visibility", "body"]
|
||||||
widgets = {"teams": forms.CheckboxSelectMultiple, "body": forms.Textarea(attrs={"rows": 8})}
|
widgets = {
|
||||||
|
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type to filter teams...")}),
|
||||||
|
"body": forms.Textarea(attrs={"rows": 8}),
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, *args, club=None, **kwargs):
|
def __init__(self, *args, club=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "management/base.html" %}
|
{% extends "management/base.html" %}
|
||||||
{% load i18n lucide ui %}
|
{% load i18n lucide static ui %}
|
||||||
|
|
||||||
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New news item" %}{% endif %}{% endblock heading %}
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New news item" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
@@ -15,6 +15,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not update_view %}
|
||||||
|
<p class="opacity-70 text-sm mb-2">{% trans "Photos can be added once the news item is created." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{% form_field field %}
|
{% form_field field %}
|
||||||
@@ -29,3 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||||
|
{% endblock extra_body %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "management/base.html" %}
|
{% extends "management/base.html" %}
|
||||||
{% load i18n lucide %}
|
{% load i18n lucide static %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
One section per non-MEMBER role (management.views.ClubRoleListView) -- the
|
One section per non-MEMBER role (management.views.ClubRoleListView) -- the
|
||||||
@@ -62,93 +62,5 @@
|
|||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|
||||||
{% block extra_body %}
|
{% block extra_body %}
|
||||||
{% trans "Type a name to search..." as search_placeholder %}
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
// Progressive enhancement: the plain <select name="member"> in the "Grant
|
|
||||||
// role" modal still works with no JS -- this hides it and drives it from a
|
|
||||||
// small typeahead combobox instead (a select2-alike without the dependency):
|
|
||||||
// type to filter, click or Enter to pick, the hidden <select> still carries
|
|
||||||
// the actual value the form submits.
|
|
||||||
const select = document.querySelector("#grant_role_modal select[name='member']");
|
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
const options = Array.from(select.options).filter((option) => option.value !== "");
|
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "relative";
|
|
||||||
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "text";
|
|
||||||
input.className = "input input-bordered w-full";
|
|
||||||
input.placeholder = "{{ search_placeholder }}";
|
|
||||||
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);
|
|
||||||
wrapper.append(input, list, select);
|
|
||||||
select.classList.add("hidden");
|
|
||||||
|
|
||||||
let highlighted = -1;
|
|
||||||
|
|
||||||
const choose = (option) => {
|
|
||||||
select.value = option.value;
|
|
||||||
input.value = option.text;
|
|
||||||
list.classList.add("hidden");
|
|
||||||
};
|
|
||||||
|
|
||||||
const render = (query) => {
|
|
||||||
const needle = query.trim().toLowerCase();
|
|
||||||
const matches = needle ? options.filter((option) => option.text.toLowerCase().includes(needle)) : options;
|
|
||||||
|
|
||||||
list.innerHTML = "";
|
|
||||||
matches.forEach((option) => {
|
|
||||||
const item = document.createElement("li");
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.textContent = option.text;
|
|
||||||
// mousedown, not click: it fires before the input's blur, so the
|
|
||||||
// list is still in the DOM (and un-hidden) when the pick is applied.
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redisplayed after a validation error elsewhere in the modal: keep
|
|
||||||
// whatever member was already chosen visible in the text input.
|
|
||||||
if (select.value) {
|
|
||||||
const selected = options.find((option) => option.value === select.value);
|
|
||||||
if (selected) input.value = selected.text;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock extra_body %}
|
{% endblock extra_body %}
|
||||||
|
|||||||
@@ -3900,6 +3900,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.empty\:hidden {
|
||||||
|
&:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hover\:opacity-100 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:hidden {
|
.sm\:hidden {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
152
static/js/searchable-select.js
Normal file
152
static/js/searchable-select.js
Normal 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);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user