Skip to content

Commit fca8aa7

Browse files
committed
feat: add _field_combo_multi.html and use it for category field in _library_filter.html
1 parent d886537 commit fca8aa7

File tree

4 files changed

+189
-11
lines changed

4 files changed

+189
-11
lines changed

core/views.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,15 @@ def get_context_data(self, **kwargs):
11681168
("math", "Math & Numerics"),
11691169
("networking", "Networking"),
11701170
]
1171+
context["demo_combo_multi_tags"] = [
1172+
("algorithms", "Algorithms"),
1173+
("containers", "Containers"),
1174+
("io", "I/O"),
1175+
("math", "Math & Numerics"),
1176+
("networking", "Networking"),
1177+
("testing", "Testing"),
1178+
("concurrency", "Concurrency"),
1179+
]
11711180
badge_img = f"{settings.STATIC_URL}img/v3/badges"
11721181
context["badge_icon_srcs"] = [
11731182
f"{badge_img}/badge-first-place.png",
@@ -1331,11 +1340,10 @@ def get_context_data(self, **kwargs):
13311340
"width": "narrow",
13321341
},
13331342
{
1334-
"type": "combo",
1343+
"type": "combo_multi",
13351344
"name": "category",
13361345
"label": "Category",
13371346
"options": [
1338-
("all", "All"),
13391347
("algorithms", "Algorithms"),
13401348
("asynchronous", "Asynchronous"),
13411349
("awaitables", "Awaitables"),
@@ -1349,7 +1357,6 @@ def get_context_data(self, **kwargs):
13491357
("formatting", "Formatting"),
13501358
("graphics", "Graphics"),
13511359
],
1352-
"selected": "all",
13531360
"width": "wide",
13541361
"placeholder": "Search",
13551362
},

