Skip to main content
brickwork
GitHub Log in Get started
Display
Theme
Density
Direction
Brand

Documentation

Combobox

A progressively enhanced filterable select that keeps a native select as the submitted control.

All components Component Brickwork 4.3.1

Try it

Server-filtered countries

Open full-page preview

A bound form field with remote filtering.

Use for a long or remote-filtered choice list, where plain select behaviour must remain available.

Use it

Working invocation

The live demonstration uses this site's routes. For a portable implementation, download the complete Django and Brickwork cookbook: every file below is shown exactly as it ships in the archive.

Download the consumer cookbook

component_cookbook/views.py

Python

"""Deterministic interaction endpoints: no models, storage, or external calls."""
from __future__ import annotations

from django.contrib import messages
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.cache import patch_vary_headers
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods, require_POST

from .forms import FilterForm, LanguageForm, MessageForm, UploadForm

LANGUAGES = (("python", "Python"), ("django", "Django"), ("htmx", "HTMX"), ("alpine", "Alpine"))
ROWS = tuple({"name": name, "status": status} for name, status in (
    ("Modal response", "ready"), ("Combobox endpoint", "draft"), ("Toast delivery", "ready"),
    ("No-JavaScript floor", "draft"), ("Slide-over focus", "ready"), ("Tooltip focus", "draft"),
    ("Dropdown links", "ready"), ("Native disclosure", "draft"), ("Tag input carrier", "ready"),
    ("Dropzone metadata", "draft"), ("Table selection", "ready"), ("Search request", "draft"),
))


def _htmx(request: HttpRequest) -> bool:
    return request.headers.get("HX-Request") == "true"


def _branch(request: HttpRequest, partial: str, page: str, context: dict, status: int = 200) -> HttpResponse:
    response = render(request, partial if _htmx(request) else page, context, status=status)
    patch_vary_headers(response, ("HX-Request",))
    return response


def _page(request: HttpRequest, template: str, **context) -> HttpResponse:
    return render(request, template, context)


def index(request): return _page(request, "component_cookbook/index.html")
def dropdown(request):
    return _page(request, "component_cookbook/dropdown.html", items=[
        {"label": _("Open cookbook"), "url": reverse("component_cookbook:index"), "icon": "external-link"},
        {"divider": True}, {"label": _("Delete draft"), "url": "#delete", "variant": "danger", "icon": "trash"},
    ])
def tabs(request):
    active = request.GET.get("tab", "overview")
    if active not in {"overview", "api"}:
        return HttpResponseBadRequest("Unknown cookbook tab.")
    return _page(request, "component_cookbook/tabs.html", active=active, tabs=[{"key": "overview", "label": _("Overview")}, {"key": "api", "label": "API"}], overview=mark_safe("<p>Overview content comes from this consumer template.</p>"), api=mark_safe("<p>The API panel uses the same package tab relationship.</p>"))
def tooltip(request): return _page(request, "component_cookbook/tooltip.html", tooltip_id="cookbook-tooltip", text=_("Explains the button"), placement="top")
def disclosure(request): return _page(request, "component_cookbook/disclosure.html")
def tag_input(request): return _page(request, "component_cookbook/tag_input.html")


def modal(request):
    return _branch(request, "component_cookbook/partials/modal.html", "component_cookbook/modal.html", {"form": MessageForm(), "modal_id": "cookbook-message-modal", "close_href": reverse("component_cookbook:index")})


@require_POST
def modal_submit(request):
    form = MessageForm(request.POST)
    context = {"form": form, "modal_id": "cookbook-message-modal", "close_href": reverse("component_cookbook:index")}
    if not form.is_valid():
        return _branch(request, "component_cookbook/partials/modal.html", "component_cookbook/modal.html", context, 422 if _htmx(request) else 200)
    if _htmx(request):
        response = HttpResponse(status=204)
        response["HX-Trigger"] = '{"bw:modal:close": {"id": "cookbook-message-modal"}}'
        return response
    messages.success(request, _("The demo accepted your message without saving it."))
    return redirect("component_cookbook:modal")


def slide_over(request):
    size = request.GET.get("size", "md")
    placement = request.GET.get("placement", "end")
    if size not in {"sm", "md", "lg"} or placement not in {"start", "end"}:
        return HttpResponseBadRequest("Invalid slide-over options.")
    return _branch(request, "component_cookbook/partials/slide_over.html", "component_cookbook/slide_over.html", {"close_href": reverse("component_cookbook:index"), "size": size, "placement": placement})


@require_POST
def toggle(request):
    return _branch(request, "component_cookbook/partials/toggle_outcome.html", "component_cookbook/toggle.html", {"enabled": request.POST.get("notices") == "on"})


@require_POST
def toast(request):
    if not _htmx(request):
        messages.success(request, _("The server delivered this demonstration outcome."))
        response = redirect("component_cookbook:toast-page")
    else:
        response = render(request, "component_cookbook/partials/toast_oob.html")
    patch_vary_headers(response, ("HX-Request",))
    return response


def toast_page(request):
    return _page(request, "component_cookbook/toast_region.html")


