Select
Enhances the native <select> with search, tags and server-side search. The original
element stays in the form holding the value: name, multiple,
required and request.POST.getlist() work as always.
Examples
Everything below is the same <select> with different attributes.
Groups and a disabled option
<optgroup> becomes a group heading; disabled is respected on the option, on the whole group and on the <select> itself, which neither opens nor clears.
Choice limit
data-max-items="3" stops at the third.
Tags on several lines
data-wrap-tags="true" lets the field grow instead of scrolling.
With an error
aria-invalid="true" on the native element — Django 5 already writes it.
Choose a state.
How to use
Mark the <select> and it initializes by itself, including the ones that arrive later through HTMX.
The first <option value=""> becomes the placeholder text, not an option.
<select name="state" data-tuc-select>
<option value="">Select...</option>
<option value="SP">São Paulo</option>
</select>
<select name="tags" data-tuc-select multiple data-placeholder="Add...">
<option value="django">Django</option>
</select>In the Django form
class CustomerForm(forms.ModelForm):
class Meta:
model = Customer
fields = ["state", "tags"]
widgets = {
"state": forms.Select(attrs={"data-tuc-select": ""}),
"tags": forms.SelectMultiple(attrs={"data-tuc-select": "", "data-max-items": "3"}),
}In JavaScript
For options that don't exist as attributes, or to read and write the value.
const s = new Tucano.Select('#state', { search: true, maxItems: 3 });
s.getValue(); // 'SP' — an array when multiple
s.setValue(['SP', 'PR']);
s.refresh(); // rereads the <option> elements after HTMX replaces themServer-side search
For lists too large to come whole in the page. On every keystroke the component asks the server, instead of filtering what is already loaded.
On this page the response is simulated with a 400 ms delay; in your project it comes from data-url.
<select name="customer" data-tuc-select data-url="{% url 'api-customers' %}"></select>def customers(request):
q = request.GET.get("q", "")
qs = Customer.objects.filter(name__icontains=q)[:20]
return JsonResponse([{"value": c.pk, "label": c.name} for c in qs], safe=False)| Attribute | Default | What for |
|---|---|---|
data-url | — | Turns on server-side search |
data-query-param | q | Name of the search term parameter in the URL |
data-min-chars | 1 | Characters typed before asking |
data-debounce | 300 | Wait, in ms, after the last keystroke |
data-page-param | page | Loads the next page on scroll; false turns it off |
data-cache | true | Keeps the result of each term |
Accepts the response in several formats
So the server doesn't have to change because of this: [{value, label}], ["a", "b"], DRF's {results: [...]} and Select2's {id, text}. For any other source there is loadOptions.
Why JSON, and not HTMX, here
The list lives inside the panel the component redraws. If HTMX also wrote there, the same DOM would have two owners. And for a list of options JSON is the right contract: HTML would have to be parsed back into objects.
Value and form
The value is held by the native <select>, hidden next to the control. Django receives
exactly what it would receive without the component.
POST state = SP
POST tags = django # request.POST.getlist("tags")
POST tags = htmxEvery choice fires tucano:change on the <select> itself, and also the native change — so HTMX's hx-trigger="change" works with no adjustment.
document.querySelector('#state').addEventListener('tucano:change', (e) => {
e.detail.value; // 'SP', or an array when multiple
e.detail.instance; // the Select
});Keyboard and accessibility
The control is a combobox with a listbox, and announces open, closed and the active option.
Arriving with Tab doesn't open the panel — that's the rule of the native <select>.
| Key | Action |
|---|---|
↓ Enter Space | Opens the panel |
↑ ↓ | Moves between options, skipping disabled ones |
Home End | First and last option, skipping disabled ones |
Enter | Chooses the active option |
Backspace | With the search empty, removes the last tag |
Backspace Delete | In single mode, with the search empty, clears the value, like the X |
Esc | Closes and returns focus to the field |
Search ignores accents
sao finds "São Paulo". And the field has a fixed height on purpose: with tags wrapping it would grow and misalign the form — the overflow scrolls horizontally, unless you use data-wrap-tags.
API
Generated from the code on every build — if something is not here, it does not exist.
select[data-tuc-select]new Tucano.Select(alvo, opcoes)data-cache data-clearable data-close-on-select data-debounce data-empty-text data-max-items data-min-chars data-page-param data-query-param data-search data-short-circuit data-url data-wrap-tagsgetValue setValue clear refresh open close toggle destroytucano:changeOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
search | undefined | default: liga a partir de 6 opcoes |
searchMinItems | 6 | |
placeholder | undefined | default: do atributo, da <option value=""> ou setTexts ("Selecione...") |
searchPlaceholder | undefined | default: setTexts ("Buscar...") |
emptyText | undefined | default: setTexts ("Nenhum resultado") |
clearable | true | |
maxItems | null | limite no modo multiplo |
wrapTags | false | true deixa o campo crescer em varias linhas |
closeOnSelect | undefined | default: true em simples, false em multiplo |
placement | 'bottom-start' | |
appendTo | undefined | |
url | null | com url, a lista vem do servidor a cada digitacao |
loadOptions | null | (termo) => Promise<[{value,label,disabled,group}]> |
queryParam | 'q' | |
pageParam | 'page' | paginacao ao rolar; null desliga |
minChars | 1 | |
debounce | 300 | |
cache | true | guarda o resultado de cada termo |
cacheSize | 60 | |
shortCircuit | false | ver _noChance() |
loadingText | undefined | default: setTexts ("Buscando...") |
errorText | undefined | default: setTexts ("Falha ao buscar") |
onChange | null |