templates/v3/examples/_v3_example_section.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ <h3>{{ section_title }}</h3>
387387
{% include "v3/includes/_field_checkbox.html" with name="ex_agree" label="I agree to the terms and conditions" %}
388388
{% include "v3/includes/_field_combo.html" with name="ex_library" label="Combo (searchable)" placeholder="Search libraries..." options=demo_libs %}
389389
{% include "v3/includes/_field_multiselect.html" with name="ex_categories" label="Multi-select" placeholder="Select categories..." options=demo_cats %}
390+
{% include "v3/includes/_field_combo_multi.html" with name="ex_combo_multi" label="Combo multi-select (searchable)" placeholder="Search and select..." options=demo_combo_multi_tags %}
390391
</div>
391392
</div>
392393
</div>
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
{% comment %}
2+
V3 combo multi-select (searchable dropdown with multi-selection).
3+
Variables:
4+
name (required) — input name attribute (submitted as multiple values via getlist)
5+
label (optional) — label text
6+
placeholder (optional) — placeholder text when nothing selected / search input hint
7+
options (required) — list of (value, label) 2-tuples (e.g. from TextChoices.choices)
8+
selected_values (optional) — list of pre-selected values, e.g. ["a","b"]
9+
help_text (optional) — help text below the field
10+
error (optional) — error message (activates error state)
11+
required (optional) — if truthy, adds required attribute
12+
extra_class (optional) — additional classes on the wrapper
13+
Usage:
14+
{% include "v3/includes/_field_combo_multi.html" with name="tags" label="Tags" options=tags_options placeholder="Search tags..." %}
15+
{% endcomment %}
16+
{% load text_helpers %}
17+
<script type="application/json" id="field-{{ name }}-options">{{ options|to_json }}</script>
18+
{% if selected_values %}
19+
<script type="application/json" id="field-{{ name }}-selected">{{ selected_values|to_json }}</script>
20+
{% endif %}
21+
<div class="field{% if error %} field--error{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}"
22+
{# djlint:off #}
23+
x-data="{
24+
jsReady: false,
25+
open: false,
26+
query: '',
27+
selectedValues: [],
28+
options: [],
29+
get filtered() {
30+
if (!this.query) return this.options;
31+
const q = this.query.toLowerCase();
32+
return this.options.filter(o => o.label.toLowerCase().includes(q));
33+
},
34+
get displayText() {
35+
if (this.selectedValues.length === 0) return '';
36+
return this.options
37+
.filter(o => this.selectedValues.includes(o.value))
38+
.map(o => o.label)
39+
.join(', ');
40+
},
41+
init() {
42+
this.jsReady = true;
43+
this.options = JSON.parse(document.getElementById('field-{{ name }}-options').textContent);
44+
const selEl = document.getElementById('field-{{ name }}-selected');
45+
if (selEl) this.selectedValues = JSON.parse(selEl.textContent);
46+
},
47+
toggle(value) {
48+
const idx = this.selectedValues.indexOf(value);
49+
if (idx === -1) {
50+
this.selectedValues.push(value);
51+
} else {
52+
this.selectedValues.splice(idx, 1);
53+
}
54+
},
55+
isSelected(value) {
56+
return this.selectedValues.includes(value);
57+
},
58+
toggleOpen() {
59+
if (this.open) {
60+
this.open = false;
61+
} else {
62+
this.open = true;
63+
this.query = '';
64+
this.$nextTick(() => this.$refs.searchInput.focus());
65+
}
66+
}
67+
}">
68+
{# djlint:on #}
69+
{% if label %}<label class="field__label" id="field-{{ name }}-label">{{ label }}</label>{% endif %}
70+
<select class="field__select"
71+
multiple
72+
name="{{ name }}"
73+
{% if required %}required{% endif %}
74+
{% if label %}aria-labelledby="field-{{ name }}-label"{% endif %}
75+
{% if error %}aria-invalid="true" aria-describedby="field-{{ name }}-error"{% elif help_text %}aria-describedby="field-{{ name }}-help"{% endif %}
76+
x-show="!jsReady"
77+
:disabled="jsReady">
78+
{% for value, label in options %}
79+
<option value="{{ value }}"{% if value in selected_values %} selected{% endif %}>{{ label }}</option>
80+
{% endfor %}
81+
</select>
82+
<div class="dropdown dropdown--combo" :class="{ 'dropdown--open': open }" x-cloak>
83+
<div class="dropdown__trigger"
84+
:class="{ 'dropdown__trigger--active': open }"
85+
@click="if (!open) toggleOpen()"
86+
{% if label %}aria-labelledby="field-{{ name }}-label"{% endif %}>
87+
<!-- Closed state: show selected values or placeholder -->
88+
<template x-if="!open">
89+
<span class="multiselect__trigger-text">
90+
<span x-show="selectedValues.length > 0"
91+
x-text="displayText"></span>
92+
<span class="dropdown__trigger-placeholder"
93+
x-show="selectedValues.length === 0">{{ placeholder|default:"Search..." }}</span>
94+
</span>
95+
</template>
96+
<!-- Open state: show search icon + input -->
97+
<template x-if="open">
98+
<span class="combo__search-row">
99+
{% include "includes/icon.html" with icon_name="search" icon_class="combo__search-icon" %}
100+
<input type="text"
101+
class="combo__search-input"
102+
x-ref="searchInput"
103+
x-model="query"
104+
@keydown.escape.prevent="open = false; setTimeout(() => $refs.chevronBtn.focus())"
105+
@keydown.arrow-down.prevent="$refs.list.querySelector('[role=option]')?.focus()"
106+
placeholder="{{ placeholder|default:'Type to search...' }}"
107+
aria-autocomplete="list"
108+
aria-label="Search options">
109+
</span>
110+
</template>
111+
<!-- Chevron: always present, animates via .dropdown--open -->
112+
<button type="button"
113+
class="combo__chevron-btn"
114+
x-ref="chevronBtn"
115+
@click.stop="toggleOpen()"
116+
:aria-expanded="open"
117+
aria-label="Toggle search">
118+
{% include "includes/icon.html" with icon_name="chevron-down" icon_class="dropdown__chevron" %}
119+
</button>
120+
</div>
121+
<div class="dropdown__panel"
122+
x-show="open"
123+
@click.away="open = false"
124+
@keydown.escape.prevent="open = false; setTimeout(() => $refs.chevronBtn.focus())"
125+
x-transition:enter="transition ease-out duration-100"
126+
x-transition:enter-start="opacity-0"
127+
x-transition:enter-end="opacity-100"
128+
x-transition:leave="transition ease-in duration-75"
129+
x-transition:leave-start="opacity-100"
130+
x-transition:leave-end="opacity-0"
131+
style="display: none"
132+
role="listbox"
133+
aria-multiselectable="true"
134+
{% if label %}aria-labelledby="field-{{ name }}-label"{% endif %}>
135+
<div class="dropdown__list" x-ref="list">
136+
<template x-for="option in filtered" :key="option.value">
137+
<div class="multiselect__item"
138+
role="option"
139+
:aria-selected="isSelected(option.value)"
140+
:data-value="option.value"
141+
@click="toggle(option.value)"
142+
@keydown.enter.prevent="toggle(option.value)"
143+
@keydown.space.prevent="toggle(option.value)"
144+
@keydown.arrow-down.prevent="$el.nextElementSibling?.focus()"
145+
@keydown.arrow-up.prevent="$el.previousElementSibling?.hasAttribute('data-value') ? $el.previousElementSibling.focus() : $refs.searchInput.focus()"
146+
tabindex="0">
147+
<span class="multiselect__check"
148+
:class="{ 'multiselect__check--active': isSelected(option.value) }">
149+
{% include "includes/icon.html" with icon_name="check" icon_class="multiselect__check-icon" x_show="isSelected(option.value)" x_cloak="true" %}
150+
</span>
151+
<span x-text="option.label"></span>
152+
</div>
153+
</template>
154+
<div class="dropdown__empty" x-show="filtered.length === 0">No results found</div>
155+
</div>
156+
</div>
157+
</div>
158+
<!-- Hidden inputs for form submission -->
159+
<template x-for="val in selectedValues" :key="val">
160+
<input type="hidden" :name="'{{ name }}'" :value="val">
161+
</template>
162+
{% if error %}
163+
<p class="field__error" id="field-{{ name }}-error" role="alert">{{ error }}</p>
164+
{% elif help_text %}
165+
<p class="field__help" id="field-{{ name }}-help">{{ help_text }}</p>
166+
{% endif %}
167+
</div>

templates/v3/includes/_library_filter.html

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
Variables:
99
filter_id (required) — unique ID for the modal (e.g. "library-filter")
1010
fields (required) — list of field dicts, each with:
11-
type — "dropdown" or "combo"
12-
name — input name attribute
13-
label — label text
14-
options — list of (value, label) 2-tuples
15-
selected — pre-selected value
16-
width — "narrow" | "wide" (omit for default)
17-
placeholder — placeholder text (combo type only)
11+
type — "dropdown" (default), "combo", or "combo_multi"
12+
name — input name attribute
13+
label — label text
14+
options — list of (value, label) 2-tuples
15+
selected — pre-selected value (dropdown/combo)
16+
selected_values — list of pre-selected values (combo_multi)
17+
width — "narrow" | "wide" (omit for default)
18+
placeholder — placeholder text for search bar (combo/combo_multi only)
1819

1920
Usage:
2021
{% include "v3/includes/_library_filter.html" with filter_id="library-filter" fields=filter_fields %}
@@ -46,7 +47,9 @@
4647
{% for field in fields %}
4748
{% with width=field.width|default:"default" %}
4849
{% with field_class="library-filter__field library-filter__field-"|add:width %}
49-
{% if field.type == "combo" %}
50+
{% if field.type == "combo_multi" %}
51+
{% include "v3/includes/_field_combo_multi.html" with name=field.name label=field.label options=field.options selected_values=field.selected_values placeholder=field.placeholder extra_class=field_class %}
52+
{% elif field.type == "combo" %}
5053
{% include "v3/includes/_field_combo.html" with name=field.name label=field.label options=field.options selected=field.selected placeholder=field.placeholder extra_class=field_class %}
5154
{% else %}
5255
{% include "v3/includes/_field_dropdown.html" with name=field.name label=field.label options=field.options selected=field.selected extra_class=field_class %}

0 commit comments

Comments
 (0)