def toast_region(request):
    return _page(request, "component_cookbook/toast_region.html")


def combobox(request): return _page(request, "component_cookbook/combobox.html", form=LanguageForm(), options_url=reverse("component_cookbook:combobox-options"))


def combobox_options(request):
    query = request.GET.get("q", "").strip().casefold()[:40]
    return render(request, "component_cookbook/partials/combobox_options.html", {"choices": [choice for choice in LANGUAGES if not query or query in choice[1].casefold()]})


@require_POST
def combobox_submit(request):
    form = LanguageForm(request.POST)
    context = {"form": form, "options_url": reverse("component_cookbook:combobox-options")}
    if form.is_valid():
        context["selected_label"] = dict(LANGUAGES)[form.cleaned_data["language"]]
    return _branch(request, "component_cookbook/partials/combobox_form.html", "component_cookbook/combobox.html", context, 422 if _htmx(request) and not form.is_valid() else 200)


def _rows(query: str, status: str):
    return [row for row in ROWS if (not query or query.casefold() in row["name"].casefold()) and (not status or row["status"] == status)]


def table(request):
    form = FilterForm(request.GET)
    query, status = (form.cleaned_data["q"], form.cleaned_data["status"]) if form.is_valid() else ("", "")
    context = {"filter_form": form, "page_obj": Paginator(_rows(query, status), 4).get_page(request.GET.get("page", 1)), "table_url": reverse("component_cookbook:table"), "form_invalid": not form.is_valid()}
    return _branch(request, "component_cookbook/partials/table_region.html", "component_cookbook/table.html", context)


def search(request):
    query = request.GET.get("q", "").strip()[:40]
    return _page(request, "component_cookbook/search.html", query=query, result_count=len(_rows(query, "")), search_url=reverse("component_cookbook:search"))


@require_http_methods(["GET", "POST"])
def upload(request):
    if request.method == "GET":
        return _page(request, "component_cookbook/upload.html", form=UploadForm())
    form = UploadForm(request.POST, request.FILES)
    context = {"form": form}
    if form.is_valid():
        uploaded = form.cleaned_data["file"]
        context["outcome"] = _("Validated %(name)s (%(size)s bytes). The application did not retain it.") % {"name": uploaded.name, "size": uploaded.size}
        context["form"] = UploadForm()
    return _branch(request, "component_cookbook/partials/upload_form.html", "component_cookbook/upload.html", context, 422 if _htmx(request) and not form.is_valid() else 200)

component_cookbook/forms.py

Python

from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _


class MessageForm(forms.Form):
    message = forms.CharField(max_length=80, help_text=_("Up to 80 characters."))


class LanguageForm(forms.Form):
    language = forms.ChoiceField(choices=(("", _("Choose a language")), ("python", "Python"), ("django", "Django"), ("htmx", "HTMX")))


class FilterForm(forms.Form):
    q = forms.CharField(label=_("Filter examples"), max_length=40, required=False)
    status = forms.ChoiceField(required=False, choices=(("", _("All statuses")), ("ready", _("Ready")), ("draft", _("Draft"))))


class UploadForm(forms.Form):
    file = forms.FileField()

    def clean_file(self):
        uploaded = self.cleaned_data["file"]
        if uploaded.size > 256 * 1024:
            raise ValidationError(_("Choose a file smaller than 256 KB."))
        if uploaded.content_type not in {"text/plain", "application/pdf"}:
            raise ValidationError(_("Choose a plain text file or PDF."))
        return uploaded

component_cookbook/urls.py

Python

from django.urls import path

from . import views

app_name = "component_cookbook"

urlpatterns = [
    path("", views.index, name="index"),
    path("dropdown/", views.dropdown, name="dropdown"),
    path("tabs/", views.tabs, name="tabs"),
    path("tooltip/", views.tooltip, name="tooltip"),
    path("disclosure/", views.disclosure, name="disclosure"),
    path("tag-input/", views.tag_input, name="tag-input"),
    path("modal/", views.modal, name="modal"),
    path("modal/submit/", views.modal_submit, name="modal-submit"),
    path("slide-over/", views.slide_over, name="slide-over"),
    path("toggle/", views.toggle, name="toggle"),
    path("toasts/", views.toast_page, name="toast-page"),
    path("toast-region/", views.toast_region, name="toast-region"),
    path("toast/", views.toast, name="toast"),
    path("combobox/", views.combobox, name="combobox"),
    path("combobox/options/", views.combobox_options, name="combobox-options"),
    path("combobox/submit/", views.combobox_submit, name="combobox-submit"),
    path("table/", views.table, name="table"),
    path("search/", views.search, name="search"),
    path("upload/", views.upload, name="upload"),
]

templates/component_cookbook/partials/combobox_form.html

Django template

