diff --git a/controlpanel/templates/templatetags/field.html b/controlpanel/templates/templatetags/field.html index 8930650..d69077f 100644 --- a/controlpanel/templates/templatetags/field.html +++ b/controlpanel/templates/templatetags/field.html @@ -59,6 +59,7 @@ name="{{ field.html_name }}" id="{{ field.auto_id }}" {% if field.widget_type == "selectmultiple" %}multiple{% endif %} + {% for attr, value in field.field.widget.attrs.items %}{{ attr }}="{{ value }}" {% endfor %} > {% if field.widget_type == "selectmultiple" %} diff --git a/management/forms.py b/management/forms.py index 6ff8f45..bf50e65 100644 --- a/management/forms.py +++ b/management/forms.py @@ -47,6 +47,7 @@ class ClubRoleAssignForm(forms.ModelForm): class Meta: model = ClubRole 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): super().__init__(*args, **kwargs) @@ -155,7 +156,10 @@ class NewsForm(forms.ModelForm): class Meta: model = News 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): super().__init__(*args, **kwargs) diff --git a/management/templates/management/news_form.html b/management/templates/management/news_form.html index 83918b1..2e69760 100644 --- a/management/templates/management/news_form.html +++ b/management/templates/management/news_form.html @@ -1,5 +1,5 @@ {% 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 %} @@ -15,6 +15,10 @@ {% endfor %} + {% if not update_view %} +

{% trans "Photos can be added once the news item is created." %}

+ {% endif %} +
{% for field in form %} {% form_field field %} @@ -29,3 +33,7 @@
{% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/role_list.html b/management/templates/management/role_list.html index 3a35795..7a0d20b 100644 --- a/management/templates/management/role_list.html +++ b/management/templates/management/role_list.html @@ -1,5 +1,5 @@ {% extends "management/base.html" %} -{% load i18n lucide %} +{% load i18n lucide static %} {% comment %} One section per non-MEMBER role (management.views.ClubRoleListView) -- the @@ -62,93 +62,5 @@ {% endblock panel %} {% block extra_body %} - {% trans "Type a name to search..." as search_placeholder %} - + {% endblock extra_body %} diff --git a/static/css/app.css b/static/css/app.css index 65efb5b..4b2b18f 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; diff --git a/static/js/searchable-select.js b/static/js/searchable-select.js new file mode 100644 index 0000000..a08fb8f --- /dev/null +++ b/static/js/searchable-select.js @@ -0,0 +1,152 @@ +/* + * Progressive enhancement for a plain + * 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); +})();