Tucano v0.37.2

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 them

Server-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)
AttributeDefaultWhat for
data-url—Turns on server-side search
data-query-paramqName of the search term parameter in the URL
data-min-chars1Characters typed before asking
data-debounce300Wait, in ms, after the last keystroke
data-page-parampageLoads the next page on scroll; false turns it off
data-cachetrueKeeps 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  = htmx

Every 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>.

KeyAction
↓ Enter SpaceOpens the panel
↑ ↓Moves between options, skipping disabled ones
Home EndFirst and last option, skipping disabled ones
EnterChooses the active option
BackspaceWith the search empty, removes the last tag
Backspace DeleteIn single mode, with the search empty, clears the value, like the X
EscCloses 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.

Markup
select[data-tuc-select]
In JS
new Tucano.Select(alvo, opcoes)
Attributes
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-tags
Methods
getValue setValue clear refresh open close toggle destroy
Events
tucano:change

Options

The notes in this table come from comments in the source code, which are written in Portuguese.

OptionDefaultWhat for
searchundefineddefault: liga a partir de 6 opcoes
searchMinItems6
placeholderundefineddefault: do atributo, da <option value=""> ou setTexts ("Selecione...")
searchPlaceholderundefineddefault: setTexts ("Buscar...")
emptyTextundefineddefault: setTexts ("Nenhum resultado")
clearabletrue
maxItemsnulllimite no modo multiplo
wrapTagsfalsetrue deixa o campo crescer em varias linhas
closeOnSelectundefineddefault: true em simples, false em multiplo
placement'bottom-start'
appendToundefined
urlnullcom url, a lista vem do servidor a cada digitacao
loadOptionsnull(termo) => Promise<[{value,label,disabled,group}]>
queryParam'q'
pageParam'page'paginacao ao rolar; null desliga
minChars1
debounce300
cachetrueguarda o resultado de cada termo
cacheSize60
shortCircuitfalsever _noChance()
loadingTextundefineddefault: setTexts ("Buscando...")
errorTextundefineddefault: setTexts ("Falha ao buscar")
onChangenull