{% load brickwork_interactions i18n %}<form id="cookbook-combobox-form" method="post" action="{% url 'component_cookbook:combobox-submit' %}" hx-post="{% url 'component_cookbook:combobox-submit' %}" hx-target="this" hx-swap="outerHTML">{% csrf_token %}{% bw_combobox field=form.language options_url=options_url filter_mode="server" placeholder="Search languages" %}<button class="bw-btn bw-btn--primary" type="submit">{% translate "Confirm choice" %}</button>{% if selected_label %}<p role="status">{% blocktranslate %}Selected {{ selected_label }} without saving it.{% endblocktranslate %}</p>{% endif %}</form>

templates/component_cookbook/partials/combobox_options.html

Django template

{% load i18n %}{% for value, label in choices %}<li class="bw-combobox__option" role="option" data-bw-value="{{ value }}">{{ label }}</li>{% empty %}<li class="bw-combobox__empty" role="presentation">{% translate "No matching language" %}</li>{% endfor %}

templates/component_cookbook/combobox.html

Django template

{% extends "component_cookbook/base.html" %}{% load i18n %}{% block heading %}{% translate "Server-filtered combobox" %}{% endblock %}{% block introduction %}{% translate "Options are a bounded local list and selection is not persisted." %}{% endblock %}{% block cookbook_content %}{% include "component_cookbook/partials/combobox_form.html" %}{% endblock %}

templates/component_cookbook/base.html

Django template

{% extends "brickwork/shell/centred.html" %}
{% load static i18n %}
{% block page_title %}{% translate "Brickwork component cookbook" %}{% endblock %}
{% block head_js %}<script>window.BRICKWORK_JS_URL = "{% static 'brickwork/dist/brickwork.js' %}";</script>{% endblock %}
{% block body_js %}
<script src="{% static 'component_cookbook/vendor/htmx.min.js' %}"></script>
<script type="module">
import Alpine from "{% static 'component_cookbook/vendor/alpine.module.js' %}";
import focus from "{% static 'component_cookbook/vendor/focus.module.js' %}";
window.htmx.config.responseHandling = [{code: "204", swap: false}, {code: "422", swap: true}, {code: "[23]..", swap: true}, {code: "[45]..", swap: false, error: true}];
Alpine.plugin(focus);
const { registerBrickworkComponents } = await import(window.BRICKWORK_JS_URL);
registerBrickworkComponents(Alpine);
window.Alpine = Alpine;
Alpine.start();
</script>
{% endblock %}
{% block content %}
<header class="bw-stack bw-stack--sm"><p><a href="{% url 'component_cookbook:index' %}">{% translate "Component cookbook" %}</a></p><h1>{% block heading %}{% endblock %}</h1><p>{% block introduction %}{% endblock %}</p></header>
{% if messages %}{% for message in messages %}<p class="bw-alert bw-alert--success" role="status">{{ message }}</p>{% endfor %}{% endif %}
{% block cookbook_content %}{% endblock %}
{% endblock %}

templates/your_app/component.html

Django template

{% load brickwork_interactions %}
{% bw_combobox field=form.country filter_mode=filter_mode placeholder=placeholder %}

your_app/views.py

Python

from django import forms
class _CountryForm(forms.Form):
    country = forms.ChoiceField(choices=(("gb", "United Kingdom"), ("ie", "Ireland"), ("pt", "Portugal")))
__FORM__ = _CountryForm()
context = {'filter_mode': 'client', 'placeholder': 'Find a country'}
context['form'] = __FORM__

Guide

Add it to your project

  1. Load `brickwork_interactions`.
  2. Pass either a bound choice `field`, or explicit `name`, `options`, and `selected`.
  3. For the default server filtering, provide `options_url`.
  4. Initialise Brickwork interactions for the richer search interface.

View this component's source for the installed release

Options

Public API

Option Type Default Required Description
field BoundField Not set No Bound Django choice field; takes precedence over explicit inputs. Constraint: Pass a Django BoundField from the form being rendered; do not substitute a raw string or unrelated widget markup.
name str Empty string No Id-safe explicit field name. Constraint: With no field, requires options and selected.
options list[tuple]|mapping Not set No Explicit choices. Constraint: Use local `(value, label)` choices or mapping records matching the selected-value type; server filtering needs `options_url`.
selected str|list Not set No Explicit selected value or values. Constraint: Use values that occur in the option records and match `multiple`.
filter_mode str server No Where filtering happens. Allowed: server, client. Constraint: Use `server` only with `options_url`; otherwise supply local options for client filtering.
options_url str Empty string No Server option endpoint. Constraint: Required when filter_mode is server.
multiple bool False No Native multi-select and enhanced multiple selection. Constraint: Match the receiving form field and selected-value shape.
allow_create bool False No Allows a new value in enhanced UI. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
empty_message str No matches No No-match copy. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
placeholder str Empty string No Enhanced input placeholder. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.

Behaviour

Accessibility and responsive behaviour

Accessibility
The native select remains the submitted control, and bound-field mode keeps normal label, help, and error wiring.
Responsive
No documented breakpoint-specific behaviour.
Without JavaScript
The native select is complete and visible before JavaScript enhancement.

Need help?

Troubleshooting

Contract check: validation errors through POST data, never through Alpine state.

Return a bound invalid POST response with the field errors; do not try to communicate validation only through Alpine state.

Documentation